Skip to content

Commit 01c83ac

Browse files
committed
Add --duplicate-of flag and duplicate reason to gh issue close
Support closing issues as duplicates via --reason duplicate and --duplicate-of <issue> flags. The --duplicate-of flag accepts an issue number or URL, validates it references a different issue (not a PR), and passes the duplicate issue ID to the closeIssue mutation. Feature detection checks whether the GHES instance supports the DUPLICATE enum value in IssueClosedStateReason before using it.
1 parent 097bad6 commit 01c83ac

4 files changed

Lines changed: 368 additions & 22 deletions

File tree

internal/featuredetection/feature_detection.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ type Detector interface {
2323
}
2424

2525
type IssueFeatures struct {
26-
StateReason bool
27-
ActorIsAssignable bool
26+
StateReason bool
27+
StateReasonDuplicate bool
28+
ActorIsAssignable bool
2829
}
2930

3031
var allIssueFeatures = IssueFeatures{
31-
StateReason: true,
32-
ActorIsAssignable: true,
32+
StateReason: true,
33+
StateReasonDuplicate: true,
34+
ActorIsAssignable: true,
3335
}
3436

3537
type PullRequestFeatures struct {
@@ -138,8 +140,9 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
138140
}
139141

140142
features := IssueFeatures{
141-
StateReason: false,
142-
ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES
143+
StateReason: false,
144+
StateReasonDuplicate: false,
145+
ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES
143146
}
144147

145148
var featureDetection struct {
@@ -162,6 +165,30 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
162165
}
163166
}
164167

168+
if !features.StateReason {
169+
return features, nil
170+
}
171+
172+
var issueClosedStateReasonFeatureDetection struct {
173+
IssueClosedStateReason struct {
174+
EnumValues []struct {
175+
Name string
176+
} `graphql:"enumValues(includeDeprecated: true)"`
177+
} `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"`
178+
}
179+
180+
err = gql.Query(d.host, "IssueClosedStateReason_enumValues", &issueClosedStateReasonFeatureDetection, nil)
181+
if err != nil {
182+
return features, err
183+
}
184+
185+
for _, enumValue := range issueClosedStateReasonFeatureDetection.IssueClosedStateReason.EnumValues {
186+
if enumValue.Name == "DUPLICATE" {
187+
features.StateReasonDuplicate = true
188+
break
189+
}
190+
}
191+
165192
return features, nil
166193
}
167194

internal/featuredetection/feature_detection_test.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@ func TestIssueFeatures(t *testing.T) {
2323
name: "github.com",
2424
hostname: "github.com",
2525
wantFeatures: IssueFeatures{
26-
StateReason: true,
27-
ActorIsAssignable: true,
26+
StateReason: true,
27+
StateReasonDuplicate: true,
28+
ActorIsAssignable: true,
2829
},
2930
wantErr: false,
3031
},
3132
{
3233
name: "ghec data residency (ghe.com)",
3334
hostname: "stampname.ghe.com",
3435
wantFeatures: IssueFeatures{
35-
StateReason: true,
36-
ActorIsAssignable: true,
36+
StateReason: true,
37+
StateReasonDuplicate: true,
38+
ActorIsAssignable: true,
3739
},
3840
wantErr: false,
3941
},
@@ -44,23 +46,56 @@ func TestIssueFeatures(t *testing.T) {
4446
`query Issue_fields\b`: `{"data": {}}`,
4547
},
4648
wantFeatures: IssueFeatures{
47-
StateReason: false,
48-
ActorIsAssignable: false,
49+
StateReason: false,
50+
StateReasonDuplicate: false,
51+
ActorIsAssignable: false,
4952
},
5053
wantErr: false,
5154
},
5255
{
53-
name: "GHE has state reason field",
56+
name: "GHE has state reason field without duplicate enum",
5457
hostname: "git.my.org",
5558
queryResponse: map[string]string{
5659
`query Issue_fields\b`: heredoc.Doc(`
5760
{ "data": { "Issue": { "fields": [
5861
{"name": "stateReason"}
5962
] } } }
6063
`),
64+
`query IssueClosedStateReason_enumValues\b`: heredoc.Doc(`
65+
{ "data": { "IssueClosedStateReason": { "enumValues": [
66+
{"name": "COMPLETED"},
67+
{"name": "NOT_PLANNED"}
68+
] } } }
69+
`),
70+
},
71+
wantFeatures: IssueFeatures{
72+
StateReason: true,
73+
StateReasonDuplicate: false,
74+
ActorIsAssignable: false,
75+
},
76+
wantErr: false,
77+
},
78+
{
79+
name: "GHE has duplicate state reason enum value",
80+
hostname: "git.my.org",
81+
queryResponse: map[string]string{
82+
`query Issue_fields\b`: heredoc.Doc(`
83+
{ "data": { "Issue": { "fields": [
84+
{"name": "stateReason"}
85+
] } } }
86+
`),
87+
`query IssueClosedStateReason_enumValues\b`: heredoc.Doc(`
88+
{ "data": { "IssueClosedStateReason": { "enumValues": [
89+
{"name": "COMPLETED"},
90+
{"name": "NOT_PLANNED"},
91+
{"name": "DUPLICATE"}
92+
] } } }
93+
`),
6194
},
6295
wantFeatures: IssueFeatures{
63-
StateReason: true,
96+
StateReason: true,
97+
StateReasonDuplicate: true,
98+
ActorIsAssignable: false,
6499
},
65100
wantErr: false,
66101
},

