Add support for issue state reason (#6245)

This commit is contained in:
Sam Coe 2022-09-14 12:39:15 +04:00 committed by GitHub
parent e7102f9d84
commit e14d14cef2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 450 additions and 192 deletions

View file

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

View file

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

View file

@ -30,7 +30,7 @@ func TestPullRequestGraphQL(t *testing.T) {
},
{
name: "invalid fields",
fields: []string{"isPinned", "number"},
fields: []string{"isPinned", "stateReason", "number"},
want: "number",
},
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

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

View file

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

View file

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