diff --git a/api/queries_issue.go b/api/queries_issue.go index ba9b43d6536..56a04edd906 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -390,3 +390,22 @@ func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { return nil } + +func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error { + var mutation struct { + ReopenIssue struct { + Issue struct { + ID githubv4.ID + } + } `graphql:"reopenIssue(input: $input)"` + } + + input := githubv4.ReopenIssueInput{ + IssueID: issue.ID, + } + + v4 := githubv4.NewClient(client.http) + err := v4.Mutate(context.Background(), &mutation, input, nil) + + return err +} diff --git a/command/issue.go b/command/issue.go index d1b57485ea0..f86ad2cd542 100644 --- a/command/issue.go +++ b/command/issue.go @@ -41,6 +41,7 @@ func init() { issueViewCmd.Flags().BoolP("web", "w", false, "Open an issue in the browser") issueCmd.AddCommand(issueCloseCmd) + issueCmd.AddCommand(issueReopenCmd) } var issueCmd = &cobra.Command{ @@ -82,11 +83,17 @@ With '--web', open the issue in a web browser instead.`, RunE: issueView, } var issueCloseCmd = &cobra.Command{ - Use: "close ", - Short: "close and issue issues", + Use: "close ", + Short: "close issue", Args: cobra.ExactArgs(1), RunE: issueClose, } +var issueReopenCmd = &cobra.Command{ + Use: "reopen ", + Short: "reopen issue", + Args: cobra.ExactArgs(1), + RunE: issueReopen, +} func issueList(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) @@ -559,7 +566,41 @@ func issueClose(cmd *cobra.Command, args []string) error { fmt.Fprintf(colorableErr(cmd), "%s Closed issue #%d\n", utils.Red("✔"), issue.Number) return nil +} + +func issueReopen(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 open\n", utils.Yellow("!"), issue.Number) + return nil + } + + err = api.IssueReopen(apiClient, baseRepo, *issue) + if err != nil { + return fmt.Errorf("API call failed:%w", err) + } + + fmt.Fprintf(colorableErr(cmd), "%s Reopened issue #%d\n", utils.Green("✔"), issue.Number) + + return nil } func displayURL(urlStr string) string { diff --git a/command/issue_test.go b/command/issue_test.go index 4f75cde3ab5..e408ce3ccb7 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -753,3 +753,76 @@ func TestIssueClose_issuesDisabled(t *testing.T) { t.Fatalf("got unexpected error: %s", err) } } + +func TestIssueReopen(t *testing.T) { + initBlankContext("", "OWNER/REPO", "master") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": true} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := RunCommand(issueReopenCmd, "issue reopen 2") + if err != nil { + t.Fatalf("error running command `issue reopen`: %v", err) + } + + r := regexp.MustCompile(`Reopened issue #2`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueReopen_alreadyOpen(t *testing.T) { + initBlankContext("", "OWNER/REPO", "master") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": false} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := RunCommand(issueReopenCmd, "issue reopen 2") + if err != nil { + t.Fatalf("error running command `issue reopen`: %v", err) + } + + r := regexp.MustCompile(`#2 is already open`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestIssueReopen_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(issueReopenCmd, "issue reopen 2") + 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) + } +}