Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions tools/jtk/api/links.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 124 additions & 0 deletions tools/jtk/api/links_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions tools/jtk/cmd/jtk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
Loading
Loading