Skip to content

Commit 28b35aa

Browse files
Merge pull request cli#843 from cli/when-god-closes-an-issue-he-opens-a-pull-request
Add `gh issue close <urlOrNumber>`
2 parents d908320 + c78c30b commit 28b35aa

File tree

3 files changed

+152
-1
lines changed

3 files changed

+152
-1
lines changed

api/queries_issue.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package api
22

33
import (
4+
"context"
45
"fmt"
56
"time"
67

78
"github.com/cli/cli/internal/ghrepo"
9+
"github.com/shurcooL/githubv4"
810
)
911

1012
type IssuesPayload struct {
@@ -20,10 +22,12 @@ type IssuesAndTotalCount struct {
2022

2123
// Ref. https://developer.github.com/v4/object/issue/
2224
type Issue struct {
25+
ID string
2326
Number int
2427
Title string
2528
URL string
2629
State string
30+
Closed bool
2731
Body string
2832
CreatedAt time.Time
2933
UpdatedAt time.Time
@@ -61,6 +65,10 @@ type Issue struct {
6165
}
6266
}
6367

68+
type IssuesDisabledError struct {
69+
error
70+
}
71+
6472
const fragments = `
6573
fragment issue on Issue {
6674
number
@@ -296,8 +304,10 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
296304
repository(owner: $owner, name: $repo) {
297305
hasIssuesEnabled
298306
issue(number: $issue_number) {
307+
id
299308
title
300309
state
310+
closed
301311
body
302312
author {
303313
login
@@ -351,8 +361,32 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
351361
}
352362

353363
if !resp.Repository.HasIssuesEnabled {
354-
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
364+
365+
return nil, &IssuesDisabledError{fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))}
355366
}
356367

357368
return &resp.Repository.Issue, nil
358369
}
370+
371+
func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error {
372+
var mutation struct {
373+
CloseIssue struct {
374+
Issue struct {
375+
ID githubv4.ID
376+
}
377+
} `graphql:"closeIssue(input: $input)"`
378+
}
379+
380+
input := githubv4.CloseIssueInput{
381+
IssueID: issue.ID,
382+
}
383+
384+
v4 := githubv4.NewClient(client.http)
385+
err := v4.Mutate(context.Background(), &mutation, input, nil)
386+
387+
if err != nil {
388+
return err
389+
}
390+
391+
return nil
392+
}

command/issue.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ func init() {
3939

4040
issueCmd.AddCommand(issueViewCmd)
4141
issueViewCmd.Flags().BoolP("web", "w", false, "Open an issue in the browser")
42+
43+
issueCmd.AddCommand(issueCloseCmd)
4244
}
4345

4446
var issueCmd = &cobra.Command{
@@ -79,6 +81,12 @@ var issueViewCmd = &cobra.Command{
7981
With '--web', open the issue in a web browser instead.`,
8082
RunE: issueView,
8183
}
84+
var issueCloseCmd = &cobra.Command{
85+
Use: "close <number>",
86+
Short: "close and issue issues",
87+
Args: cobra.ExactArgs(1),
88+
RunE: issueClose,
89+
}
8290

8391
func issueList(cmd *cobra.Command, args []string) error {
8492
ctx := contextForCommand(cmd)
@@ -518,6 +526,42 @@ func issueProjectList(issue api.Issue) string {
518526
return list
519527
}
520528

529+
func issueClose(cmd *cobra.Command, args []string) error {
530+
ctx := contextForCommand(cmd)
531+
apiClient, err := apiClientForContext(ctx)
532+
if err != nil {
533+
return err
534+
}
535+
536+
baseRepo, err := determineBaseRepo(cmd, ctx)
537+
if err != nil {
538+
return err
539+
}
540+
541+
issue, err := issueFromArg(apiClient, baseRepo, args[0])
542+
var idErr *api.IssuesDisabledError
543+
if errors.As(err, &idErr) {
544+
return fmt.Errorf("issues disabled for %s", ghrepo.FullName(baseRepo))
545+
} else if err != nil {
546+
return fmt.Errorf("failed to find issue #%d: %w", issue.Number, err)
547+
}
548+
549+
if issue.Closed {
550+
fmt.Fprintf(colorableErr(cmd), "%s Issue #%d is already closed\n", utils.Yellow("!"), issue.Number)
551+
return nil
552+
}
553+
554+
err = api.IssueClose(apiClient, baseRepo, *issue)
555+
if err != nil {
556+
return fmt.Errorf("API call failed:%w", err)
557+
}
558+
559+
fmt.Fprintf(colorableErr(cmd), "%s Closed issue #%d\n", utils.Red("✔"), issue.Number)
560+
561+
return nil
562+
563+
}
564+
521565
func displayURL(urlStr string) string {
522566
u, err := url.Parse(urlStr)
523567
if err != nil {

command/issue_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,3 +680,76 @@ func TestIssueStateTitleWithColor(t *testing.T) {
680680
})
681681
}
682682
}
683+
684+
func TestIssueClose(t *testing.T) {
685+
initBlankContext("", "OWNER/REPO", "master")
686+
http := initFakeHTTP()
687+
http.StubRepoResponse("OWNER", "REPO")
688+
689+
http.StubResponse(200, bytes.NewBufferString(`
690+
{ "data": { "repository": {
691+
"hasIssuesEnabled": true,
692+
"issue": { "number": 13}
693+
} } }
694+
`))
695+
696+
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
697+
698+
output, err := RunCommand(issueCloseCmd, "issue close 13")
699+
if err != nil {
700+
t.Fatalf("error running command `issue close`: %v", err)
701+
}
702+
703+
r := regexp.MustCompile(`Closed issue #13`)
704+
705+
if !r.MatchString(output.Stderr()) {
706+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
707+
}
708+
}
709+
710+
func TestIssueClose_alreadyClosed(t *testing.T) {
711+
initBlankContext("", "OWNER/REPO", "master")
712+
http := initFakeHTTP()
713+
http.StubRepoResponse("OWNER", "REPO")
714+
715+
http.StubResponse(200, bytes.NewBufferString(`
716+
{ "data": { "repository": {
717+
"hasIssuesEnabled": true,
718+
"issue": { "number": 13, "closed": true}
719+
} } }
720+
`))
721+
722+
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
723+
724+
output, err := RunCommand(issueCloseCmd, "issue close 13")
725+
if err != nil {
726+
t.Fatalf("error running command `issue close`: %v", err)
727+
}
728+
729+
r := regexp.MustCompile(`#13 is already closed`)
730+
731+
if !r.MatchString(output.Stderr()) {
732+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
733+
}
734+
}
735+
736+
func TestIssueClose_issuesDisabled(t *testing.T) {
737+
initBlankContext("", "OWNER/REPO", "master")
738+
http := initFakeHTTP()
739+
http.StubRepoResponse("OWNER", "REPO")
740+
741+
http.StubResponse(200, bytes.NewBufferString(`
742+
{ "data": { "repository": {
743+
"hasIssuesEnabled": false
744+
} } }
745+
`))
746+
747+
_, err := RunCommand(issueCloseCmd, "issue close 13")
748+
if err == nil {
749+
t.Fatalf("expected error when issues are disabled")
750+
}
751+
752+
if !strings.Contains(err.Error(), "issues disabled") {
753+
t.Fatalf("got unexpected error: %s", err)
754+
}
755+
}

0 commit comments

Comments
 (0)