diff --git a/api/queries_issue.go b/api/queries_issue.go index cc01c039f..ba9b43d65 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -1,10 +1,12 @@ package api import ( + "context" "fmt" "time" "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" ) type IssuesPayload struct { @@ -20,10 +22,12 @@ type IssuesAndTotalCount struct { // Ref. https://developer.github.com/v4/object/issue/ type Issue struct { + ID string Number int Title string URL string State string + Closed bool Body string CreatedAt time.Time UpdatedAt time.Time @@ -61,6 +65,10 @@ type Issue struct { } } +type IssuesDisabledError struct { + error +} + const fragments = ` fragment issue on Issue { number @@ -296,8 +304,10 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e repository(owner: $owner, name: $repo) { hasIssuesEnabled issue(number: $issue_number) { + id title state + closed body author { login @@ -351,8 +361,32 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e } if !resp.Repository.HasIssuesEnabled { - return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) + + return nil, &IssuesDisabledError{fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))} } return &resp.Repository.Issue, nil } + +func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { + var mutation struct { + CloseIssue struct { + Issue struct { + ID githubv4.ID + } + } `graphql:"closeIssue(input: $input)"` + } + + input := githubv4.CloseIssueInput{ + IssueID: issue.ID, + } + + v4 := githubv4.NewClient(client.http) + err := v4.Mutate(context.Background(), &mutation, input, nil) + + if err != nil { + return err + } + + return nil +} diff --git a/command/issue.go b/command/issue.go index f2cb4ba47..d1b57485e 100644 --- a/command/issue.go +++ b/command/issue.go @@ -39,6 +39,8 @@ func init() { issueCmd.AddCommand(issueViewCmd) issueViewCmd.Flags().BoolP("web", "w", false, "Open an issue in the browser") + + issueCmd.AddCommand(issueCloseCmd) } var issueCmd = &cobra.Command{ @@ -79,6 +81,12 @@ var issueViewCmd = &cobra.Command{ With '--web', open the issue in a web browser instead.`, RunE: issueView, } +var issueCloseCmd = &cobra.Command{ + Use: "close ", + Short: "close and issue issues", + Args: cobra.ExactArgs(1), + RunE: issueClose, +} func issueList(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) @@ -518,6 +526,42 @@ func issueProjectList(issue api.Issue) string { return list } +func issueClose(cmd *cobra.Command, args []string) error { + ctx := contextForCommand(cmd) + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + + baseRepo, err := determineBaseRepo(cmd, ctx) + if err != nil { + return err + } + + issue, err := issueFromArg(apiClient, baseRepo, args[0]) + var idErr *api.IssuesDisabledError + if errors.As(err, &idErr) { + return fmt.Errorf("issues disabled for %s", ghrepo.FullName(baseRepo)) + } else if err != nil { + return fmt.Errorf("failed to find issue #%d: %w", issue.Number, err) + } + + if issue.Closed { + fmt.Fprintf(colorableErr(cmd), "%s Issue #%d is already closed\n", utils.Yellow("!"), issue.Number) + return nil + } + + err = api.IssueClose(apiClient, baseRepo, *issue) + if err != nil { + return fmt.Errorf("API call failed:%w", err) + } + + fmt.Fprintf(colorableErr(cmd), "%s Closed issue #%d\n", utils.Red("✔"), issue.Number) + + return nil + +} + func displayURL(urlStr string) string { u, err := url.Parse(urlStr) if err != nil { diff --git a/command/issue_test.go b/command/issue_test.go index e9229ac24..4f75cde3a 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -680,3 +680,76 @@ func TestIssueStateTitleWithColor(t *testing.T) { }) } } + +func TestIssueClose(t *testing.T) { + initBlankContext("", "OWNER/REPO", "master") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := RunCommand(issueCloseCmd, "issue close 13") + if err != nil { + t.Fatalf("error running command `issue close`: %v", err) + } + + r := regexp.MustCompile(`Closed issue #13`) + + 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) { + initBlankContext("", "OWNER/REPO", "master") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "closed": true} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := RunCommand(issueCloseCmd, "issue close 13") + if err != nil { + t.Fatalf("error running command `issue close`: %v", err) + } + + r := regexp.MustCompile(`#13 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) { + initBlankContext("", "OWNER/REPO", "master") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } } + `)) + + _, err := RunCommand(issueCloseCmd, "issue close 13") + if err == nil { + t.Fatalf("expected error when issues are disabled") + } + + if !strings.Contains(err.Error(), "issues disabled") { + t.Fatalf("got unexpected error: %s", err) + } +}