gh pr checks

A basic first pass on gh pr checks that shows all check runs for a given
PR's latest commit.
This commit is contained in:
vilmibm 2020-08-19 21:27:56 -05:00
parent bb65ca0635
commit 8dd93e1748
9 changed files with 645 additions and 5 deletions

View file

@ -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

211
pkg/cmd/pr/checks/checks.go Normal file
View file

@ -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()
}

View file

@ -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)
})
}
}

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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
}

View file

@ -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")
}