From 8898bea1b56a329e06da36ad4b1abab119eb326e Mon Sep 17 00:00:00 2001 From: Hinne Stolzenberg Date: Tue, 24 Mar 2026 09:05:16 +0100 Subject: [PATCH] feat: add issue link list and delete commands Add --list flag to show all links on an issue with ID, relation, linked issue key and summary. Add --delete flag to remove a link by ID (validates link exists on the issue first). API additions: GetIssueLinks, DeleteIssueLink on JiraService. --- internal/api/jira.go | 44 ++++++++++ internal/cmd/issue/link.go | 163 ++++++++++++++++++++++++++++++++++++- 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/internal/api/jira.go b/internal/api/jira.go index 34f7ef3..c47d61e 100644 --- a/internal/api/jira.go +++ b/internal/api/jira.go @@ -853,6 +853,50 @@ func (s *JiraService) CreateIssueLink(ctx context.Context, inwardKey, outwardKey return s.client.Post(ctx, path, req, nil) } +// IssueLink represents a link between two issues as returned by the issue fields. +type IssueLink struct { + ID string `json:"id"` + Type *IssueLinkType `json:"type"` + InwardIssue *LinkedIssue `json:"inwardIssue,omitempty"` + OutwardIssue *LinkedIssue `json:"outwardIssue,omitempty"` +} + +// LinkedIssue represents the linked issue summary in a link response. +type LinkedIssue struct { + Key string `json:"key"` + Fields *LinkedIssueFields `json:"fields,omitempty"` +} + +// LinkedIssueFields contains summary fields for a linked issue. +type LinkedIssueFields struct { + Summary string `json:"summary"` +} + +// issueLinksResponse is used to parse issuelinks from the issue fields. +type issueLinksResponse struct { + Fields struct { + IssueLinks []*IssueLink `json:"issuelinks"` + } `json:"fields"` +} + +// GetIssueLinks returns all issue links for the given issue key. +func (s *JiraService) GetIssueLinks(ctx context.Context, issueKey string) ([]*IssueLink, error) { + path := fmt.Sprintf("%s/issue/%s?fields=issuelinks", s.client.JiraBaseURL(), issueKey) + + var result issueLinksResponse + if err := s.client.Get(ctx, path, &result); err != nil { + return nil, err + } + + return result.Fields.IssueLinks, nil +} + +// DeleteIssueLink deletes an issue link by its ID. +func (s *JiraService) DeleteIssueLink(ctx context.Context, linkID string) error { + path := fmt.Sprintf("%s/issueLink/%s", s.client.JiraBaseURL(), linkID) + return s.client.Delete(ctx, path) +} + // RemoteLink represents a remote/web link on an issue. type RemoteLink struct { ID int `json:"id"` diff --git a/internal/cmd/issue/link.go b/internal/cmd/issue/link.go index af73799..5efcc1d 100644 --- a/internal/cmd/issue/link.go +++ b/internal/cmd/issue/link.go @@ -19,6 +19,8 @@ type LinkOptions struct { OutwardKey string LinkType string ListTypes bool + ListLinks bool + DeleteID string JSON bool } @@ -29,9 +31,9 @@ func NewCmdLink(ios *iostreams.IOStreams) *cobra.Command { } cmd := &cobra.Command{ - Use: "link ", - Short: "Link two Jira issues", - Long: `Create a link between two Jira issues. + Use: "link [outward-issue]", + Short: "Manage issue links", + Long: `Create, list, or delete links between Jira issues. Common link types: - Blocks (A blocks B) @@ -46,12 +48,24 @@ Use --list-types to see all available link types for your Jira instance.`, # Link PROJ-1 relates to PROJ-2 atl issue link PROJ-1 PROJ-2 --type Relates + # List links on an issue + atl issue link PROJ-1 --list + + # Delete a link by ID + atl issue link PROJ-1 --delete 12345 + # List available link types atl issue link --list-types`, Args: func(cmd *cobra.Command, args []string) error { if opts.ListTypes { return nil } + if opts.ListLinks || opts.DeleteID != "" { + if len(args) != 1 { + return fmt.Errorf("requires exactly 1 argument: ") + } + return nil + } if len(args) != 2 { return fmt.Errorf("requires exactly 2 arguments: ") } @@ -61,6 +75,14 @@ Use --list-types to see all available link types for your Jira instance.`, if opts.ListTypes { return runListLinkTypes(opts) } + if opts.ListLinks { + opts.InwardKey = args[0] + return runListLinks(opts) + } + if opts.DeleteID != "" { + opts.InwardKey = args[0] + return runDeleteLink(opts) + } opts.InwardKey = args[0] opts.OutwardKey = args[1] if opts.LinkType == "" { @@ -72,6 +94,8 @@ Use --list-types to see all available link types for your Jira instance.`, cmd.Flags().StringVarP(&opts.LinkType, "type", "t", "", "Link type (e.g., Blocks, Relates, Duplicate)") cmd.Flags().BoolVar(&opts.ListTypes, "list-types", false, "List available link types") + cmd.Flags().BoolVar(&opts.ListLinks, "list", false, "List links on an issue") + cmd.Flags().StringVar(&opts.DeleteID, "delete", "", "Delete a link by ID") cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") return cmd @@ -98,6 +122,22 @@ type LinkTypesOutput struct { Types []*LinkTypeOutput `json:"types"` } +// IssueLinkOutput represents a single issue link in list output. +type IssueLinkOutput struct { + ID string `json:"id"` + Type string `json:"type"` + Direction string `json:"direction"` + Relation string `json:"relation"` + IssueKey string `json:"issue_key"` + Summary string `json:"summary"` +} + +// IssueLinksOutput represents the output for listing issue links. +type IssueLinksOutput struct { + IssueKey string `json:"issue_key"` + Links []*IssueLinkOutput `json:"links"` +} + func runLink(opts *LinkOptions) error { client, err := api.NewClientFromConfig() if err != nil { @@ -148,6 +188,123 @@ func runLink(opts *LinkOptions) error { return nil } +func runListLinks(opts *LinkOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + jira := api.NewJiraService(client) + + links, err := jira.GetIssueLinks(ctx, opts.InwardKey) + if err != nil { + return fmt.Errorf("failed to get issue links: %w", err) + } + + linksOutput := &IssueLinksOutput{ + IssueKey: opts.InwardKey, + Links: make([]*IssueLinkOutput, 0, len(links)), + } + + for _, link := range links { + lo := &IssueLinkOutput{ + ID: link.ID, + Type: link.Type.Name, + } + + if link.OutwardIssue != nil { + lo.Direction = "outward" + lo.Relation = link.Type.Outward + lo.IssueKey = link.OutwardIssue.Key + if link.OutwardIssue.Fields != nil { + lo.Summary = link.OutwardIssue.Fields.Summary + } + } else if link.InwardIssue != nil { + lo.Direction = "inward" + lo.Relation = link.Type.Inward + lo.IssueKey = link.InwardIssue.Key + if link.InwardIssue.Fields != nil { + lo.Summary = link.InwardIssue.Fields.Summary + } + } + + linksOutput.Links = append(linksOutput.Links, lo) + } + + if opts.JSON { + return output.JSON(opts.IO.Out, linksOutput) + } + + if len(linksOutput.Links) == 0 { + fmt.Fprintf(opts.IO.Out, "No links found on %s\n", opts.InwardKey) + return nil + } + + fmt.Fprintf(opts.IO.Out, "Links on %s:\n\n", opts.InwardKey) + headers := []string{"ID", "RELATION", "ISSUE", "SUMMARY"} + rows := make([][]string, 0, len(linksOutput.Links)) + + for _, l := range linksOutput.Links { + rows = append(rows, []string{l.ID, l.Relation, l.IssueKey, l.Summary}) + } + + output.SimpleTable(opts.IO.Out, headers, rows) + return nil +} + +func runDeleteLink(opts *LinkOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + jira := api.NewJiraService(client) + + // Verify the link exists on this issue before deleting + links, err := jira.GetIssueLinks(ctx, opts.InwardKey) + if err != nil { + return fmt.Errorf("failed to get issue links: %w", err) + } + + var found *api.IssueLink + for _, link := range links { + if link.ID == opts.DeleteID { + found = link + break + } + } + + if found == nil { + return fmt.Errorf("link ID %s not found on %s\n\nUse 'atl issue link %s --list' to see links", opts.DeleteID, opts.InwardKey, opts.InwardKey) + } + + err = jira.DeleteIssueLink(ctx, opts.DeleteID) + if err != nil { + return fmt.Errorf("failed to delete link: %w", err) + } + + var linkedKey string + if found.OutwardIssue != nil { + linkedKey = found.OutwardIssue.Key + } else if found.InwardIssue != nil { + linkedKey = found.InwardIssue.Key + } + + if opts.JSON { + return output.JSON(opts.IO.Out, map[string]string{ + "deleted": opts.DeleteID, + "issue": opts.InwardKey, + "linked_issue": linkedKey, + "type": found.Type.Name, + }) + } + + fmt.Fprintf(opts.IO.Out, "Deleted link %s (%s %s %s)\n", opts.DeleteID, opts.InwardKey, found.Type.Name, linkedKey) + return nil +} + func runListLinkTypes(opts *LinkOptions) error { client, err := api.NewClientFromConfig() if err != nil {