Skip to content

Commit 2ad774d

Browse files
authored
feat(links): add first-class support for issue links (#164)
* feat(links): add first-class support for issue links Adds `jtk links` command group with full CRUD support for Jira issue links: - `jtk links list <issue-key>` — list all links on an issue - `jtk links create <outward> <inward> --type <name>` — create a link - `jtk links delete <link-id>` — 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. * 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.
1 parent b4efa05 commit 2ad774d

5 files changed

Lines changed: 706 additions & 0 deletions

File tree

tools/jtk/api/links.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
)
8+
9+
// IssueLinkType represents a type of link between issues (e.g., "Blocks", "Relates")
10+
type IssueLinkType struct {
11+
ID string `json:"id"`
12+
Name string `json:"name"`
13+
Inward string `json:"inward"`
14+
Outward string `json:"outward"`
15+
}
16+
17+
// IssueLink represents a link between two issues
18+
type IssueLink struct {
19+
ID string `json:"id"`
20+
Type IssueLinkType `json:"type"`
21+
// Exactly one of InwardIssue or OutwardIssue will be set when reading links from an issue
22+
InwardIssue *LinkedIssue `json:"inwardIssue,omitempty"`
23+
OutwardIssue *LinkedIssue `json:"outwardIssue,omitempty"`
24+
}
25+
26+
// LinkedIssue represents the summary info of a linked issue
27+
type LinkedIssue struct {
28+
ID string `json:"id"`
29+
Key string `json:"key"`
30+
Fields struct {
31+
Summary string `json:"summary"`
32+
Status *Status `json:"status,omitempty"`
33+
IssueType *IssueType `json:"issuetype,omitempty"`
34+
} `json:"fields"`
35+
}
36+
37+
// CreateIssueLinkRequest represents a request to create a link between two issues
38+
type CreateIssueLinkRequest struct {
39+
Type IssueLinkTypeRef `json:"type"`
40+
InwardIssue IssueRef `json:"inwardIssue"`
41+
OutwardIssue IssueRef `json:"outwardIssue"`
42+
}
43+
44+
// IssueLinkTypeRef identifies a link type by name
45+
type IssueLinkTypeRef struct {
46+
Name string `json:"name"`
47+
}
48+
49+
// IssueRef identifies an issue by key
50+
type IssueRef struct {
51+
Key string `json:"key"`
52+
}
53+
54+
// GetIssueLinks returns the links on an issue by fetching the issue and extracting the issuelinks field
55+
func (c *Client) GetIssueLinks(issueKey string) ([]IssueLink, error) {
56+
if issueKey == "" {
57+
return nil, ErrIssueKeyRequired
58+
}
59+
60+
urlStr := buildURL(
61+
fmt.Sprintf("%s/issue/%s", c.BaseURL, url.PathEscape(issueKey)),
62+
map[string]string{"fields": "issuelinks"},
63+
)
64+
65+
body, err := c.get(urlStr)
66+
if err != nil {
67+
return nil, err
68+
}
69+
70+
var result struct {
71+
Fields struct {
72+
IssueLinks []IssueLink `json:"issuelinks"`
73+
} `json:"fields"`
74+
}
75+
if err := json.Unmarshal(body, &result); err != nil {
76+
return nil, fmt.Errorf("failed to parse issue links: %w", err)
77+
}
78+
79+
return result.Fields.IssueLinks, nil
80+
}
81+
82+
// CreateIssueLink creates a link between two issues
83+
func (c *Client) CreateIssueLink(outwardKey, inwardKey, linkTypeName string) error {
84+
if outwardKey == "" || inwardKey == "" {
85+
return ErrIssueKeyRequired
86+
}
87+
if linkTypeName == "" {
88+
return fmt.Errorf("link type name is required")
89+
}
90+
91+
urlStr := fmt.Sprintf("%s/issueLink", c.BaseURL)
92+
req := CreateIssueLinkRequest{
93+
Type: IssueLinkTypeRef{Name: linkTypeName},
94+
OutwardIssue: IssueRef{Key: outwardKey},
95+
InwardIssue: IssueRef{Key: inwardKey},
96+
}
97+
98+
_, err := c.post(urlStr, req)
99+
return err
100+
}
101+
102+
// DeleteIssueLink deletes an issue link by its ID
103+
func (c *Client) DeleteIssueLink(linkID string) error {
104+
if linkID == "" {
105+
return fmt.Errorf("link ID is required")
106+
}
107+
108+
urlStr := fmt.Sprintf("%s/issueLink/%s", c.BaseURL, url.PathEscape(linkID))
109+
_, err := c.delete(urlStr)
110+
return err
111+
}
112+
113+
// GetIssueLinkTypes returns all available issue link types
114+
func (c *Client) GetIssueLinkTypes() ([]IssueLinkType, error) {
115+
urlStr := fmt.Sprintf("%s/issueLinkType", c.BaseURL)
116+
117+
body, err := c.get(urlStr)
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
var result struct {
123+
IssueLinkTypes []IssueLinkType `json:"issueLinkTypes"`
124+
}
125+
if err := json.Unmarshal(body, &result); err != nil {
126+
return nil, fmt.Errorf("failed to parse link types: %w", err)
127+
}
128+
129+
return result.IssueLinkTypes, nil
130+
}

