From c6e7cc05590723fe77f0fe27307efc093e0be977 Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 18 Feb 2026 12:23:10 -0500 Subject: [PATCH 1/2] feat(links): add first-class support for issue links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `jtk links` command group with full CRUD support for Jira issue links: - `jtk links list ` — list all links on an issue - `jtk links create --type ` — create a link - `jtk links delete ` — delete a link - `jtk links types` — list available link types Includes API layer (api/links.go) and comprehensive unit tests for both API operations and command handlers. --- tools/jtk/api/links.go | 130 ++++++++++ tools/jtk/api/links_test.go | 124 ++++++++++ tools/jtk/cmd/jtk/main.go | 2 + tools/jtk/internal/cmd/links/links.go | 265 +++++++++++++++++++++ tools/jtk/internal/cmd/links/links_test.go | 181 ++++++++++++++ 5 files changed, 702 insertions(+) create mode 100644 tools/jtk/api/links.go create mode 100644 tools/jtk/api/links_test.go create mode 100644 tools/jtk/internal/cmd/links/links.go create mode 100644 tools/jtk/internal/cmd/links/links_test.go diff --git a/tools/jtk/api/links.go b/tools/jtk/api/links.go new file mode 100644 index 0000000..c0ca5f1 --- /dev/null +++ b/tools/jtk/api/links.go @@ -0,0 +1,130 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// IssueLinkType represents a type of link between issues (e.g., "Blocks", "Relates") +type IssueLinkType struct { + ID string `json:"id"` + Name string `json:"name"` + Inward string `json:"inward"` + Outward string `json:"outward"` +} + +// IssueLink represents a link between two issues +type IssueLink struct { + ID string `json:"id"` + Type IssueLinkType `json:"type"` + // Exactly one of InwardIssue or OutwardIssue will be set when reading links from an issue + InwardIssue *LinkedIssue `json:"inwardIssue,omitempty"` + OutwardIssue *LinkedIssue `json:"outwardIssue,omitempty"` +} + +// LinkedIssue represents the summary info of a linked issue +type LinkedIssue struct { + ID string `json:"id"` + Key string `json:"key"` + Fields struct { + Summary string `json:"summary"` + Status *Status `json:"status,omitempty"` + IssueType *IssueType `json:"issuetype,omitempty"` + } `json:"fields"` +} + +// CreateIssueLinkRequest represents a request to create a link between two issues +type CreateIssueLinkRequest struct { + Type IssueLinkTypeRef `json:"type"` + InwardIssue IssueRef `json:"inwardIssue"` + OutwardIssue IssueRef `json:"outwardIssue"` +} + +// IssueLinkTypeRef identifies a link type by name +type IssueLinkTypeRef struct { + Name string `json:"name"` +} + +// IssueRef identifies an issue by key +type IssueRef struct { + Key string `json:"key"` +} + +// GetIssueLinks returns the links on an issue by fetching the issue and extracting the issuelinks field +func (c *Client) GetIssueLinks(issueKey string) ([]IssueLink, error) { + if issueKey == "" { + return nil, ErrIssueKeyRequired + } + + urlStr := buildURL( + fmt.Sprintf("%s/issue/%s", c.BaseURL, url.PathEscape(issueKey)), + map[string]string{"fields": "issuelinks"}, + ) + + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result struct { + Fields struct { + IssueLinks []IssueLink `json:"issuelinks"` + } `json:"fields"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse issue links: %w", err) + } + + return result.Fields.IssueLinks, nil +} + +// CreateIssueLink creates a link between two issues +func (c *Client) CreateIssueLink(outwardKey, inwardKey, linkTypeName string) error { + if outwardKey == "" || inwardKey == "" { + return ErrIssueKeyRequired + } + if linkTypeName == "" { + return fmt.Errorf("link type name is required") + } + + urlStr := fmt.Sprintf("%s/issueLink", c.BaseURL) + req := CreateIssueLinkRequest{ + Type: IssueLinkTypeRef{Name: linkTypeName}, + OutwardIssue: IssueRef{Key: outwardKey}, + InwardIssue: IssueRef{Key: inwardKey}, + } + + _, err := c.post(urlStr, req) + return err +} + +// DeleteIssueLink deletes an issue link by its ID +func (c *Client) DeleteIssueLink(linkID string) error { + if linkID == "" { + return fmt.Errorf("link ID is required") + } + + urlStr := fmt.Sprintf("%s/issueLink/%s", c.BaseURL, url.PathEscape(linkID)) + _, err := c.delete(urlStr) + return err +} + +// GetIssueLinkTypes returns all available issue link types +func (c *Client) GetIssueLinkTypes() ([]IssueLinkType, error) { + urlStr := fmt.Sprintf("%s/issueLinkType", c.BaseURL) + + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result struct { + IssueLinkTypes []IssueLinkType `json:"issueLinkTypes"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse link types: %w", err) + } + + return result.IssueLinkTypes, nil +} diff --git a/tools/jtk/api/links_test.go b/tools/jtk/api/links_test.go new file mode 100644 index 0000000..e4d94e2 --- /dev/null +++ b/tools/jtk/api/links_test.go @@ -0,0 +1,124 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetIssueLinks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue/PROJ-123", r.URL.Path) + assert.Equal(t, "issuelinks", r.URL.Query().Get("fields")) + + json.NewEncoder(w).Encode(map[string]interface{}{ + "fields": map[string]interface{}{ + "issuelinks": []map[string]interface{}{ + { + "id": "10001", + "type": map[string]string{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, + "outwardIssue": map[string]interface{}{ + "key": "PROJ-456", + "fields": map[string]interface{}{ + "summary": "Other issue", + }, + }, + }, + }, + }, + }) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + links, err := client.GetIssueLinks("PROJ-123") + require.NoError(t, err) + require.Len(t, links, 1) + assert.Equal(t, "10001", links[0].ID) + assert.Equal(t, "Blocks", links[0].Type.Name) + require.NotNil(t, links[0].OutwardIssue) + assert.Equal(t, "PROJ-456", links[0].OutwardIssue.Key) +} + +func TestGetIssueLinks_EmptyKey(t *testing.T) { + _, err := (&Client{}).GetIssueLinks("") + assert.Equal(t, ErrIssueKeyRequired, err) +} + +func TestCreateIssueLink(t *testing.T) { + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issueLink", r.URL.Path) + assert.Equal(t, "POST", r.Method) + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + err = client.CreateIssueLink("PROJ-123", "PROJ-456", "Blocks") + require.NoError(t, err) + + var req CreateIssueLinkRequest + err = json.Unmarshal(capturedBody, &req) + require.NoError(t, err) + assert.Equal(t, "Blocks", req.Type.Name) + assert.Equal(t, "PROJ-123", req.OutwardIssue.Key) + assert.Equal(t, "PROJ-456", req.InwardIssue.Key) +} + +func TestCreateIssueLink_EmptyKeys(t *testing.T) { + assert.Error(t, (&Client{}).CreateIssueLink("", "B", "t")) + assert.Error(t, (&Client{}).CreateIssueLink("A", "", "t")) + assert.Error(t, (&Client{}).CreateIssueLink("A", "B", "")) +} + +func TestDeleteIssueLink(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issueLink/10001", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + err = client.DeleteIssueLink("10001") + require.NoError(t, err) +} + +func TestDeleteIssueLink_EmptyID(t *testing.T) { + assert.Error(t, (&Client{}).DeleteIssueLink("")) +} + +func TestGetIssueLinkTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issueLinkType", r.URL.Path) + json.NewEncoder(w).Encode(map[string]interface{}{ + "issueLinkTypes": []map[string]string{ + {"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, + {"id": "2", "name": "Relates", "inward": "relates to", "outward": "relates to"}, + }, + }) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + types, err := client.GetIssueLinkTypes() + require.NoError(t, err) + require.Len(t, types, 2) + assert.Equal(t, "Blocks", types[0].Name) + assert.Equal(t, "Relates", types[1].Name) +} diff --git a/tools/jtk/cmd/jtk/main.go b/tools/jtk/cmd/jtk/main.go index b0ecad2..b7f82e1 100644 --- a/tools/jtk/cmd/jtk/main.go +++ b/tools/jtk/cmd/jtk/main.go @@ -16,6 +16,7 @@ import ( "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/fields" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/initcmd" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/issues" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/links" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/me" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/projects" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" @@ -41,6 +42,7 @@ func run() error { issues.Register(rootCmd, opts) transitions.Register(rootCmd, opts) comments.Register(rootCmd, opts) + links.Register(rootCmd, opts) attachments.Register(rootCmd, opts) automation.Register(rootCmd, opts) boards.Register(rootCmd, opts) diff --git a/tools/jtk/internal/cmd/links/links.go b/tools/jtk/internal/cmd/links/links.go new file mode 100644 index 0000000..12764e1 --- /dev/null +++ b/tools/jtk/internal/cmd/links/links.go @@ -0,0 +1,265 @@ +package links + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +// Register registers the links commands +func Register(parent *cobra.Command, opts *root.Options) { + cmd := &cobra.Command{ + Use: "links", + Aliases: []string{"link", "l"}, + Short: "Manage issue links", + Long: "Commands for listing, creating, and deleting issue links.", + } + + cmd.AddCommand(newListCmd(opts)) + cmd.AddCommand(newCreateCmd(opts)) + cmd.AddCommand(newDeleteCmd(opts)) + cmd.AddCommand(newTypesCmd(opts)) + + parent.AddCommand(cmd) +} + +func newListCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List links on an issue", + Long: "List all links on a specific issue.", + Example: ` jtk links list PROJ-123 + jtk links list PROJ-123 -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runList(opts, args[0]) + }, + } + + return cmd +} + +func runList(opts *root.Options, issueKey string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + links, err := client.GetIssueLinks(issueKey) + if err != nil { + return err + } + + if len(links) == 0 { + v.Info("No links on %s", issueKey) + return nil + } + + if opts.Output == "json" { + return v.JSON(links) + } + + headers := []string{"ID", "TYPE", "DIRECTION", "ISSUE", "SUMMARY"} + var rows [][]string + + for _, link := range links { + var direction, key, summary string + + if link.OutwardIssue != nil { + direction = link.Type.Outward + key = link.OutwardIssue.Key + summary = link.OutwardIssue.Fields.Summary + } else if link.InwardIssue != nil { + direction = link.Type.Inward + key = link.InwardIssue.Key + summary = link.InwardIssue.Fields.Summary + } + + rows = append(rows, []string{ + link.ID, + link.Type.Name, + direction, + key, + summary, + }) + } + + return v.Table(headers, rows) +} + +func newCreateCmd(opts *root.Options) *cobra.Command { + var linkType string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a link between two issues", + Long: `Create a link between two issues. + +The first issue is the outward issue and the second is the inward issue. +For example, "jtk links create A B --type Blocks" means "A blocks B".`, + Example: ` # A blocks B + jtk links create PROJ-123 PROJ-456 --type Blocks + + # A relates to B + jtk links create PROJ-123 PROJ-456 --type Relates + + # A is cloned by B + jtk links create PROJ-123 PROJ-456 --type Cloners`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runCreate(opts, args[0], args[1], linkType) + }, + } + + cmd.Flags().StringVarP(&linkType, "type", "t", "", "Link type name (required)") + _ = cmd.MarkFlagRequired("type") + + return cmd +} + +func runCreate(opts *root.Options, outwardKey, inwardKey, linkType string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + // Validate link type exists + linkTypes, err := client.GetIssueLinkTypes() + if err != nil { + return fmt.Errorf("failed to get link types: %w", err) + } + + var found bool + for _, lt := range linkTypes { + if strings.EqualFold(lt.Name, linkType) { + linkType = lt.Name // Use exact casing from server + found = true + break + } + } + + if !found { + var available []string + for _, lt := range linkTypes { + available = append(available, lt.Name) + } + return fmt.Errorf("link type %q not found (available: %s)", linkType, strings.Join(available, ", ")) + } + + if err := client.CreateIssueLink(outwardKey, inwardKey, linkType); err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(map[string]string{ + "status": "created", + "outwardIssue": outwardKey, + "inwardIssue": inwardKey, + "type": linkType, + }) + } + + v.Success("Created %s link: %s → %s", linkType, outwardKey, inwardKey) + return nil +} + +func newDeleteCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an issue link", + Long: "Delete an issue link by its ID. Use 'jtk links list' to find link IDs.", + Example: ` jtk links delete 10001 + jtk links list PROJ-123 # find link IDs first`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(opts, args[0]) + }, + } + + return cmd +} + +func runDelete(opts *root.Options, linkID string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.DeleteIssueLink(linkID); err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(map[string]string{"status": "deleted", "linkId": linkID}) + } + + v.Success("Deleted link %s", linkID) + return nil +} + +func newTypesCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "types", + Short: "List available link types", + Long: "List all available issue link types in the Jira instance.", + Example: ` jtk links types + jtk links types -o json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runTypes(opts) + }, + } + + return cmd +} + +func runTypes(opts *root.Options) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + linkTypes, err := client.GetIssueLinkTypes() + if err != nil { + return err + } + + if len(linkTypes) == 0 { + v.Info("No link types available") + return nil + } + + if opts.Output == "json" { + return v.JSON(linkTypes) + } + + headers := []string{"ID", "NAME", "OUTWARD", "INWARD"} + var rows [][]string + + for _, lt := range linkTypes { + rows = append(rows, []string{ + lt.ID, + lt.Name, + lt.Outward, + lt.Inward, + }) + } + + return v.Table(headers, rows) +} + +// GetIssueLinkTypes returns all link types (exported for use by other commands) +func GetIssueLinkTypes(client *api.Client) ([]api.IssueLinkType, error) { + return client.GetIssueLinkTypes() +} diff --git a/tools/jtk/internal/cmd/links/links_test.go b/tools/jtk/internal/cmd/links/links_test.go new file mode 100644 index 0000000..a032a0c --- /dev/null +++ b/tools/jtk/internal/cmd/links/links_test.go @@ -0,0 +1,181 @@ +package links + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +func TestNewListCmd(t *testing.T) { + opts := &root.Options{} + cmd := newListCmd(opts) + + assert.Equal(t, "list ", cmd.Use) + assert.Equal(t, "List links on an issue", cmd.Short) +} + +func TestRunList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "fields": map[string]interface{}{ + "issuelinks": []map[string]interface{}{ + { + "id": "10001", + "type": map[string]string{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, + "outwardIssue": map[string]interface{}{ + "key": "PROJ-456", + "fields": map[string]string{"summary": "Blocked issue"}, + }, + }, + }, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runList(opts, "PROJ-123") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "PROJ-456") + assert.Contains(t, stdout.String(), "Blocks") +} + +func TestRunList_NoLinks(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "fields": map[string]interface{}{ + "issuelinks": []interface{}{}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout, stderr bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &stderr} + opts.SetAPIClient(client) + + err = runList(opts, "PROJ-123") + require.NoError(t, err) +} + +func TestRunCreate(t *testing.T) { + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rest/api/3/issueLinkType": + json.NewEncoder(w).Encode(map[string]interface{}{ + "issueLinkTypes": []map[string]string{ + {"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, + }, + }) + case "/rest/api/3/issueLink": + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runCreate(opts, "PROJ-123", "PROJ-456", "Blocks") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created") + + var req api.CreateIssueLinkRequest + err = json.Unmarshal(capturedBody, &req) + require.NoError(t, err) + assert.Equal(t, "Blocks", req.Type.Name) + assert.Equal(t, "PROJ-123", req.OutwardIssue.Key) + assert.Equal(t, "PROJ-456", req.InwardIssue.Key) +} + +func TestRunCreate_InvalidType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "issueLinkTypes": []map[string]string{ + {"id": "1", "name": "Blocks"}, + {"id": "2", "name": "Relates"}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + opts := &root.Options{Output: "table", Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runCreate(opts, "A", "B", "InvalidType") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.Contains(t, err.Error(), "Blocks") +} + +func TestRunDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issueLink/10001", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runDelete(opts, "10001") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Deleted") +} + +func TestRunTypes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "issueLinkTypes": []map[string]string{ + {"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}, + {"id": "2", "name": "Relates", "inward": "relates to", "outward": "relates to"}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runTypes(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Blocks") + assert.Contains(t, stdout.String(), "Relates") +} From 7f7f9d08b4f605773cd1d78be4ba573c9caf56ed Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 18 Feb 2026 13:34:57 -0500 Subject: [PATCH 2/2] fix(links): correct link direction display in list output When Jira returns InwardIssue, the current issue is the outward side (and vice versa). The direction labels were swapped. --- tools/jtk/internal/cmd/links/links.go | 6 ++++-- tools/jtk/internal/cmd/links/links_test.go | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/jtk/internal/cmd/links/links.go b/tools/jtk/internal/cmd/links/links.go index 12764e1..1488714 100644 --- a/tools/jtk/internal/cmd/links/links.go +++ b/tools/jtk/internal/cmd/links/links.go @@ -72,11 +72,13 @@ func runList(opts *root.Options, issueKey string) error { var direction, key, summary string if link.OutwardIssue != nil { - direction = link.Type.Outward + // OutwardIssue is set → current issue is the inward side + direction = link.Type.Inward key = link.OutwardIssue.Key summary = link.OutwardIssue.Fields.Summary } else if link.InwardIssue != nil { - direction = link.Type.Inward + // InwardIssue is set → current issue is the outward side + direction = link.Type.Outward key = link.InwardIssue.Key summary = link.InwardIssue.Fields.Summary } diff --git a/tools/jtk/internal/cmd/links/links_test.go b/tools/jtk/internal/cmd/links/links_test.go index a032a0c..da86680 100644 --- a/tools/jtk/internal/cmd/links/links_test.go +++ b/tools/jtk/internal/cmd/links/links_test.go @@ -53,6 +53,8 @@ func TestRunList(t *testing.T) { require.NoError(t, err) assert.Contains(t, stdout.String(), "PROJ-456") assert.Contains(t, stdout.String(), "Blocks") + // OutwardIssue is set → current issue is the inward side → show inward direction + assert.Contains(t, stdout.String(), "is blocked by") } func TestRunList_NoLinks(t *testing.T) {