Skip to content
This repository was archived by the owner on Feb 18, 2026. It is now read-only.

Commit 05f9f44

Browse files
yahsan2claude
andcommitted
Add gh issue add-sub command for sub-issue management
- Implement addSubIssue GraphQL mutation support - Add new 'gh issue add-sub <parent> <sub>' command - Support issue numbers, URLs, and --repo flag - Include proper error handling and validation - Add comprehensive unit tests - Follow existing gh CLI patterns and conventions Addresses community request in cli#10298 for parent/sub-issue functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent e5913fe commit 05f9f44

3 files changed

Lines changed: 299 additions & 0 deletions

File tree

‎pkg/cmd/issue/addsub/addsub.go‎

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package addsub
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/MakeNowJust/heredoc"
8+
"github.com/cli/cli/v2/api"
9+
"github.com/cli/cli/v2/internal/ghrepo"
10+
"github.com/cli/cli/v2/pkg/cmd/issue/shared"
11+
"github.com/cli/cli/v2/pkg/cmdutil"
12+
"github.com/cli/cli/v2/pkg/iostreams"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type AddSubOptions struct {
17+
HttpClient func() (*http.Client, error)
18+
IO *iostreams.IOStreams
19+
BaseRepo func() (ghrepo.Interface, error)
20+
21+
ParentIssueArg string
22+
SubIssueArg string
23+
}
24+
25+
func NewCmdAddSub(f *cmdutil.Factory, runF func(*AddSubOptions) error) *cobra.Command {
26+
opts := &AddSubOptions{
27+
IO: f.IOStreams,
28+
HttpClient: f.HttpClient,
29+
BaseRepo: f.BaseRepo,
30+
}
31+
32+
cmd := &cobra.Command{
33+
Use: "add-sub <parent-issue> <sub-issue>",
34+
Short: "Add a sub-issue to an issue",
35+
Long: heredoc.Doc(`
36+
Add a sub-issue to a parent issue, creating a hierarchical relationship.
37+
38+
Both issues must exist in the same repository. The sub-issue will be
39+
linked to the parent issue, allowing for better organization of related work.
40+
`),
41+
Example: heredoc.Doc(`
42+
# Add issue #456 as a sub-issue of #123
43+
$ gh issue add-sub 123 456
44+
45+
# Using URLs
46+
$ gh issue add-sub https://github.com/owner/repo/issues/123 456
47+
48+
# With repository specification
49+
$ gh issue add-sub 123 456 --repo owner/repo
50+
`),
51+
Args: cobra.ExactArgs(2),
52+
RunE: func(cmd *cobra.Command, args []string) error {
53+
// support `-R, --repo` override
54+
opts.BaseRepo = f.BaseRepo
55+
56+
opts.ParentIssueArg = args[0]
57+
opts.SubIssueArg = args[1]
58+
59+
if runF != nil {
60+
return runF(opts)
61+
}
62+
return addSubRun(opts)
63+
},
64+
}
65+
66+
return cmd
67+
}
68+
69+
func addSubRun(opts *AddSubOptions) error {
70+
httpClient, err := opts.HttpClient()
71+
if err != nil {
72+
return err
73+
}
74+
75+
baseRepo, err := opts.BaseRepo()
76+
if err != nil {
77+
return err
78+
}
79+
80+
// Parse parent issue
81+
parentIssueNumber, parentRepo, err := shared.ParseIssueFromArg(opts.ParentIssueArg)
82+
if err != nil {
83+
return fmt.Errorf("invalid parent issue: %w", err)
84+
}
85+
if parentRepo.IsSome() {
86+
baseRepo = parentRepo.Unwrap()
87+
}
88+
89+
// Parse sub-issue
90+
subIssueNumber, subRepo, err := shared.ParseIssueFromArg(opts.SubIssueArg)
91+
if err != nil {
92+
return fmt.Errorf("invalid sub-issue: %w", err)
93+
}
94+
if subRepo.IsSome() {
95+
if !ghrepo.IsSame(baseRepo, subRepo.Unwrap()) {
96+
return fmt.Errorf("parent issue and sub-issue must be in the same repository")
97+
}
98+
}
99+
100+
// Check if issues are the same
101+
if parentIssueNumber == subIssueNumber {
102+
return fmt.Errorf("an issue cannot be its own sub-issue")
103+
}
104+
105+
// Get issue IDs for GraphQL
106+
parentIssueID, err := getIssueID(httpClient, baseRepo, parentIssueNumber)
107+
if err != nil {
108+
return fmt.Errorf("failed to get parent issue ID: %w", err)
109+
}
110+
111+
subIssueID, err := getIssueID(httpClient, baseRepo, subIssueNumber)
112+
if err != nil {
113+
return fmt.Errorf("failed to get sub-issue ID: %w", err)
114+
}
115+
116+
// Execute GraphQL mutation
117+
err = addSubIssue(httpClient, parentIssueID, subIssueID)
118+
if err != nil {
119+
return fmt.Errorf("failed to add sub-issue: %w", err)
120+
}
121+
122+
fmt.Fprintf(opts.IO.Out, "✓ Added issue #%d as a sub-issue of #%d\n", subIssueNumber, parentIssueNumber)
123+
return nil
124+
}
125+
126+
func getIssueID(httpClient *http.Client, repo ghrepo.Interface, issueNumber int) (string, error) {
127+
query := `
128+
query($owner: String!, $repo: String!, $number: Int!) {
129+
repository(owner: $owner, name: $repo) {
130+
issueOrPullRequest(number: $number) {
131+
... on Issue {
132+
id
133+
}
134+
}
135+
}
136+
}
137+
`
138+
139+
variables := map[string]interface{}{
140+
"owner": repo.RepoOwner(),
141+
"repo": repo.RepoName(),
142+
"number": issueNumber,
143+
}
144+
145+
var response struct {
146+
Repository struct {
147+
IssueOrPullRequest struct {
148+
ID string `json:"id"`
149+
} `json:"issueOrPullRequest"`
150+
} `json:"repository"`
151+
}
152+
153+
client := api.NewClientFromHTTP(httpClient)
154+
err := client.GraphQL(repo.RepoHost(), query, variables, &response)
155+
if err != nil {
156+
return "", err
157+
}
158+
159+
if response.Repository.IssueOrPullRequest.ID == "" {
160+
return "", fmt.Errorf("issue #%d not found", issueNumber)
161+
}
162+
163+
return response.Repository.IssueOrPullRequest.ID, nil
164+
}
165+
166+
func addSubIssue(httpClient *http.Client, parentIssueID, subIssueID string) error {
167+
mutation := `
168+
mutation($issueId: ID!, $subIssueId: ID!) {
169+
addSubIssue(input: {issueId: $issueId, subIssueId: $subIssueId}) {
170+
clientMutationId
171+
issue {
172+
id
173+
number
174+
title
175+
}
176+
subIssue {
177+
id
178+
number
179+
title
180+
}
181+
}
182+
}
183+
`
184+
185+
variables := map[string]interface{}{
186+
"issueId": parentIssueID,
187+
"subIssueId": subIssueID,
188+
}
189+
190+
var response struct {
191+
AddSubIssue struct {
192+
Issue struct {
193+
ID string `json:"id"`
194+
Number int `json:"number"`
195+
Title string `json:"title"`
196+
} `json:"issue"`
197+
SubIssue struct {
198+
ID string `json:"id"`
199+
Number int `json:"number"`
200+
Title string `json:"title"`
201+
} `json:"subIssue"`
202+
} `json:"addSubIssue"`
203+
}
204+
205+
client := api.NewClientFromHTTP(httpClient)
206+
return client.GraphQL("github.com", mutation, variables, &response)
207+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package addsub
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/cli/cli/v2/internal/ghrepo"
8+
"github.com/cli/cli/v2/pkg/cmdutil"
9+
"github.com/cli/cli/v2/pkg/iostreams"
10+
"github.com/google/shlex"
11+
"github.com/stretchr/testify/assert"
12+
)
13+
14+
func TestNewCmdAddSub(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
cli string
18+
wants AddSubOptions
19+
wantsErr bool
20+
}{
21+
{
22+
name: "basic usage",
23+
cli: "123 456",
24+
wants: AddSubOptions{
25+
ParentIssueArg: "123",
26+
SubIssueArg: "456",
27+
},
28+
},
29+
{
30+
name: "no arguments",
31+
cli: "",
32+
wantsErr: true,
33+
},
34+
{
35+
name: "too many arguments",
36+
cli: "123 456 789",
37+
wantsErr: true,
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
ios, _, _, _ := iostreams.Test()
44+
f := &cmdutil.Factory{
45+
IOStreams: ios,
46+
}
47+
48+
var opts *AddSubOptions
49+
cmd := NewCmdAddSub(f, func(gotOpts *AddSubOptions) error {
50+
opts = gotOpts
51+
return nil
52+
})
53+
54+
args, err := shlex.Split(tt.cli)
55+
assert.NoError(t, err)
56+
57+
cmd.SetArgs(args)
58+
_, err = cmd.ExecuteC()
59+
60+
if tt.wantsErr {
61+
assert.Error(t, err)
62+
return
63+
}
64+
65+
assert.NoError(t, err)
66+
assert.Equal(t, tt.wants.ParentIssueArg, opts.ParentIssueArg)
67+
assert.Equal(t, tt.wants.SubIssueArg, opts.SubIssueArg)
68+
})
69+
}
70+
}
71+
72+
func TestAddSubRun_SameIssue(t *testing.T) {
73+
ios, _, _, _ := iostreams.Test()
74+
75+
opts := &AddSubOptions{
76+
IO: ios,
77+
ParentIssueArg: "123",
78+
SubIssueArg: "123",
79+
HttpClient: func() (*http.Client, error) {
80+
return nil, nil // Won't be called for same issue check
81+
},
82+
BaseRepo: func() (ghrepo.Interface, error) {
83+
return nil, nil // Won't be called for same issue check
84+
},
85+
}
86+
87+
err := addSubRun(opts)
88+
assert.Error(t, err)
89+
assert.Contains(t, err.Error(), "an issue cannot be its own sub-issue")
90+
}

‎pkg/cmd/issue/issue.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package issue
22

33
import (
44
"github.com/MakeNowJust/heredoc"
5+
cmdAddSub "github.com/cli/cli/v2/pkg/cmd/issue/addsub"
56
cmdClose "github.com/cli/cli/v2/pkg/cmd/issue/close"
67
cmdComment "github.com/cli/cli/v2/pkg/cmd/issue/comment"
78
cmdCreate "github.com/cli/cli/v2/pkg/cmd/issue/create"
@@ -55,6 +56,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command {
5556
cmdReopen.NewCmdReopen(f, nil),
5657
cmdEdit.NewCmdEdit(f, nil),
5758
cmdDevelop.NewCmdDevelop(f, nil),
59+
cmdAddSub.NewCmdAddSub(f, nil),
5860
cmdLock.NewCmdLock(f, cmd.Name(), nil),
5961
cmdLock.NewCmdUnlock(f, cmd.Name(), nil),
6062
cmdPin.NewCmdPin(f, nil),

0 commit comments

Comments
 (0)