tools/jtk/api/links_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestGetIssueLinks(t *testing.T) {
15+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
assert.Equal(t, "/rest/api/3/issue/PROJ-123", r.URL.Path)
17+
assert.Equal(t, "issuelinks", r.URL.Query().Get("fields"))
18+
19+
json.NewEncoder(w).Encode(map[string]interface{}{
20+
"fields": map[string]interface{}{
21+
"issuelinks": []map[string]interface{}{
22+
{
23+
"id": "10001",
24+
"type": map[string]string{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
25+
"outwardIssue": map[string]interface{}{
26+
"key": "PROJ-456",
27+
"fields": map[string]interface{}{
28+
"summary": "Other issue",
29+
},
30+
},
31+
},
32+
},
33+
},
34+
})
35+
}))
36+
defer server.Close()
37+
38+
client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"})
39+
require.NoError(t, err)
40+
41+
links, err := client.GetIssueLinks("PROJ-123")
42+
require.NoError(t, err)
43+
require.Len(t, links, 1)
44+
assert.Equal(t, "10001", links[0].ID)
45+
assert.Equal(t, "Blocks", links[0].Type.Name)
46+
require.NotNil(t, links[0].OutwardIssue)
47+
assert.Equal(t, "PROJ-456", links[0].OutwardIssue.Key)
48+
}
49+
50+
func TestGetIssueLinks_EmptyKey(t *testing.T) {
51+
_, err := (&Client{}).GetIssueLinks("")
52+
assert.Equal(t, ErrIssueKeyRequired, err)
53+
}
54+
55+
func TestCreateIssueLink(t *testing.T) {
56+
var capturedBody []byte
57+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
58+
assert.Equal(t, "/rest/api/3/issueLink", r.URL.Path)
59+
assert.Equal(t, "POST", r.Method)
60+
capturedBody, _ = io.ReadAll(r.Body)
61+
w.WriteHeader(http.StatusCreated)
62+
}))
63+
defer server.Close()
64+
65+
client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"})
66+
require.NoError(t, err)
67+
68+
err = client.CreateIssueLink("PROJ-123", "PROJ-456", "Blocks")
69+
require.NoError(t, err)
70+
71+
var req CreateIssueLinkRequest
72+
err = json.Unmarshal(capturedBody, &req)
73+
require.NoError(t, err)
74+
assert.Equal(t, "Blocks", req.Type.Name)
75+
assert.Equal(t, "PROJ-123", req.OutwardIssue.Key)
76+
assert.Equal(t, "PROJ-456", req.InwardIssue.Key)
77+
}
78+
79+
func TestCreateIssueLink_EmptyKeys(t *testing.T) {
80+
assert.Error(t, (&Client{}).CreateIssueLink("", "B", "t"))
81+
assert.Error(t, (&Client{}).CreateIssueLink("A", "", "t"))
82+
assert.Error(t, (&Client{}).CreateIssueLink("A", "B", ""))
83+
}
84+
85+
func TestDeleteIssueLink(t *testing.T) {
86+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+
assert.Equal(t, "/rest/api/3/issueLink/10001", r.URL.Path)
88+
assert.Equal(t, "DELETE", r.Method)
89+
w.WriteHeader(http.StatusNoContent)
90+
}))
91+
defer server.Close()
92+
93+
client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"})
94+
require.NoError(t, err)
95+
96+
err = client.DeleteIssueLink("10001")
97+
require.NoError(t, err)
98+
}
99+
100+
func TestDeleteIssueLink_EmptyID(t *testing.T) {
101+
assert.Error(t, (&Client{}).DeleteIssueLink(""))
102+
}
103+
104+
func TestGetIssueLinkTypes(t *testing.T) {
105+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106+
assert.Equal(t, "/rest/api/3/issueLinkType", r.URL.Path)
107+
json.NewEncoder(w).Encode(map[string]interface{}{
108+
"issueLinkTypes": []map[string]string{
109+
{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"},
110+
{"id": "2", "name": "Relates", "inward": "relates to", "outward": "relates to"},
111+
},
112+
})
113+
}))
114+
defer server.Close()
115+
116+
client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"})
117+
require.NoError(t, err)
118+
119+
types, err := client.GetIssueLinkTypes()
120+
require.NoError(t, err)
121+
require.Len(t, types, 2)
122+
assert.Equal(t, "Blocks", types[0].Name)
123+
assert.Equal(t, "Relates", types[1].Name)
124+
}

tools/jtk/cmd/jtk/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/fields"
1717
"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/initcmd"
1818
"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/issues"
19+
"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/links"
1920
"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/me"
2021
"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/projects"
2122
"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root"
@@ -41,6 +42,7 @@ func run() error {
4142
issues.Register(rootCmd, opts)
4243
transitions.Register(rootCmd, opts)
4344
comments.Register(rootCmd, opts)
45+
links.Register(rootCmd, opts)
4446
attachments.Register(rootCmd, opts)
4547
automation.Register(rootCmd, opts)
4648
boards.Register(rootCmd, opts)

0 commit comments

Comments
 (0)