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+ }
0 commit comments