diff --git a/api/client.go b/api/client.go index f8ff619e..b367797a 100644 --- a/api/client.go +++ b/api/client.go @@ -107,6 +107,7 @@ func ProxyGetIssueRaw(c *jira.Client, key string) (string, error) { // ProxyGetIssue uses either a v2 or v3 version of the Jira GET /issue/{key} // endpoint to fetch the issue details based on configured installation type. // Defaults to v3 if installation type is not defined in the config. +// Also fetches remote links for the issue. func ProxyGetIssue(c *jira.Client, key string, opts ...filter.Filter) (*jira.Issue, error) { var ( iss *jira.Issue @@ -121,6 +122,19 @@ func ProxyGetIssue(c *jira.Client, key string, opts ...filter.Filter) (*jira.Iss iss, err = c.GetIssue(key, opts...) } + if err != nil { + return iss, err + } + + // Fetch remote links for the issue + remoteLinks, err := c.GetIssueRemoteLinks(key) + if err != nil { + // Don't fail the entire request if remote links can't be fetched + // Just log and continue without remote links + remoteLinks = []jira.RemoteLink{} + } + iss.Fields.RemoteLinks = remoteLinks + return iss, err } diff --git a/go.sum b/go.sum index ff292e4f..e8f3b281 100644 --- a/go.sum +++ b/go.sum @@ -22,7 +22,7 @@ github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2 github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM= +github.com/charmbracelet/glamour v0.9.1 h1:Q7PdJLOx8EoepsXUvW6Puz5WQ3YUElIGQdYKrIpiGLA= github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= diff --git a/internal/cmd/issue/view/view.go b/internal/cmd/issue/view/view.go index cf3eed41..f752ccbf 100644 --- a/internal/cmd/issue/view/view.go +++ b/internal/cmd/issue/view/view.go @@ -1,6 +1,7 @@ package view import ( + "encoding/json" "fmt" "github.com/spf13/cobra" @@ -78,7 +79,20 @@ func viewRaw(cmd *cobra.Command, args []string) { defer s.Stop() client := api.DefaultClient(debug) - return api.ProxyGetIssueRaw(client, key) + + // Fetch the issue with remote links included (same as structured view) + issue, err := api.ProxyGetIssue(client, key) + if err != nil { + return "", err + } + + // Convert back to JSON for raw output + rawJSON, err := json.MarshalIndent(issue, "", " ") + if err != nil { + return "", err + } + + return string(rawJSON), nil }() cmdutil.ExitIfError(err) diff --git a/internal/view/issue.go b/internal/view/issue.go index 8386d480..29484d0d 100644 --- a/internal/view/issue.go +++ b/internal/view/issue.go @@ -109,6 +109,9 @@ func (i Issue) String() string { if len(i.Data.Fields.IssueLinks) > 0 { s.WriteString(fmt.Sprintf("\n\n%s\n\n%s\n", i.separator("Linked Issues"), i.linkedIssues())) } + if len(i.Data.Fields.RemoteLinks) > 0 { + s.WriteString(fmt.Sprintf("\n\n%s\n\n%s\n", i.separator("External Links"), i.remoteLinks())) + } total := i.Data.Fields.Comment.Total if total > 0 && i.Options.NumComments > 0 { sep := fmt.Sprintf("%d Comments", total) @@ -160,6 +163,17 @@ func (i Issue) fragments() []fragment { ) } + if len(i.Data.Fields.RemoteLinks) > 0 { + scraps = append( + scraps, + newBlankFragment(1), + fragment{Body: i.separator("External Links")}, + newBlankFragment(2), + fragment{Body: i.remoteLinks()}, + newBlankFragment(1), + ) + } + if i.Data.Fields.Comment.Total > 0 && i.Options.NumComments > 0 { scraps = append( scraps, @@ -378,6 +392,40 @@ func (i Issue) linkedIssues() string { return linked.String() } +func (i Issue) remoteLinks() string { + if len(i.Data.Fields.RemoteLinks) == 0 { + return "" + } + + var ( + remote strings.Builder + maxTitleLen int + summaryLen = defaultSummaryLength + ) + + // Calculate max lengths for formatting + for _, link := range i.Data.Fields.RemoteLinks { + maxTitleLen = max(len(link.Object.Title), maxTitleLen) + } + + if maxTitleLen < summaryLen { + summaryLen = maxTitleLen + } + + remote.WriteString("\n") + for _, link := range i.Data.Fields.RemoteLinks { + remote.WriteString( + fmt.Sprintf( + " %s\n %s\n\n", + coloredOut(shortenAndPad(link.Object.Title, summaryLen), color.FgCyan, color.Bold), + coloredOut(link.Object.URL, color.FgBlue, color.Underline), + ), + ) + } + + return remote.String() +} + func (i Issue) comments() []issueComment { total := i.Data.Fields.Comment.Total comments := make([]issueComment, 0, total) diff --git a/pkg/jira/issue.go b/pkg/jira/issue.go index ef24c75d..a381973b 100644 --- a/pkg/jira/issue.go +++ b/pkg/jira/issue.go @@ -460,6 +460,37 @@ func (c *Client) RemoteLinkIssue(issueID, title, url string) error { return nil } +// GetIssueRemoteLinks fetches remote links for an issue using GET /issue/{issueId}/remotelink endpoint. +func (c *Client) GetIssueRemoteLinks(issueID string) ([]RemoteLink, error) { + path := fmt.Sprintf("/issue/%s/remotelink", issueID) + + res, err := c.GetV2(context.Background(), path, nil) + if err != nil { + return nil, err + } + if res == nil { + return nil, ErrEmptyResponse + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return nil, formatUnexpectedResponse(res) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + var remoteLinks []RemoteLink + err = json.Unmarshal(body, &remoteLinks) + if err != nil { + return nil, err + } + + return remoteLinks, nil +} + // WatchIssue adds user as a watcher using v2 version of the POST /issue/{key}/watchers endpoint. func (c *Client) WatchIssue(key, watcher string) error { return c.watchIssue(key, watcher, apiVersion3) diff --git a/pkg/jira/types.go b/pkg/jira/types.go index e1c17719..6ce38691 100644 --- a/pkg/jira/types.go +++ b/pkg/jira/types.go @@ -122,8 +122,9 @@ type IssueFields struct { InwardIssue *Issue `json:"inwardIssue,omitempty"` OutwardIssue *Issue `json:"outwardIssue,omitempty"` } `json:"issueLinks"` - Created string `json:"created"` - Updated string `json:"updated"` + RemoteLinks []RemoteLink `json:"remoteLinks,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` } // Field holds field info. @@ -165,6 +166,16 @@ type IssueLinkType struct { Outward string `json:"outward"` } +// RemoteLink holds remote link info. +type RemoteLink struct { + ID int `json:"id"` + Self string `json:"self"` + Object struct { + URL string `json:"url"` + Title string `json:"title"` + } `json:"object"` +} + // Sprint holds sprint info. type Sprint struct { ID int `json:"id"`