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
44 changes: 44 additions & 0 deletions internal/api/jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
163 changes: 160 additions & 3 deletions internal/cmd/issue/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type LinkOptions struct {
OutwardKey string
LinkType string
ListTypes bool
ListLinks bool
DeleteID string
JSON bool
}

Expand All @@ -29,9 +31,9 @@ func NewCmdLink(ios *iostreams.IOStreams) *cobra.Command {
}

cmd := &cobra.Command{
Use: "link <inward-issue> <outward-issue>",
Short: "Link two Jira issues",
Long: `Create a link between two Jira issues.
Use: "link <inward-issue> [outward-issue]",
Short: "Manage issue links",
Long: `Create, list, or delete links between Jira issues.

Common link types:
- Blocks (A blocks B)
Expand All @@ -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
}
Comment on lines 60 to 62

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The argument validation for --list-types could be made more strict. Currently, if a user provides arguments with this flag (e.g., atl issue link --list-types some-arg), the arguments are ignored. It would be better to return an error to inform the user that this flag does not accept arguments. This improves the command's robustness and provides clearer feedback to the user.

            if len(args) != 0 {
                return fmt.Errorf("the --list-types flag does not accept any arguments")
            }
            return nil

if opts.ListLinks || opts.DeleteID != "" {
if len(args) != 1 {
return fmt.Errorf("requires exactly 1 argument: <issue-key>")
}
return nil
}
if len(args) != 2 {
return fmt.Errorf("requires exactly 2 arguments: <inward-issue> <outward-issue>")
}
Expand All @@ -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 == "" {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading