Repository: cli/cli
Pull request #6245 (2022-09-13 06:58:46)

Add support for issue state reason
This PR adds support for issue state reason to various commands:
- `issue list` set color for closed issues according to state reason
- `issue view` set color for closed issues according to state reason
- `search issues` set color for closed issues according to state reason
- `issue close` add `--reason` flag to allow closing of issues with a reason.
    - If running on GHES that does not support issue state reason, silently remove reason but still close the issue.

This PR does not add support of searching by issue state reason to `issue list` or `search issues`. Both of those commands support custom search queries that can be used to search by issue state reason according to the [docs](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-reason-an-issue-was-closed). We can revisit adding in dedicated flags for this later, but I felt as support for issue state reason was only introduced in GHES 3.6 that adding in flags that did not work on all supported GHES versions would be confusing for users of GHES older than 3.6. 

cc/ @azenMatt
cc/ https://github.com/github/cli/issues/114


Patch:
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/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/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/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,12 +93,14 @@ 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())
 	opts.IO.StopProgressIndicator()
@@ -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"`


Test patch:
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_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_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)
-
-	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())
-	}
-}
-
-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)
-	}
-
-	r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`)
-
-	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 = cmd.ExecuteC()
+			if tt.wantErr {
+				assert.Error(t, err)
+				assert.Equal(t, tt.errMsg, err.Error())
+				return
+			}
 
-	_, err := runCommand(http, true, "13")
-	if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
-		t.Fatalf("got error: %v", err)
+			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_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)
+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(`Closed issue #13 \(The title of the issue\)`)
+			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())
+			assert.NoError(t, err)
+			assert.Equal(t, tt.wantStderr, stderr.String())
+		})
 	}
 }