pkg/cmd/issue/close/close.go

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type CloseOptions struct {
2424
IssueNumber int
2525
Comment string
2626
Reason string
27+
DuplicateOf string
2728

2829
Detector fd.Detector
2930
}
@@ -55,6 +56,13 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
5556
}
5657

5758
opts.IssueNumber = issueNumber
59+
if opts.DuplicateOf != "" {
60+
if opts.Reason == "" {
61+
opts.Reason = "duplicate"
62+
} else if opts.Reason != "duplicate" {
63+
return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`")
64+
}
65+
}
5866

5967
if runF != nil {
6068
return runF(opts)
@@ -64,13 +72,22 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
6472
}
6573

6674
cmd.Flags().StringVarP(&opts.Comment, "comment", "c", "", "Leave a closing comment")
67-
cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned"}, "Reason for closing")
75+
cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned", "duplicate"}, "Reason for closing")
76+
cmd.Flags().StringVar(&opts.DuplicateOf, "duplicate-of", "", "Mark as duplicate of another issue by number or URL")
6877

6978
return cmd
7079
}
7180

7281
func closeRun(opts *CloseOptions) error {
7382
cs := opts.IO.ColorScheme()
83+
closeReason := opts.Reason
84+
if opts.DuplicateOf != "" {
85+
if closeReason == "" {
86+
closeReason = "duplicate"
87+
} else if closeReason != "duplicate" {
88+
return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`")
89+
}
90+
}
7491

7592
httpClient, err := opts.HttpClient()
7693
if err != nil {
@@ -92,6 +109,32 @@ func closeRun(opts *CloseOptions) error {
92109
return nil
93110
}
94111

112+
var duplicateIssueID string
113+
if opts.DuplicateOf != "" {
114+
if issue.IsPullRequest() {
115+
return cmdutil.FlagErrorf("`--duplicate-of` is only supported for issues")
116+
}
117+
duplicateIssueNumber, duplicateRepo, err := shared.ParseIssueFromArg(opts.DuplicateOf)
118+
if err != nil {
119+
return cmdutil.FlagErrorf("invalid value for `--duplicate-of`: %v", err)
120+
}
121+
duplicateIssueRepo := baseRepo
122+
if parsedRepo, present := duplicateRepo.Value(); present {
123+
duplicateIssueRepo = parsedRepo
124+
}
125+
if ghrepo.IsSame(baseRepo, duplicateIssueRepo) && issue.Number == duplicateIssueNumber {
126+
return cmdutil.FlagErrorf("`--duplicate-of` cannot reference the current issue")
127+
}
128+
duplicateIssue, err := shared.FindIssueOrPR(httpClient, duplicateIssueRepo, duplicateIssueNumber, []string{"id"})
129+
if err != nil {
130+
return err
131+
}
132+
if duplicateIssue.IsPullRequest() {
133+
return cmdutil.FlagErrorf("`--duplicate-of` must reference an issue")
134+
}
135+
duplicateIssueID = duplicateIssue.ID
136+
}
137+
95138
if opts.Comment != "" {
96139
commentOpts := &prShared.CommentableOptions{
97140
Body: opts.Comment,
@@ -108,7 +151,7 @@ func closeRun(opts *CloseOptions) error {
108151
}
109152
}
110153

111-
err = apiClose(httpClient, baseRepo, issue, opts.Detector, opts.Reason)
154+
err = apiClose(httpClient, baseRepo, issue, opts.Detector, closeReason, duplicateIssueID)
112155
if err != nil {
113156
return err
114157
}
@@ -118,12 +161,12 @@ func closeRun(opts *CloseOptions) error {
118161
return nil
119162
}
120163

121-
func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string) error {
164+
func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string, duplicateIssueID string) error {
122165
if issue.IsPullRequest() {
123166
return api.PullRequestClose(httpClient, repo, issue.ID)
124167
}
125168

126-
if reason != "" {
169+
if reason != "" || duplicateIssueID != "" {
127170
if detector == nil {
128171
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
129172
detector = fd.NewDetector(cachedClient, repo.RepoHost())
@@ -135,6 +178,15 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue,
135178
// TODO stateReasonCleanup
136179
if !features.StateReason {
137180
// If StateReason is not supported silently close issue without setting StateReason.
181+
if duplicateIssueID != "" {
182+
return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost())
183+
}
184+
reason = ""
185+
} else if reason == "duplicate" && !features.StateReasonDuplicate {
186+
if duplicateIssueID != "" {
187+
return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost())
188+
}
189+
// If DUPLICATE is not supported silently close issue without setting StateReason.
138190
reason = ""
139191
}
140192
}
@@ -144,6 +196,8 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue,
144196
// If no reason is specified do not set it.
145197
case "not planned":
146198
reason = "NOT_PLANNED"
199+
case "duplicate":
200+
reason = "DUPLICATE"
147201
default:
148202
reason = "COMPLETED"
149203
}
@@ -158,8 +212,9 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue,
158212

159213
variables := map[string]interface{}{
160214
"input": CloseIssueInput{
161-
IssueID: issue.ID,
162-
StateReason: reason,
215+
IssueID: issue.ID,
216+
StateReason: reason,
217+
DuplicateIssueID: duplicateIssueID,
163218
},
164219
}
165220

@@ -168,6 +223,7 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue,
168223
}
169224

170225
type CloseIssueInput struct {
171-
IssueID string `json:"issueId"`
172-
StateReason string `json:"stateReason,omitempty"`
226+
IssueID string `json:"issueId"`
227+
StateReason string `json:"stateReason,omitempty"`
228+
DuplicateIssueID string `json:"duplicateIssueId,omitempty"`
173229
}

0 commit comments

Comments
 (0)