Skip to content

Commit e172f31

Browse files
Merge pull request cli#854 from cli/close-a-pull-request
Add `gh pr close <numberOrURL>`
2 parents 9e9e994 + fef11b3 commit e172f31

File tree

3 files changed

+258
-3
lines changed

3 files changed

+258
-3
lines changed

api/queries_pr.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package api
22

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

89
"github.com/cli/cli/internal/ghrepo"
10+
"github.com/shurcooL/githubv4"
911
)
1012

1113
type PullRequestsPayload struct {
@@ -20,9 +22,11 @@ type PullRequestAndTotalCount struct {
2022
}
2123

2224
type PullRequest struct {
25+
ID string
2326
Number int
2427
Title string
2528
State string
29+
Closed bool
2630
URL string
2731
BaseRefName string
2832
HeadRefName string
@@ -344,10 +348,12 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
344348
query($owner: String!, $repo: String!, $pr_number: Int!) {
345349
repository(owner: $owner, name: $repo) {
346350
pullRequest(number: $pr_number) {
351+
id
347352
url
348353
number
349354
title
350355
state
356+
closed
351357
body
352358
author {
353359
login
@@ -755,6 +761,44 @@ loop:
755761
return &res, nil
756762
}
757763

764+
func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
765+
var mutation struct {
766+
ClosePullRequest struct {
767+
PullRequest struct {
768+
ID githubv4.ID
769+
}
770+
} `graphql:"closePullRequest(input: $input)"`
771+
}
772+
773+
input := githubv4.ClosePullRequestInput{
774+
PullRequestID: pr.ID,
775+
}
776+
777+
v4 := githubv4.NewClient(client.http)
778+
err := v4.Mutate(context.Background(), &mutation, input, nil)
779+
780+
return err
781+
}
782+
783+
func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
784+
var mutation struct {
785+
ReopenPullRequest struct {
786+
PullRequest struct {
787+
ID githubv4.ID
788+
}
789+
} `graphql:"reopenPullRequest(input: $input)"`
790+
}
791+
792+
input := githubv4.ReopenPullRequestInput{
793+
PullRequestID: pr.ID,
794+
}
795+
796+
v4 := githubv4.NewClient(client.http)
797+
err := v4.Mutate(context.Background(), &mutation, input, nil)
798+
799+
return err
800+
}
801+
758802
func min(a, b int) int {
759803
if a < b {
760804
return a

command/pr.go

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,18 @@ func init() {
2121
RootCmd.AddCommand(prCmd)
2222
prCmd.AddCommand(prCheckoutCmd)
2323
prCmd.AddCommand(prCreateCmd)
24-
prCmd.AddCommand(prListCmd)
2524
prCmd.AddCommand(prStatusCmd)
26-
prCmd.AddCommand(prViewCmd)
25+
prCmd.AddCommand(prCloseCmd)
26+
prCmd.AddCommand(prReopenCmd)
2727

28+
prCmd.AddCommand(prListCmd)
2829
prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of items to fetch")
2930
prListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|merged|all}")
3031
prListCmd.Flags().StringP("base", "B", "", "Filter by base branch")
3132
prListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label")
3233
prListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
3334

35+
prCmd.AddCommand(prViewCmd)
3436
prViewCmd.Flags().BoolP("web", "w", false, "Open a pull request in the browser")
3537
}
3638

@@ -65,6 +67,18 @@ is displayed.
6567
With '--web', open the pull request in a web browser instead.`,
6668
RunE: prView,
6769
}
70+
var prCloseCmd = &cobra.Command{
71+
Use: "close <number | url>",
72+
Short: "Close a pull request",
73+
Args: cobra.ExactArgs(1),
74+
RunE: prClose,
75+
}
76+
var prReopenCmd = &cobra.Command{
77+
Use: "reopen <number | url>",
78+
Short: "Reopen a pull request",
79+
Args: cobra.ExactArgs(1),
80+
RunE: prReopen,
81+
}
6882

6983
func prStatus(cmd *cobra.Command, args []string) error {
7084
ctx := contextForCommand(cmd)
@@ -328,6 +342,78 @@ func prView(cmd *cobra.Command, args []string) error {
328342
}
329343
}
330344

345+
func prClose(cmd *cobra.Command, args []string) error {
346+
ctx := contextForCommand(cmd)
347+
apiClient, err := apiClientForContext(ctx)
348+
if err != nil {
349+
return err
350+
}
351+
352+
baseRepo, err := determineBaseRepo(cmd, ctx)
353+
if err != nil {
354+
return err
355+
}
356+
357+
pr, err := prFromArg(apiClient, baseRepo, args[0])
358+
if err != nil {
359+
return err
360+
}
361+
362+
if pr.State == "MERGED" {
363+
err := fmt.Errorf("%s Pull request #%d can't be closed because it was already merged", utils.Red("!"), pr.Number)
364+
return err
365+
} else if pr.Closed {
366+
fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is already closed\n", utils.Yellow("!"), pr.Number)
367+
return nil
368+
}
369+
370+
err = api.PullRequestClose(apiClient, baseRepo, pr)
371+
if err != nil {
372+
return fmt.Errorf("API call failed: %w", err)
373+
}
374+
375+
fmt.Fprintf(colorableErr(cmd), "%s Closed pull request #%d\n", utils.Red("✔"), pr.Number)
376+
377+
return nil
378+
}
379+
380+
func prReopen(cmd *cobra.Command, args []string) error {
381+
ctx := contextForCommand(cmd)
382+
apiClient, err := apiClientForContext(ctx)
383+
if err != nil {
384+
return err
385+
}
386+
387+
baseRepo, err := determineBaseRepo(cmd, ctx)
388+
if err != nil {
389+
return err
390+
}
391+
392+
pr, err := prFromArg(apiClient, baseRepo, args[0])
393+
if err != nil {
394+
return err
395+
}
396+
397+
if pr.State == "MERGED" {
398+
err := fmt.Errorf("%s Pull request #%d can't be reopened because it was already merged", utils.Red("!"), pr.Number)
399+
return err
400+
}
401+
402+
if !pr.Closed {
403+
fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is already open\n", utils.Yellow("!"), pr.Number)
404+
return nil
405+
}
406+
407+
err = api.PullRequestReopen(apiClient, baseRepo, pr)
408+
if err != nil {
409+
return fmt.Errorf("API call failed: %w", err)
410+
}
411+
412+
fmt.Fprintf(colorableErr(cmd), "%s Reopened pull request #%d\n", utils.Green("✔"), pr.Number)
413+
414+
return nil
415+
}
416+
331417
func printPrPreview(out io.Writer, pr *api.PullRequest) error {
332418
// Header (Title and State)
333419
fmt.Fprintln(out, utils.Bold(pr.Title))

command/pr_test.go

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ func RunCommand(cmd *cobra.Command, args string) (*cmdOut, error) {
5151
cmd.SetOut(&outBuf)
5252
errBuf := bytes.Buffer{}
5353
cmd.SetErr(&errBuf)
54-
5554
// Reset flag values so they don't leak between tests
5655
// FIXME: change how we initialize Cobra commands to render this hack unnecessary
5756
cmd.Flags().VisitAll(func(f *pflag.Flag) {
@@ -800,3 +799,129 @@ func TestPrStateTitleWithColor(t *testing.T) {
800799
})
801800
}
802801
}
802+
803+
func TestPrClose(t *testing.T) {
804+
initBlankContext("", "OWNER/REPO", "master")
805+
http := initFakeHTTP()
806+
http.StubRepoResponse("OWNER", "REPO")
807+
808+
http.StubResponse(200, bytes.NewBufferString(`
809+
{ "data": { "repository": {
810+
"pullRequest": { "number": 96 }
811+
} } }
812+
`))
813+
814+
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
815+
816+
output, err := RunCommand(prCloseCmd, "pr close 96")
817+
if err != nil {
818+
t.Fatalf("error running command `pr close`: %v", err)
819+
}
820+
821+
r := regexp.MustCompile(`Closed pull request #96`)
822+
823+
if !r.MatchString(output.Stderr()) {
824+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
825+
}
826+
}
827+
828+
func TestPrClose_alreadyClosed(t *testing.T) {
829+
initBlankContext("", "OWNER/REPO", "master")
830+
http := initFakeHTTP()
831+
http.StubRepoResponse("OWNER", "REPO")
832+
833+
http.StubResponse(200, bytes.NewBufferString(`
834+
{ "data": { "repository": {
835+
"pullRequest": { "number": 101, "closed": true }
836+
} } }
837+
`))
838+
839+
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
840+
841+
output, err := RunCommand(prCloseCmd, "pr close 101")
842+
if err != nil {
843+
t.Fatalf("error running command `pr close`: %v", err)
844+
}
845+
846+
r := regexp.MustCompile(`Pull request #101 is already closed`)
847+
848+
if !r.MatchString(output.Stderr()) {
849+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
850+
}
851+
}
852+
853+
func TestPRReopen(t *testing.T) {
854+
initBlankContext("", "OWNER/REPO", "master")
855+
http := initFakeHTTP()
856+
http.StubRepoResponse("OWNER", "REPO")
857+
858+
http.StubResponse(200, bytes.NewBufferString(`
859+
{ "data": { "repository": {
860+
"pullRequest": { "number": 666, "closed": true}
861+
} } }
862+
`))
863+
864+
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
865+
866+
output, err := RunCommand(prReopenCmd, "pr reopen 666")
867+
if err != nil {
868+
t.Fatalf("error running command `pr reopen`: %v", err)
869+
}
870+
871+
r := regexp.MustCompile(`Reopened pull request #666`)
872+
873+
if !r.MatchString(output.Stderr()) {
874+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
875+
}
876+
}
877+
878+
func TestPRReopen_alreadyOpen(t *testing.T) {
879+
initBlankContext("", "OWNER/REPO", "master")
880+
http := initFakeHTTP()
881+
http.StubRepoResponse("OWNER", "REPO")
882+
883+
http.StubResponse(200, bytes.NewBufferString(`
884+
{ "data": { "repository": {
885+
"pullRequest": { "number": 666, "closed": false}
886+
} } }
887+
`))
888+
889+
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
890+
891+
output, err := RunCommand(prReopenCmd, "pr reopen 666")
892+
if err != nil {
893+
t.Fatalf("error running command `pr reopen`: %v", err)
894+
}
895+
896+
r := regexp.MustCompile(`Pull request #666 is already open`)
897+
898+
if !r.MatchString(output.Stderr()) {
899+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
900+
}
901+
}
902+
903+
func TestPRReopen_alreadyMerged(t *testing.T) {
904+
initBlankContext("", "OWNER/REPO", "master")
905+
http := initFakeHTTP()
906+
http.StubRepoResponse("OWNER", "REPO")
907+
908+
http.StubResponse(200, bytes.NewBufferString(`
909+
{ "data": { "repository": {
910+
"pullRequest": { "number": 666, "closed": true, "state": "MERGED"}
911+
} } }
912+
`))
913+
914+
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
915+
916+
output, err := RunCommand(prReopenCmd, "pr reopen 666")
917+
if err == nil {
918+
t.Fatalf("expected an error running command `pr reopen`: %v", err)
919+
}
920+
921+
r := regexp.MustCompile(`Pull request #666 can't be reopened because it was already merged`)
922+
923+
if !r.MatchString(err.Error()) {
924+
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
925+
}
926+
927+
}

0 commit comments

Comments
 (0)