From 01c83acfe837660712b3fd5227176cac2275760a Mon Sep 17 00:00:00 2001 From: Takeshi Date: Sat, 28 Feb 2026 20:01:38 -0500 Subject: [PATCH] Add --duplicate-of flag and duplicate reason to gh issue close Support closing issues as duplicates via --reason duplicate and --duplicate-of 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. --- .../featuredetection/feature_detection.go | 39 ++- .../feature_detection_test.go | 51 +++- pkg/cmd/issue/close/close.go | 72 +++++- pkg/cmd/issue/close/close_test.go | 228 ++++++++++++++++++ 4 files changed, 368 insertions(+), 22 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 7a200e20c..b162e4c2c 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -23,13 +23,15 @@ type Detector interface { } type IssueFeatures struct { - StateReason bool - ActorIsAssignable bool + StateReason bool + StateReasonDuplicate bool + ActorIsAssignable bool } var allIssueFeatures = IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, } type PullRequestFeatures struct { @@ -138,8 +140,9 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } features := IssueFeatures{ - StateReason: false, - ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES + StateReason: false, + StateReasonDuplicate: false, + ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES } var featureDetection struct { @@ -162,6 +165,30 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } } + if !features.StateReason { + return features, nil + } + + var issueClosedStateReasonFeatureDetection struct { + IssueClosedStateReason struct { + EnumValues []struct { + Name string + } `graphql:"enumValues(includeDeprecated: true)"` + } `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"` + } + + err = gql.Query(d.host, "IssueClosedStateReason_enumValues", &issueClosedStateReasonFeatureDetection, nil) + if err != nil { + return features, err + } + + for _, enumValue := range issueClosedStateReasonFeatureDetection.IssueClosedStateReason.EnumValues { + if enumValue.Name == "DUPLICATE" { + features.StateReasonDuplicate = true + break + } + } + return features, nil } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 032f5cda0..c1d7b3b4a 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -23,8 +23,9 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -32,8 +33,9 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - StateReason: true, - ActorIsAssignable: true, + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -44,13 +46,14 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: `{"data": {}}`, }, wantFeatures: IssueFeatures{ - StateReason: false, - ActorIsAssignable: false, + StateReason: false, + StateReasonDuplicate: false, + ActorIsAssignable: false, }, wantErr: false, }, { - name: "GHE has state reason field", + name: "GHE has state reason field without duplicate enum", hostname: "git.my.org", queryResponse: map[string]string{ `query Issue_fields\b`: heredoc.Doc(` @@ -58,9 +61,41 @@ func TestIssueFeatures(t *testing.T) { {"name": "stateReason"} ] } } } `), + `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` + { "data": { "IssueClosedStateReason": { "enumValues": [ + {"name": "COMPLETED"}, + {"name": "NOT_PLANNED"} + ] } } } + `), }, wantFeatures: IssueFeatures{ - StateReason: true, + StateReason: true, + StateReasonDuplicate: false, + ActorIsAssignable: false, + }, + wantErr: false, + }, + { + name: "GHE has duplicate state reason enum value", + hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields\b`: heredoc.Doc(` + { "data": { "Issue": { "fields": [ + {"name": "stateReason"} + ] } } } + `), + `query IssueClosedStateReason_enumValues\b`: heredoc.Doc(` + { "data": { "IssueClosedStateReason": { "enumValues": [ + {"name": "COMPLETED"}, + {"name": "NOT_PLANNED"}, + {"name": "DUPLICATE"} + ] } } } + `), + }, + wantFeatures: IssueFeatures{ + StateReason: true, + StateReasonDuplicate: true, + ActorIsAssignable: false, }, wantErr: false, }, diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index c61d1d917..19510bf89 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -24,6 +24,7 @@ type CloseOptions struct { IssueNumber int Comment string Reason string + DuplicateOf string Detector fd.Detector } @@ -55,6 +56,13 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm } opts.IssueNumber = issueNumber + if opts.DuplicateOf != "" { + if opts.Reason == "" { + opts.Reason = "duplicate" + } else if opts.Reason != "duplicate" { + return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`") + } + } if runF != nil { return runF(opts) @@ -64,13 +72,22 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.Comment, "comment", "c", "", "Leave a closing comment") - cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned"}, "Reason for closing") + cmdutil.StringEnumFlag(cmd, &opts.Reason, "reason", "r", "", []string{"completed", "not planned", "duplicate"}, "Reason for closing") + cmd.Flags().StringVar(&opts.DuplicateOf, "duplicate-of", "", "Mark as duplicate of another issue by number or URL") return cmd } func closeRun(opts *CloseOptions) error { cs := opts.IO.ColorScheme() + closeReason := opts.Reason + if opts.DuplicateOf != "" { + if closeReason == "" { + closeReason = "duplicate" + } else if closeReason != "duplicate" { + return cmdutil.FlagErrorf("`--duplicate-of` can only be used with `--reason duplicate`") + } + } httpClient, err := opts.HttpClient() if err != nil { @@ -92,6 +109,32 @@ func closeRun(opts *CloseOptions) error { return nil } + var duplicateIssueID string + if opts.DuplicateOf != "" { + if issue.IsPullRequest() { + return cmdutil.FlagErrorf("`--duplicate-of` is only supported for issues") + } + duplicateIssueNumber, duplicateRepo, err := shared.ParseIssueFromArg(opts.DuplicateOf) + if err != nil { + return cmdutil.FlagErrorf("invalid value for `--duplicate-of`: %v", err) + } + duplicateIssueRepo := baseRepo + if parsedRepo, present := duplicateRepo.Value(); present { + duplicateIssueRepo = parsedRepo + } + if ghrepo.IsSame(baseRepo, duplicateIssueRepo) && issue.Number == duplicateIssueNumber { + return cmdutil.FlagErrorf("`--duplicate-of` cannot reference the current issue") + } + duplicateIssue, err := shared.FindIssueOrPR(httpClient, duplicateIssueRepo, duplicateIssueNumber, []string{"id"}) + if err != nil { + return err + } + if duplicateIssue.IsPullRequest() { + return cmdutil.FlagErrorf("`--duplicate-of` must reference an issue") + } + duplicateIssueID = duplicateIssue.ID + } + if opts.Comment != "" { commentOpts := &prShared.CommentableOptions{ Body: opts.Comment, @@ -108,7 +151,7 @@ func closeRun(opts *CloseOptions) error { } } - err = apiClose(httpClient, baseRepo, issue, opts.Detector, opts.Reason) + err = apiClose(httpClient, baseRepo, issue, opts.Detector, closeReason, duplicateIssueID) if err != nil { return err } @@ -118,12 +161,12 @@ func closeRun(opts *CloseOptions) error { return nil } -func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string) error { +func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string, duplicateIssueID string) error { if issue.IsPullRequest() { return api.PullRequestClose(httpClient, repo, issue.ID) } - if reason != "" { + if reason != "" || duplicateIssueID != "" { if detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) detector = fd.NewDetector(cachedClient, repo.RepoHost()) @@ -135,6 +178,15 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, // TODO stateReasonCleanup if !features.StateReason { // If StateReason is not supported silently close issue without setting StateReason. + if duplicateIssueID != "" { + return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) + } + reason = "" + } else if reason == "duplicate" && !features.StateReasonDuplicate { + if duplicateIssueID != "" { + return fmt.Errorf("closing as duplicate is not supported on %s", repo.RepoHost()) + } + // If DUPLICATE is not supported silently close issue without setting StateReason. reason = "" } } @@ -144,6 +196,8 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, // If no reason is specified do not set it. case "not planned": reason = "NOT_PLANNED" + case "duplicate": + reason = "DUPLICATE" default: reason = "COMPLETED" } @@ -158,8 +212,9 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, variables := map[string]interface{}{ "input": CloseIssueInput{ - IssueID: issue.ID, - StateReason: reason, + IssueID: issue.ID, + StateReason: reason, + DuplicateIssueID: duplicateIssueID, }, } @@ -168,6 +223,7 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, } type CloseIssueInput struct { - IssueID string `json:"issueId"` - StateReason string `json:"stateReason,omitempty"` + IssueID string `json:"issueId"` + StateReason string `json:"stateReason,omitempty"` + DuplicateIssueID string `json:"duplicateIssueId,omitempty"` } diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index 04c39cd8d..ddab71210 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -16,6 +16,15 @@ import ( "github.com/stretchr/testify/require" ) +type issueFeaturesDetectorMock struct { + fd.EnabledDetectorMock + issueFeatures fd.IssueFeatures +} + +func (md *issueFeaturesDetectorMock) IssueFeatures() (fd.IssueFeatures, error) { + return md.issueFeatures, nil +} + func TestNewCmdClose(t *testing.T) { // Test shared parsing of issue number / URL. argparsetest.TestArgParsing(t, NewCmdClose) @@ -44,6 +53,29 @@ func TestNewCmdClose(t *testing.T) { Reason: "not planned", }, }, + { + name: "reason duplicate", + input: "123 --reason duplicate", + output: CloseOptions{ + IssueNumber: 123, + Reason: "duplicate", + }, + }, + { + name: "duplicate of sets duplicate reason", + input: "123 --duplicate-of 456", + output: CloseOptions{ + IssueNumber: 123, + Reason: "duplicate", + DuplicateOf: "456", + }, + }, + { + name: "duplicate of with invalid reason", + input: "123 --reason completed --duplicate-of 456", + wantErr: true, + errMsg: "`--duplicate-of` can only be used with `--reason duplicate`", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -74,6 +106,7 @@ func TestNewCmdClose(t *testing.T) { assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber) assert.Equal(t, tt.output.Comment, gotOpts.Comment) assert.Equal(t, tt.output.Reason, gotOpts.Reason) + assert.Equal(t, tt.output.DuplicateOf, gotOpts.DuplicateOf) if tt.expectedBaseRepo != nil { baseRepo, err := gotOpts.BaseRepo() require.NoError(t, err) @@ -184,6 +217,201 @@ func TestCloseRun(t *testing.T) { }, wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", }, + { + name: "close issue with duplicate reason", + opts: &CloseOptions{ + IssueNumber: 13, + Reason: "duplicate", + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 2, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + assert.Equal(t, "DUPLICATE", inputs["stateReason"]) + }), + ) + }, + wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue as duplicate of another issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "DUPLICATE-ID", "number": 99} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 3, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + assert.Equal(t, "DUPLICATE", inputs["stateReason"]) + assert.Equal(t, "DUPLICATE-ID", inputs["duplicateIssueId"]) + }), + ) + }, + wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue with duplicate reason when duplicate is not supported", + opts: &CloseOptions{ + IssueNumber: 13, + Reason: "duplicate", + Detector: &issueFeaturesDetectorMock{ + issueFeatures: fd.IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, 1, len(inputs)) + assert.Equal(t, "THE-ID", inputs["issueId"]) + }), + ) + }, + wantStderr: "✓ Closed issue OWNER/REPO#13 (The title of the issue)\n", + }, + { + name: "close issue as duplicate when duplicate is not supported", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + Detector: &issueFeaturesDetectorMock{ + issueFeatures: fd.IssueFeatures{ + StateReason: true, + StateReasonDuplicate: false, + }, + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "DUPLICATE-ID", "number": 99} + } } }`), + ) + }, + wantErr: true, + errMsg: "closing as duplicate is not supported on github.com", + }, + { + name: "duplicate of cannot point to same issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "13", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + }, + wantErr: true, + errMsg: "`--duplicate-of` cannot reference the current issue", + }, + { + name: "duplicate of must reference an issue", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "99", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "__typename": "PullRequest", "id": "PULL-ID", "number": 99} + } } }`), + ) + }, + wantErr: true, + errMsg: "`--duplicate-of` must reference an issue", + }, + { + name: "duplicate of with invalid format", + opts: &CloseOptions{ + IssueNumber: 13, + DuplicateOf: "not-an-issue", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + }, + wantErr: true, + errMsg: "invalid value for `--duplicate-of`: invalid issue format: \"not-an-issue\"", + }, { name: "close issue with reason when reason is not supported", opts: &CloseOptions{