From 8dd93e1748438bf2b52ebd2e3950986db63568d9 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 19 Aug 2020 21:27:56 -0500 Subject: [PATCH] gh pr checks A basic first pass on gh pr checks that shows all check runs for a given PR's latest commit. --- api/queries_pr.go | 68 +++++- pkg/cmd/pr/checks/checks.go | 211 +++++++++++++++++++ pkg/cmd/pr/checks/checks_test.go | 200 ++++++++++++++++++ pkg/cmd/pr/checks/fixtures/allPassing.json | 41 ++++ pkg/cmd/pr/checks/fixtures/someFailing.json | 41 ++++ pkg/cmd/pr/checks/fixtures/somePending.json | 41 ++++ pkg/cmd/pr/checks/fixtures/withStatuses.json | 38 ++++ pkg/cmd/pr/pr.go | 2 + utils/utils.go | 8 + 9 files changed, 645 insertions(+), 5 deletions(-) create mode 100644 pkg/cmd/pr/checks/checks.go create mode 100644 pkg/cmd/pr/checks/checks_test.go create mode 100644 pkg/cmd/pr/checks/fixtures/allPassing.json create mode 100644 pkg/cmd/pr/checks/fixtures/someFailing.json create mode 100644 pkg/cmd/pr/checks/fixtures/somePending.json create mode 100644 pkg/cmd/pr/checks/fixtures/withStatuses.json diff --git a/api/queries_pr.go b/api/queries_pr.go index f2b398adf..6df1b91c9 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" @@ -72,12 +73,19 @@ type PullRequest struct { TotalCount int Nodes []struct { Commit struct { + Oid string StatusCheckRollup struct { Contexts struct { Nodes []struct { - State string - Status string - Conclusion string + Name string + Context string + State string + Status string + Conclusion string + StartedAt time.Time + CompletedAt time.Time + DetailsURL string + TargetURL string } } } @@ -272,9 +280,11 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu contexts(last: 100) { nodes { ...on StatusContext { + context state } ...on CheckRun { + name status conclusion } @@ -418,8 +428,32 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu author { login } - commits { + commits(last: 1) { totalCount + nodes { + commit { + oid + statusCheckRollup { + contexts(last: 100) { + nodes { + ...on StatusContext { + context + state + targetUrl + } + ...on CheckRun { + name + status + conclusion + startedAt + completedAt + detailsUrl + } + } + } + } + } + } } baseRefName headRefName @@ -524,8 +558,32 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea author { login } - commits { + commits(last: 1) { totalCount + nodes { + commit { + oid + statusCheckRollup { + contexts(last: 100) { + nodes { + ...on StatusContext { + context + state + targetUrl + } + ...on CheckRun { + name + status + conclusion + startedAt + completedAt + detailsUrl + } + } + } + } + } + } } url baseRefName diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go new file mode 100644 index 000000000..f42c3ec45 --- /dev/null +++ b/pkg/cmd/pr/checks/checks.go @@ -0,0 +1,211 @@ +package checks + +import ( + "errors" + "fmt" + "net/http" + "sort" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ChecksOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Branch func() (string, error) + Remotes func() (context.Remotes, error) + + SelectorArg string +} + +func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Command { + opts := &ChecksOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Branch: f.Branch, + Remotes: f.Remotes, + BaseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "checks", + Short: "Show CI status for a single pull request", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { + return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} + } + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + + return checksRun(opts) + }, + } + + return cmd +} + +func checksRun(opts *ChecksOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + if len(pr.Commits.Nodes) == 0 { + return nil + } + + rollup := pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes + if len(rollup) == 0 { + return nil + } + + passing := 0 + failing := 0 + pending := 0 + + type output struct { + mark string + bucket string + name string + elapsed string + link string + } + + outputs := []output{} + + for _, c := range pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes { + mark := "" + bucket := "" + state := c.State + if state == "" { + if c.Status == "COMPLETED" { + state = c.Conclusion + } else { + state = c.Status + } + } + switch state { + case "SUCCESS", "NEUTRAL", "SKIPPED": + mark = utils.GreenCheck() + passing++ + bucket = "pass" + case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": + mark = utils.RedX() + failing++ + bucket = "fail" + case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS", "STALE": + mark = utils.YellowDash() + pending++ + bucket = "pending" + default: + panic(fmt.Errorf("unsupported status: %q", state)) + } + + elapsed := "" + zeroTime := time.Time{} + + if c.StartedAt != zeroTime && c.CompletedAt != zeroTime { + e := c.CompletedAt.Sub(c.StartedAt) + if e > 0 { + elapsed = e.String() + } + } + + link := c.DetailsURL + if link == "" { + link = c.TargetURL + } + + name := c.Name + if name == "" { + name = c.Context + } + + outputs = append(outputs, output{mark, bucket, name, elapsed, link}) + } + + sort.Slice(outputs, func(i, j int) bool { + if outputs[i].bucket == outputs[j].bucket { + return outputs[i].name < outputs[j].name + } else { + if outputs[i].bucket == "fail" { + return true + } else if outputs[i].bucket == "pending" && outputs[j].bucket == "success" { + return true + } + } + + return false + }) + + tp := utils.NewTablePrinter(opts.IO) + + for _, o := range outputs { + if opts.IO.IsStdoutTTY() { + tp.AddField(o.mark, nil, nil) + tp.AddField(o.name, nil, nil) + tp.AddField(o.elapsed, nil, nil) + tp.AddField(o.link, nil, nil) + } else { + tp.AddField(o.name, nil, nil) + tp.AddField(o.bucket, nil, nil) + if o.elapsed == "" { + tp.AddField("0", nil, nil) + } else { + tp.AddField(o.elapsed, nil, nil) + } + tp.AddField(o.link, nil, nil) + } + + tp.EndRow() + } + + summary := "" + if failing+passing+pending > 0 { + if failing > 0 { + summary = "Some checks were not successful" + } else if pending > 0 { + summary = "Some checks are still pending" + } else { + summary = "All checks were successful" + } + + tallies := fmt.Sprintf( + "%d failing, %d successful, and %d pending checks", + failing, passing, pending) + + summary = fmt.Sprintf("%s\n%s", utils.Bold(summary), tallies) + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintln(opts.IO.Out, summary) + fmt.Fprintln(opts.IO.Out) + } + + return tp.Render() +} diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go new file mode 100644 index 000000000..152f5aea9 --- /dev/null +++ b/pkg/cmd/pr/checks/checks_test.go @@ -0,0 +1,200 @@ +package checks + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdChecks(t *testing.T) { + tests := []struct { + name string + cli string + wants ChecksOptions + }{ + { + name: "no arguments", + cli: "", + wants: ChecksOptions{}, + }, + { + name: "pr argument", + cli: "1234", + wants: ChecksOptions{ + SelectorArg: "1234", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ChecksOptions + cmd := NewCmdChecks(f, func(opts *ChecksOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.SelectorArg, gotOpts.SelectorArg) + }) + } +} + +func Test_checksRun(t *testing.T) { + tests := []struct { + name string + fixture string + stubs func(*httpmock.Registry) + wantOut string + nontty bool + }{ + { + name: "no commits", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.JSONResponse( + bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123 } + } } } + `))) + }, + }, + { + name: "no checks", + stubs: func(reg *httpmock.Registry) { + reg.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} } + } } } + `)) + }, + }, + { + name: "some failing", + fixture: "./fixtures/someFailing.json", + wantOut: "Some checks were not successful\n1 failing, 1 successful, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n- slow tests 1m26s sweet link\n", + }, + { + name: "some pending", + fixture: "./fixtures/somePending.json", + wantOut: "Some checks are still pending\n0 failing, 2 successful, and 1 pending checks\n\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n- slow tests 1m26s sweet link\n", + }, + { + name: "all passing", + fixture: "./fixtures/allPassing.json", + wantOut: "All checks were successful\n0 failing, 3 successful, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n", + }, + { + name: "with statuses", + fixture: "./fixtures/withStatuses.json", + wantOut: "Some checks were not successful\n1 failing, 2 successful, and 0 pending checks\n\nX a status sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n", + }, + { + name: "no commits", + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.JSONResponse( + bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123 } + } } } + `))) + }, + }, + { + name: "no checks", + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} } + } } } + `)) + }, + }, + { + name: "some failing", + nontty: true, + fixture: "./fixtures/someFailing.json", + wantOut: "sad tests\tfail\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n", + }, + { + name: "some pending", + nontty: true, + fixture: "./fixtures/somePending.json", + wantOut: "cool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n", + }, + { + name: "all passing", + nontty: true, + fixture: "./fixtures/allPassing.json", + wantOut: "awesome tests\tpass\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n", + }, + { + name: "with statuses", + nontty: true, + fixture: "./fixtures/withStatuses.json", + wantOut: "a status\tfail\t0\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + + opts := &ChecksOptions{ + IO: io, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + SelectorArg: "123", + } + + reg := &httpmock.Registry{} + if tt.stubs != nil { + tt.stubs(reg) + } else if tt.fixture != "" { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture)) + } else { + panic("need either stubs or fixture key") + } + + opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + err := checksRun(opts) + assert.NoError(t, err) + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/pr/checks/fixtures/allPassing.json b/pkg/cmd/pr/checks/fixtures/allPassing.json new file mode 100644 index 000000000..c75e3fc29 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/allPassing.json @@ -0,0 +1,41 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "rad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "awesome tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/someFailing.json b/pkg/cmd/pr/checks/fixtures/someFailing.json new file mode 100644 index 000000000..0e53cdb79 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/someFailing.json @@ -0,0 +1,41 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "FAILURE", + "status": "COMPLETED", + "name": "sad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "", + "status": "IN_PROGRESS", + "name": "slow tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/somePending.json b/pkg/cmd/pr/checks/fixtures/somePending.json new file mode 100644 index 000000000..6e36a5cd3 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/somePending.json @@ -0,0 +1,41 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "rad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "", + "status": "IN_PROGRESS", + "name": "slow tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/withStatuses.json b/pkg/cmd/pr/checks/fixtures/withStatuses.json new file mode 100644 index 000000000..0ce8b9c66 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/withStatuses.json @@ -0,0 +1,38 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "rad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "state": "FAILURE", + "name": "a status", + "targetUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go index a4b746656..e06eb8059 100644 --- a/pkg/cmd/pr/pr.go +++ b/pkg/cmd/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "github.com/MakeNowJust/heredoc" cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout" + cmdChecks "github.com/cli/cli/pkg/cmd/pr/checks" cmdClose "github.com/cli/cli/pkg/cmd/pr/close" cmdCreate "github.com/cli/cli/pkg/cmd/pr/create" cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff" @@ -51,6 +52,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdReview.NewCmdReview(f, nil)) cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdChecks.NewCmdChecks(f, nil)) return cmd } diff --git a/utils/utils.go b/utils/utils.go index cee56bf58..208b7a44e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -118,3 +118,11 @@ func DisplayURL(urlStr string) string { func GreenCheck() string { return Green("✓") } + +func YellowDash() string { + return Yellow("-") +} + +func RedX() string { + return Red("X") +}