diff --git a/api/queries_issue.go b/api/queries_issue.go index 2411ffe5d..ae409d729 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -26,6 +26,7 @@ type Issue struct { Title string URL string State string + StateReason string Closed bool Body string CreatedAt time.Time @@ -176,7 +177,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptio } } - fragments := fmt.Sprintf("fragment issue on Issue{%s}", PullRequestGraphQL(options.Fields)) + fragments := fmt.Sprintf("fragment issue on Issue{%s}", IssueGraphQL(options.Fields)) query := fragments + ` query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { diff --git a/api/query_builder.go b/api/query_builder.go index 996a52a71..1719f4dac 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -308,7 +308,7 @@ func IssueGraphQL(fields []string) string { // PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields. // It will try to sanitize the fields to just those available on pull request. func PullRequestGraphQL(fields []string) string { - invalidFields := []string{"isPinned"} + invalidFields := []string{"isPinned", "stateReason"} s := set.NewStringSet() s.AddValues(fields) s.RemoveValues(invalidFields) diff --git a/api/query_builder_test.go b/api/query_builder_test.go index 5b0762bdd..336752c5a 100644 --- a/api/query_builder_test.go +++ b/api/query_builder_test.go @@ -30,7 +30,7 @@ func TestPullRequestGraphQL(t *testing.T) { }, { name: "invalid fields", - fields: []string{"isPinned", "number"}, + fields: []string{"isPinned", "stateReason", "number"}, want: "number", }, } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index de599ee8b..6c81ac546 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -13,9 +13,13 @@ type Detector interface { RepositoryFeatures() (RepositoryFeatures, error) } -type IssueFeatures struct{} +type IssueFeatures struct { + StateReason bool +} -var allIssueFeatures = IssueFeatures{} +var allIssueFeatures = IssueFeatures{ + StateReason: true, +} type PullRequestFeatures struct { ReviewDecision bool @@ -64,7 +68,31 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { return allIssueFeatures, nil } - return allIssueFeatures, nil + features := IssueFeatures{ + StateReason: false, + } + + var featureDetection struct { + Issue struct { + Fields []struct { + Name string + } `graphql:"fields(includeDeprecated: true)"` + } `graphql:"Issue: __type(name: \"Issue\")"` + } + + gql := api.NewClientFromHTTP(d.httpClient) + err := gql.Query(d.host, "Issue_fields", &featureDetection, nil) + if err != nil { + return features, err + } + + for _, field := range featureDetection.Issue.Fields { + if field.Name == "stateReason" { + features.StateReason = true + } + } + + return features, nil } func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) { diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index fedfd0ffb..2f2fb5b05 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -9,6 +9,70 @@ import ( "github.com/stretchr/testify/assert" ) +func TestIssueFeatures(t *testing.T) { + tests := []struct { + name string + hostname string + queryResponse map[string]string + wantFeatures IssueFeatures + wantErr bool + }{ + { + name: "github.com", + hostname: "github.com", + wantFeatures: IssueFeatures{ + StateReason: true, + }, + wantErr: false, + }, + { + name: "GHE empty response", + hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields\b`: `{"data": {}}`, + }, + wantFeatures: IssueFeatures{ + StateReason: false, + }, + wantErr: false, + }, + { + name: "GHE has state reason field", + hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields\b`: heredoc.Doc(` + { "data": { "Issue": { "fields": [ + {"name": "stateReason"} + ] } } } + `), + }, + wantFeatures: IssueFeatures{ + StateReason: true, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + for query, resp := range tt.queryResponse { + reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp)) + } + detector := detector{host: tt.hostname, httpClient: httpClient} + gotFeatures, err := detector.IssueFeatures() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantFeatures, gotFeatures) + }) + } +} + func TestPullRequestFeatures(t *testing.T) { tests := []struct { name string @@ -82,13 +146,13 @@ func TestPullRequestFeatures(t *testing.T) { reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp)) } detector := detector{host: tt.hostname, httpClient: httpClient} - gotPrFeatures, err := detector.PullRequestFeatures() + gotFeatures, err := detector.PullRequestFeatures() if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) - assert.Equal(t, tt.wantFeatures, gotPrFeatures) + assert.Equal(t, tt.wantFeatures, gotFeatures) }) } } @@ -188,13 +252,13 @@ func TestRepositoryFeatures(t *testing.T) { reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp)) } detector := detector{host: tt.hostname, httpClient: httpClient} - gotPrFeatures, err := detector.RepositoryFeatures() + gotFeatures, err := detector.RepositoryFeatures() if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) - assert.Equal(t, tt.wantFeatures, gotPrFeatures) + assert.Equal(t, tt.wantFeatures, gotFeatures) }) } } diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index 10ad0ecbc..755363514 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -3,9 +3,10 @@ package close import ( "fmt" "net/http" + "time" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -17,19 +18,20 @@ import ( type CloseOptions struct { HttpClient func() (*http.Client, error) - Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) SelectorArg string Comment string + Reason string + + Detector fd.Detector } func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command { opts := &CloseOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, - Config: f.Config, } cmd := &cobra.Command{ @@ -52,6 +54,7 @@ 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") return cmd } @@ -90,7 +93,7 @@ func closeRun(opts *CloseOptions) error { } } - err = apiClose(httpClient, baseRepo, issue) + err = apiClose(httpClient, baseRepo, issue, opts.Detector, opts.Reason) if err != nil { return err } @@ -100,11 +103,35 @@ func closeRun(opts *CloseOptions) error { return nil } -func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue) error { +func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue, detector fd.Detector, reason string) error { if issue.IsPullRequest() { return api.PullRequestClose(httpClient, repo, issue.ID) } + if reason != "" { + if detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + detector = fd.NewDetector(cachedClient, repo.RepoHost()) + } + features, err := detector.IssueFeatures() + if err != nil { + return err + } + if !features.StateReason { + // If StateReason is not supported silently close issue without setting StateReason. + reason = "" + } + } + + switch reason { + case "": + // If no reason is specified do not set it. + case "not planned": + reason = "NOT_PLANNED" + default: + reason = "COMPLETED" + } + var mutation struct { CloseIssue struct { Issue struct { @@ -114,11 +141,17 @@ func apiClose(httpClient *http.Client, repo ghrepo.Interface, issue *api.Issue) } variables := map[string]interface{}{ - "input": githubv4.CloseIssueInput{ - IssueID: issue.ID, + "input": CloseIssueInput{ + IssueID: issue.ID, + StateReason: reason, }, } gql := api.NewClientFromHTTP(httpClient) return gql.Mutate(repo.RepoHost(), "IssueClose", &mutation, variables) } + +type CloseIssueInput struct { + IssueID string `json:"issueId"` + StateReason string `json:"stateReason,omitempty"` +} diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index 4d7bc973e..41a78d0d9 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -2,188 +2,280 @@ package close import ( "bytes" - "io" "net/http" - "regexp" "testing" - "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) -func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(isTTY) - ios.SetStdinTTY(isTTY) - ios.SetStderrTTY(isTTY) - - factory := &cmdutil.Factory{ - IOStreams: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: rt}, nil +func TestNewCmdClose(t *testing.T) { + tests := []struct { + name string + input string + output CloseOptions + wantErr bool + errMsg string + }{ + { + name: "no argument", + input: "", + wantErr: true, + errMsg: "accepts 1 arg(s), received 0", }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil + { + name: "issue number", + input: "123", + output: CloseOptions{ + SelectorArg: "123", + }, }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil + { + name: "issue url", + input: "https://github.com/cli/cli/3", + output: CloseOptions{ + SelectorArg: "https://github.com/cli/cli/3", + }, + }, + { + name: "comment", + input: "123 --comment 'closing comment'", + output: CloseOptions{ + SelectorArg: "123", + Comment: "closing comment", + }, + }, + { + name: "reason", + input: "123 --reason 'not planned'", + output: CloseOptions{ + SelectorArg: "123", + Reason: "not planned", + }, }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *CloseOptions + cmd := NewCmdClose(f, func(opts *CloseOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) - cmd := NewCmdClose(factory, nil) + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.errMsg, err.Error()) + return + } - argv, err := shlex.Split(cli) - if err != nil { - return nil, err - } - cmd.SetArgs(argv) - - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - - _, err = cmd.ExecuteC() - return &test.CmdOut{ - OutBuf: stdout, - ErrBuf: stderr, - }, err -} - -func TestIssueClose(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} - } } }`), - ) - http.Register( - httpmock.GraphQL(`mutation IssueClose\b`), - httpmock.GraphQLMutation(`{"id": "THE-ID"}`, - func(inputs map[string]interface{}) { - assert.Equal(t, inputs["issueId"], "THE-ID") - }), - ) - - output, err := runCommand(http, true, "13") - if err != nil { - t.Fatalf("error running command `issue close`: %v", err) - } - - r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + assert.NoError(t, err) + assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) + assert.Equal(t, tt.output.Comment, gotOpts.Comment) + assert.Equal(t, tt.output.Reason, gotOpts.Reason) + }) } } -func TestIssueClose_alreadyClosed(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "number": 13, "title": "The title of the issue", "state": "CLOSED"} - } } }`), - ) - - output, err := runCommand(http, true, "13") - if err != nil { - t.Fatalf("error running command `issue close`: %v", err) +func TestCloseRun(t *testing.T) { + tests := []struct { + name string + opts *CloseOptions + httpStubs func(*httpmock.Registry) + wantStderr string + wantErr bool + errMsg string + }{ + { + name: "close issue by number", + opts: &CloseOptions{ + SelectorArg: "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"} + } } }`), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "THE-ID", inputs["issueId"]) + }), + ) + }, + wantStderr: "✓ Closed issue #13 (The title of the issue)\n", + }, + { + name: "close issue with comment", + opts: &CloseOptions{ + SelectorArg: "13", + Comment: "closing comment", + }, + 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 CommentCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "addComment": { "commentEdge": { "node": { + "url": "https://github.com/OWNER/REPO/issues/123#issuecomment-456" + } } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "THE-ID", inputs["subjectId"]) + assert.Equal(t, "closing comment", inputs["body"]) + }), + ) + reg.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, "THE-ID", inputs["issueId"]) + }), + ) + }, + wantStderr: "✓ Closed issue #13 (The title of the issue)\n", + }, + { + name: "close issue with reason", + opts: &CloseOptions{ + SelectorArg: "13", + Reason: "not planned", + 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, "NOT_PLANNED", inputs["stateReason"]) + }), + ) + }, + wantStderr: "✓ Closed issue #13 (The title of the issue)\n", + }, + { + name: "close issue with reason when reason is not supported", + opts: &CloseOptions{ + SelectorArg: "13", + Reason: "not planned", + Detector: &fd.DisabledDetectorMock{}, + }, + 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 #13 (The title of the issue)\n", + }, + { + name: "issue already closed", + opts: &CloseOptions{ + SelectorArg: "13", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "title": "The title of the issue", "state": "CLOSED"} + } } }`), + ) + }, + wantStderr: "! Issue #13 (The title of the issue) is already closed\n", + }, + { + name: "issues disabled", + opts: &CloseOptions{ + SelectorArg: "13", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{ + "data": { "repository": { "hasIssuesEnabled": false, "issue": null } }, + "errors": [ { "type": "NOT_FOUND", "path": [ "repository", "issue" ], + "message": "Could not resolve to an issue or pull request with the number of 13." + } ] }`), + ) + }, + wantErr: true, + errMsg: "the 'OWNER/REPO' repository has disabled issues", + }, } + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + ios, _, _, stderr := iostreams.Test() + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + t.Run(tt.name, func(t *testing.T) { + defer reg.Verify(t) - r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`) + err := closeRun(tt.opts) + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestIssueClose_issuesDisabled(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { - "data": { - "repository": { - "hasIssuesEnabled": false, - "issue": null - } - }, - "errors": [ - { - "type": "NOT_FOUND", - "path": [ - "repository", - "issue" - ], - "message": "Could not resolve to an issue or pull request with the number of 13." - } - ] - }`), - ) - - _, err := runCommand(http, true, "13") - if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { - t.Fatalf("got error: %v", err) - } -} - -func TestIssueClose_withComment(t *testing.T) { - http := &httpmock.Registry{} - defer http.Verify(t) - - http.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} - } } }`), - ) - http.Register( - httpmock.GraphQL(`mutation CommentCreate\b`), - httpmock.GraphQLMutation(` - { "data": { "addComment": { "commentEdge": { "node": { - "url": "https://github.com/OWNER/REPO/issues/123#issuecomment-456" - } } } } }`, - func(inputs map[string]interface{}) { - assert.Equal(t, "THE-ID", inputs["subjectId"]) - assert.Equal(t, "closing comment", inputs["body"]) - }), - ) - http.Register( - httpmock.GraphQL(`mutation IssueClose\b`), - httpmock.GraphQLMutation(`{"id": "THE-ID"}`, - func(inputs map[string]interface{}) { - assert.Equal(t, inputs["issueId"], "THE-ID") - }), - ) - - output, err := runCommand(http, true, "13 --comment 'closing comment'") - if err != nil { - t.Fatalf("error running command `issue close`: %v", err) - } - - r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + assert.NoError(t, err) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) } } diff --git a/pkg/cmd/issue/list/http.go b/pkg/cmd/issue/list/http.go index db7633157..fcbfe7240 100644 --- a/pkg/cmd/issue/list/http.go +++ b/pkg/cmd/issue/list/http.go @@ -21,7 +21,7 @@ func listIssues(client *api.Client, repo ghrepo.Interface, filters prShared.Filt return nil, fmt.Errorf("invalid state: %s", filters.State) } - fragments := fmt.Sprintf("fragment issue on Issue {%s}", api.PullRequestGraphQL(filters.Fields)) + fragments := fmt.Sprintf("fragment issue on Issue {%s}", api.IssueGraphQL(filters.Fields)) query := fragments + ` query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String) { repository(owner: $owner, name: $repo) { @@ -113,7 +113,7 @@ loop: } func searchIssues(client *api.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { - fragments := fmt.Sprintf("fragment issue on Issue {%s}", api.PullRequestGraphQL(filters.Fields)) + fragments := fmt.Sprintf("fragment issue on Issue {%s}", api.IssueGraphQL(filters.Fields)) query := fragments + `query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { repository(name: $repo, owner: $owner) { diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index cdfc985ba..24f2565f2 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -29,9 +30,6 @@ type ListOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser - WebMode bool - Exporter cmdutil.Exporter - Assignee string Labels []string State string @@ -40,8 +38,11 @@ type ListOptions struct { Mention string Milestone string Search string + WebMode bool + Exporter cmdutil.Exporter - Now func() time.Time + Detector fd.Detector + Now func() time.Time } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -136,6 +137,19 @@ func listRun(opts *ListOptions) error { issueState = "" } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + features, err := opts.Detector.IssueFeatures() + if err != nil { + return err + } + fields := defaultFields + if features.StateReason { + fields = append(defaultFields, "stateReason") + } + filterOptions := prShared.FilterOptions{ Entity: "issue", State: issueState, @@ -145,7 +159,7 @@ func listRun(opts *ListOptions) error { Mention: opts.Mention, Milestone: opts.Milestone, Search: opts.Search, - Fields: defaultFields, + Fields: fields, } isTerminal := opts.IO.IsStdoutTTY() diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index f04a37328..552820cc3 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -8,9 +8,12 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/set" ) // IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields @@ -65,6 +68,21 @@ type PartialLoadError struct { } func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) { + fieldSet := set.NewStringSet() + fieldSet.AddValues(fields) + if fieldSet.Contains("stateReason") { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + detector := fd.NewDetector(cachedClient, repo.RepoHost()) + features, err := detector.IssueFeatures() + if err != nil { + return nil, err + } + if !features.StateReason { + fieldSet.Remove("stateReason") + } + } + fields = fieldSet.ToSlice() + type response struct { Repository struct { HasIssuesEnabled bool diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 044cb6c3d..7e1982c18 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -77,7 +77,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman var defaultFields = []string{ "number", "url", "state", "createdAt", "title", "body", "author", "milestone", - "assignees", "labels", "projectCards", "reactionGroups", "lastComment", + "assignees", "labels", "projectCards", "reactionGroups", "lastComment", "stateReason", } func viewRun(opts *ViewOptions) error { @@ -93,11 +93,13 @@ func viewRun(opts *ViewOptions) error { lookupFields.Add("url") } else { lookupFields.AddValues(defaultFields) + if opts.Comments { + lookupFields.Add("comments") + lookupFields.Remove("lastComment") + } } - if opts.Comments { - lookupFields.Add("comments") - lookupFields.Remove("lastComment") - } + + opts.IO.DetectTerminalTheme() opts.IO.StartProgressIndicator() issue, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields.ToSlice()) @@ -119,7 +121,6 @@ func viewRun(opts *ViewOptions) error { return opts.Browser.Browse(openURL) } - opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) } diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go index f788b46e9..e31c86d2a 100644 --- a/pkg/cmd/pr/shared/display.go +++ b/pkg/cmd/pr/shared/display.go @@ -38,6 +38,9 @@ func ColorForIssueState(issue api.Issue) string { case "OPEN": return "green" case "CLOSED": + if issue.StateReason == "NOT_PLANNED" { + return "gray" + } return "magenta" default: return "" diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index e67bc8381..7531cfedf 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -112,7 +112,7 @@ func displayIssueResults(io *iostreams.IOStreams, et EntityType, results search. if issue.IsPullRequest() { tp.AddField(issueNum, nil, cs.ColorFromString(colorForPRState(issue.State))) } else { - tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State))) + tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State, issue.StateReason))) } if !tp.IsTTY() { tp.AddField(issue.State, nil, nil) @@ -157,11 +157,14 @@ func listIssueLabels(issue *search.Issue, cs *iostreams.ColorScheme, colorize bo return strings.Join(labelNames, ", ") } -func colorForIssueState(state string) string { +func colorForIssueState(state, reason string) string { switch state { case "open": return "green" case "closed": + if reason == "not_planned" { + return "gray" + } return "magenta" default: return "" diff --git a/pkg/search/result.go b/pkg/search/result.go index 92de55113..4447f48ee 100644 --- a/pkg/search/result.go +++ b/pkg/search/result.go @@ -131,6 +131,7 @@ type Issue struct { PullRequestLinks PullRequestLinks `json:"pull_request"` RepositoryURL string `json:"repository_url"` State string `json:"state"` + StateReason string `json:"state_reason"` Title string `json:"title"` URL string `json:"html_url"` UpdatedAt time.Time `json:"updated_at"`