Add support for issue state reason (#6245)
This commit is contained in:
parent
e7102f9d84
commit
e14d14cef2
14 changed files with 450 additions and 192 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ func TestPullRequestGraphQL(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
fields: []string{"isPinned", "number"},
|
||||
fields: []string{"isPinned", "stateReason", "number"},
|
||||
want: "number",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue