Skip to content

Commit 3fec2e5

Browse files
authored
Merge pull request cli#12811 from tksohishi/issue-close-duplicate-of
Add `--duplicate-of` flag and duplicate reason to issue close
2 parents d46ca24 + b9c8d8e commit 3fec2e5

File tree

4 files changed

+352
-22
lines changed

4 files changed

+352
-22
lines changed

internal/featuredetection/feature_detection.go

Lines changed: 23 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 {
@@ -148,6 +151,11 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
148151
Name string
149152
} `graphql:"fields(includeDeprecated: true)"`
150153
} `graphql:"Issue: __type(name: \"Issue\")"`
154+
IssueClosedStateReason struct {
155+
EnumValues []struct {
156+
Name string
157+
} `graphql:"enumValues(includeDeprecated: true)"`
158+
} `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"`
151159
}
152160

153161
gql := api.NewClientFromHTTP(d.httpClient)
@@ -162,6 +170,15 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
162170
}
163171
}
164172

173+
if features.StateReason {
174+
for _, enumValue := range featureDetection.IssueClosedStateReason.EnumValues {
175+
if enumValue.Name == "DUPLICATE" {
176+
features.StateReasonDuplicate = true
177+
break
178+
}
179+
}
180+
}
181+
165182
return features, nil
166183
}
167184

internal/featuredetection/feature_detection_test.go

Lines changed: 37 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,50 @@ 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"}
62+
] }, "IssueClosedStateReason": { "enumValues": [
63+
{"name": "COMPLETED"},
64+
{"name": "NOT_PLANNED"}
5965
] } } }
6066
`),
6167
},
6268
wantFeatures: IssueFeatures{
63-
StateReason: true,
69+
StateReason: true,
70+
StateReasonDuplicate: false,
71+
ActorIsAssignable: false,
72+
},
73+
wantErr: false,
74+
},
75+
{
76+
name: "GHE has duplicate state reason enum value",
77+
hostname: "git.my.org",
78+
queryResponse: map[string]string{
79+
`query Issue_fields\b`: heredoc.Doc(`
80+
{ "data": { "Issue": { "fields": [
81+
{"name": "stateReason"}
82+
] }, "IssueClosedStateReason": { "enumValues": [
83+
{"name": "COMPLETED"},
84+
{"name": "NOT_PLANNED"},
85+
{"name": "DUPLICATE"}
86+
] } } }
87+
`),
88+
},
89+
wantFeatures: IssueFeatures{
90+
StateReason: true,
91+
StateReasonDuplicate: true,
92+
ActorIsAssignable: false,
6493
},
6594
wantErr: false,
6695
},

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)