From 667704d574eaec480460423239c851620288a975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 6 Nov 2019 17:33:45 +0100 Subject: [PATCH] Add `pr list` command Old `pr list` is now `pr status` --- api/queries.go | 92 ++++++++++++++++++++++ command/pr.go | 147 ++++++++++++++++++++++++++++++------ command/pr_test.go | 70 ++++++++++++++++- go.mod | 1 + go.sum | 1 + test/fixtures/prList.json | 80 +++++++++----------- test/fixtures/prStatus.json | 50 ++++++++++++ 7 files changed, 368 insertions(+), 73 deletions(-) create mode 100644 test/fixtures/prStatus.json diff --git a/api/queries.go b/api/queries.go index e1e078138..7e9ae0831 100644 --- a/api/queries.go +++ b/api/queries.go @@ -171,3 +171,95 @@ func PullRequestsForBranch(client *Client, ghRepo Repo, branch string) ([]PullRe return prs, nil } + +func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]PullRequest, error) { + type response struct { + Repository struct { + PullRequests struct { + Edges []struct { + Node PullRequest + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } + } + } + + query := ` + query( + $owner: String!, + $repo: String!, + $limit: Int!, + $endCursor: String, + $baseBranch: String, + $labels: [String!], + $state: [PullRequestState!] = OPEN + ) { + repository(owner: $owner, name: $repo) { + pullRequests( + states: $state, + baseRefName: $baseBranch, + labels: $labels, + first: $limit, + after: $endCursor, + orderBy: {field: CREATED_AT, direction: DESC} + ) { + edges { + node { + number + title + url + headRefName + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + }` + + prs := []PullRequest{} + pageLimit := min(limit, 100) + variables := map[string]interface{}{} + for name, val := range vars { + variables[name] = val + } + + for { + variables["limit"] = pageLimit + var data response + err := client.GraphQL(query, variables, &data) + if err != nil { + return nil, err + } + prData := data.Repository.PullRequests + + for _, edge := range prData.Edges { + prs = append(prs, edge.Node) + if len(prs) == limit { + goto done + } + } + + if prData.PageInfo.HasNextPage { + variables["endCursor"] = prData.PageInfo.EndCursor + pageLimit = min(pageLimit, limit-len(prs)) + continue + } + done: + break + } + + return prs, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/command/pr.go b/command/pr.go index f1dfe8fb0..4be283ee6 100644 --- a/command/pr.go +++ b/command/pr.go @@ -2,41 +2,49 @@ package command import ( "fmt" + "os" "strconv" "github.com/github/gh-cli/api" "github.com/github/gh-cli/utils" "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" ) func init() { RootCmd.AddCommand(prCmd) - prCmd.AddCommand( - &cobra.Command{ - Use: "list", - Short: "List pull requests", - RunE: prList, - }, - &cobra.Command{ - Use: "view [pr-number]", - Short: "Open a pull request in the browser", - RunE: prView, - }, - ) + prCmd.AddCommand(prListCmd) + prCmd.AddCommand(prStatusCmd) + prCmd.AddCommand(prViewCmd) + + prListCmd.Flags().IntP("limit", "L", 30, "maximum number of items to fetch") + prListCmd.Flags().StringP("state", "s", "open", "filter by state") + prListCmd.Flags().StringP("base", "b", "", "filter by base branch") + prListCmd.Flags().StringArrayP("label", "l", nil, "filter by label") } var prCmd = &cobra.Command{ Use: "pr", Short: "Work with pull requests", - Long: `This command allows you to -work with pull requests.`, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("%+v is not a valid PR command", args) - }, + Long: `Helps you work with pull requests.`, +} +var prListCmd = &cobra.Command{ + Use: "list", + Short: "List pull requests", + RunE: prList, +} +var prStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show status of relevant pull requests", + RunE: prStatus, +} +var prViewCmd = &cobra.Command{ + Use: "view [pr-number]", + Short: "Open a pull request in the browser", + RunE: prView, } -func prList(cmd *cobra.Command, args []string) error { +func prStatus(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) apiClient, err := apiClientForContext(ctx) if err != nil { @@ -89,6 +97,101 @@ func prList(cmd *cobra.Command, args []string) error { return nil } +func prList(cmd *cobra.Command, args []string) error { + ctx := contextForCommand(cmd) + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + + baseRepo, err := ctx.BaseRepo() + if err != nil { + return err + } + + limit, err := cmd.Flags().GetInt("limit") + if err != nil { + return err + } + state, err := cmd.Flags().GetString("state") + if err != nil { + return err + } + baseBranch, err := cmd.Flags().GetString("base") + if err != nil { + return err + } + labels, err := cmd.Flags().GetStringArray("label") + if err != nil { + return err + } + + var graphqlState string + switch state { + case "open": + graphqlState = "OPEN" + case "closed": + graphqlState = "CLOSED" + case "all": + graphqlState = "ALL" + default: + return fmt.Errorf("invalid state: %s", state) + } + + params := map[string]interface{}{ + "owner": baseRepo.RepoOwner(), + "repo": baseRepo.RepoName(), + "state": graphqlState, + } + if len(labels) > 0 { + params["labels"] = labels + } + if baseBranch != "" { + params["baseBranch"] = baseBranch + } + + prs, err := api.PullRequestList(apiClient, params, limit) + if err != nil { + return err + } + + tty := false + ttyWidth := 80 + out := cmd.OutOrStdout() + if outFile, isFile := out.(*os.File); isFile { + fd := int(outFile.Fd()) + tty = terminal.IsTerminal(fd) + if w, _, err := terminal.GetSize(fd); err == nil { + ttyWidth = w + } + } + + numWidth := 8 + branchWidth := 40 + titleWidth := ttyWidth - branchWidth - 2 - numWidth - 2 + maxTitleWidth := 0 + for _, pr := range prs { + if len(pr.Title) > maxTitleWidth { + maxTitleWidth = len(pr.Title) + } + } + if maxTitleWidth < titleWidth { + branchWidth += titleWidth - maxTitleWidth + titleWidth = maxTitleWidth + } + + for _, pr := range prs { + if tty { + prNum := utils.Yellow(fmt.Sprintf("% *s", numWidth, fmt.Sprintf("#%d", pr.Number))) + prBranch := utils.Cyan(truncate(branchWidth, pr.HeadRefName)) + fmt.Fprintf(out, "%s %-*s %s\n", prNum, titleWidth, truncate(titleWidth, pr.Title), prBranch) + } else { + fmt.Fprintf(out, "%d\t%s\t%s\n", pr.Number, pr.Title, pr.HeadRefName) + } + } + return nil +} + func prView(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) baseRepo, err := ctx.BaseRepo() @@ -129,7 +232,7 @@ func prView(cmd *cobra.Command, args []string) error { func printPrs(prs ...api.PullRequest) { for _, pr := range prs { - fmt.Printf(" #%d %s %s\n", pr.Number, truncateTitle(pr.Title), utils.Cyan("["+pr.HeadRefName+"]")) + fmt.Printf(" #%d %s %s\n", pr.Number, truncate(50, pr.Title), utils.Cyan("["+pr.HeadRefName+"]")) } } @@ -141,9 +244,7 @@ func printMessage(s string) { fmt.Println(utils.Gray(s)) } -func truncateTitle(title string) string { - const maxLength = 50 - +func truncate(maxLength int, title string) string { if len(title) > maxLength { return title[0:maxLength-3] + "..." } diff --git a/command/pr_test.go b/command/pr_test.go index e4a5122d7..57569c506 100644 --- a/command/pr_test.go +++ b/command/pr_test.go @@ -1,7 +1,11 @@ package command import ( + "bytes" + "encoding/json" + "io/ioutil" "os" + "reflect" "regexp" "testing" @@ -11,6 +15,13 @@ import ( "github.com/github/gh-cli/utils" ) +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + func initBlankContext(repo, branch string) { initContext = func() context.Context { ctx := context.NewBlank() @@ -28,17 +39,17 @@ func initFakeHTTP() *api.FakeHTTP { return http } -func TestPRList(t *testing.T) { +func TestPRStatus(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() - jsonFile, _ := os.Open("../test/fixtures/prList.json") + jsonFile, _ := os.Open("../test/fixtures/prStatus.json") defer jsonFile.Close() http.StubResponse(200, jsonFile) - output, err := test.RunCommand(RootCmd, "pr list") + output, err := test.RunCommand(RootCmd, "pr status") if err != nil { - t.Errorf("error running command `pr list`: %v", err) + t.Errorf("error running command `pr status`: %v", err) } expectedPrs := []*regexp.Regexp{ @@ -55,6 +66,57 @@ func TestPRList(t *testing.T) { } } +func TestPRList(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + jsonFile, _ := os.Open("../test/fixtures/prList.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + out := bytes.Buffer{} + prListCmd.SetOut(&out) + + RootCmd.SetArgs([]string{"pr", "list"}) + _, err := RootCmd.ExecuteC() + if err != nil { + t.Fatal(err) + } + + eq(t, out.String(), `32 New feature feature +29 Fixed bad bug bug-fix +28 Improve documentation docs +`) +} + +func TestPRList_filtering(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + respBody := bytes.NewBufferString(`{ "data": {} }`) + http.StubResponse(200, respBody) + + prListCmd.SetOut(ioutil.Discard) + + RootCmd.SetArgs([]string{"pr", "list", "-s", "all", "-l", "one", "-l", "two"}) + _, err := RootCmd.ExecuteC() + if err != nil { + t.Fatal(err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) + reqBody := struct { + Variables struct { + State string + Labels []string + } + }{} + json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.State, "ALL") + eq(t, reqBody.Variables.Labels, []string{"one", "two"}) +} + func TestPRView(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() diff --git a/go.mod b/go.mod index b80ee1d9a..07a356b32 100644 --- a/go.mod +++ b/go.mod @@ -9,5 +9,6 @@ require ( github.com/mattn/go-isatty v0.0.9 github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v0.0.5 + golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652 ) diff --git a/go.sum b/go.sum index 1a7c223fc..e5b196f09 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,7 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/test/fixtures/prList.json b/test/fixtures/prList.json index 444fa5e01..7ff9502a0 100644 --- a/test/fixtures/prList.json +++ b/test/fixtures/prList.json @@ -1,50 +1,38 @@ -{"data":{ - "repository": { - "pullRequests": { - "edges": [ - { - "node": { - "number": 10, - "title": "Blueberries are a good fruit", - "url": "https://github.com/github/gh-cli/pull/10", - "headRefName": "[blueberries]" +{ + "data": { + "repository": { + "pullRequests": { + "edges": [ + { + "node": { + "number": 32, + "title": "New feature", + "url": "https://github.com/monalisa/hello/pull/32", + "headRefName": "feature" + } + }, + { + "node": { + "number": 29, + "title": "Fixed bad bug", + "url": "https://github.com/monalisa/hello/pull/29", + "headRefName": "bug-fix" + } + }, + { + "node": { + "number": 28, + "title": "Improve documentation", + "url": "https://github.com/monalisa/hello/pull/28", + "headRefName": "docs" + } } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "" } - ] + } } - }, - "viewerCreated": { - "edges": [ - { - "node": { - "number": 8, - "title": "Strawberries are not actually berries", - "url": "https://github.com/github/gh-cli/pull/8", - "headRefName": "[strawberries]" - } - } - ], - "pageInfo": { "hasNextPage": false } - }, - "reviewRequested": { - "edges": [ - { - "node": { - "number": 9, - "title": "Apples are tasty", - "url": "https://github.com/github/gh-cli/pull/9", - "headRefName": "[apples]" - } - }, - { - "node": { - "number": 11, - "title": "Figs are my favorite", - "url": "https://github.com/github/gh-cli/pull/1", - "headRefName": "[figs]" - } - } - ], - "pageInfo": { "hasNextPage": false } } -}} \ No newline at end of file +} \ No newline at end of file diff --git a/test/fixtures/prStatus.json b/test/fixtures/prStatus.json new file mode 100644 index 000000000..444fa5e01 --- /dev/null +++ b/test/fixtures/prStatus.json @@ -0,0 +1,50 @@ +{"data":{ + "repository": { + "pullRequests": { + "edges": [ + { + "node": { + "number": 10, + "title": "Blueberries are a good fruit", + "url": "https://github.com/github/gh-cli/pull/10", + "headRefName": "[blueberries]" + } + } + ] + } + }, + "viewerCreated": { + "edges": [ + { + "node": { + "number": 8, + "title": "Strawberries are not actually berries", + "url": "https://github.com/github/gh-cli/pull/8", + "headRefName": "[strawberries]" + } + } + ], + "pageInfo": { "hasNextPage": false } + }, + "reviewRequested": { + "edges": [ + { + "node": { + "number": 9, + "title": "Apples are tasty", + "url": "https://github.com/github/gh-cli/pull/9", + "headRefName": "[apples]" + } + }, + { + "node": { + "number": 11, + "title": "Figs are my favorite", + "url": "https://github.com/github/gh-cli/pull/1", + "headRefName": "[figs]" + } + } + ], + "pageInfo": { "hasNextPage": false } + } +}} \ No newline at end of file