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.
This commit is contained in:
Takeshi 2026-02-28 20:01:38 -05:00
parent 097bad6f73
commit 01c83acfe8
4 changed files with 368 additions and 22 deletions

View file

@ -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
}

View file

@ -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,
},

View file

@ -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"`
}

View file

@ -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{