Merge pull request #12811 from tksohishi/issue-close-duplicate-of

Add `--duplicate-of` flag and duplicate reason to issue close
This commit is contained in:
Kynan Ware 2026-03-03 20:42:05 -07:00 committed by GitHub
commit 3fec2e5f7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 352 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 {
@ -148,6 +151,11 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Issue: __type(name: \"Issue\")"`
IssueClosedStateReason struct {
EnumValues []struct {
Name string
} `graphql:"enumValues(includeDeprecated: true)"`
} `graphql:"IssueClosedStateReason: __type(name: \"IssueClosedStateReason\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
@ -162,6 +170,15 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
}
}
if features.StateReason {
for _, enumValue := range featureDetection.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,23 +46,50 @@ 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(`
{ "data": { "Issue": { "fields": [
{"name": "stateReason"}
] }, "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"}
] }, "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{