Actions Support Phase 1 (#2923)

* Implement first round of support for GitHub Actions

This commit adds:

gh actions
gh run list
gh run view
gh job view

as part of our first round of actions support. These commands are
unlisted and considered in beta.

* review feedback

* tests for exit status on job view

* spinner tracks io itself

* review feedback

* fix PR matching

* enable pager for job log viewing

* add more colorf functions

* add AnnotationSymbol

* hide job, run

* do not add method to api.Client

* remove useless cargo coded copypasta
This commit is contained in:
Nate Smith 2021-03-16 13:59:34 -07:00 committed by GitHub
parent e2de02d6b0
commit 126b498e9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 2112 additions and 4 deletions

View file

@ -0,0 +1,76 @@
package actions
import (
"fmt"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type ActionsOptions struct {
IO *iostreams.IOStreams
}
func NewCmdActions(f *cmdutil.Factory) *cobra.Command {
opts := ActionsOptions{
IO: f.IOStreams,
}
cmd := &cobra.Command{
Use: "actions",
Short: "Learn about working with GitHub actions",
Args: cobra.ExactArgs(0),
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
actionsRun(opts)
},
}
return cmd
}
func actionsRun(opts ActionsOptions) {
cs := opts.IO.ColorScheme()
fmt.Fprint(opts.IO.Out, heredoc.Docf(`
Welcome to GitHub Actions on the command line.
This part of gh is in beta and subject to change!
To follow along while we get to GA, please see this
tracking issue: https://github.com/cli/cli/issues/2889
%s
gh run list: List recent workflow runs
gh run view: View details for a given workflow run
%s
gh job view: View details for a given job
`,
cs.Bold("Working with runs"),
cs.Bold("Working with jobs within runs")))
/*
fmt.Fprint(opts.IO.Out, heredoc.Docf(`
Welcome to GitHub Actions on the command line.
%s
gh workflow list: List workflows in the current repository
gh workflow run: Kick off a workflow run
gh workflow init: Create a new workflow
gh workflow check: Check a workflow file for correctness
%s
gh run list: List recent workflow runs
gh run view: View details for a given workflow run
gh run watch: Watch a streaming log for a workflow run
%s
gh job view: View details for a given job
gh job run: Run a given job within a workflow
`,
cs.Bold("Working with workflows"),
cs.Bold("Working with runs"),
cs.Bold("Working with jobs within runs")))
*/
}

22
pkg/cmd/job/job.go Normal file
View file

@ -0,0 +1,22 @@
package job
import (
viewCmd "github.com/cli/cli/pkg/cmd/job/view"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdJob(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "job <command>",
Short: "Interact with the individual jobs of a workflow run",
Hidden: true,
Long: "List and view the jobs of a workflow run including full logs",
// TODO action annotation
}
cmdutil.EnableRepoOverride(cmd, f)
cmd.AddCommand(viewCmd.NewCmdView(f, nil))
return cmd
}

47
pkg/cmd/job/view/http.go Normal file
View file

@ -0,0 +1,47 @@
package view
import (
"errors"
"fmt"
"io"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/run/shared"
)
func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID string) (io.ReadCloser, error) {
url := fmt.Sprintf("%srepos/%s/actions/jobs/%s/logs",
ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), jobID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == 404 {
return nil, errors.New("job not found")
} else if resp.StatusCode != 200 {
return nil, api.HandleHTTPError(resp)
}
return resp.Body, nil
}
func getJob(client *api.Client, repo ghrepo.Interface, jobID string) (*shared.Job, error) {
path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID)
var result shared.Job
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
return nil, err
}
return &result, nil
}

237
pkg/cmd/job/view/view.go Normal file
View file

@ -0,0 +1,237 @@
package view
import (
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/run/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ViewOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
JobID string
Log bool
ExitStatus bool
Prompt bool
Now func() time.Time
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Now: time.Now,
}
cmd := &cobra.Command{
Use: "view [<job-id>]",
Short: "View the summary or full logs of a workflow run's job",
Args: cobra.MaximumNArgs(1),
Hidden: true,
Example: heredoc.Doc(`
# Interactively select a run then job
$ gh job view
# Just view the logs for a job
$ gh job view 0451 --log
# Exit non-zero if a job failed
$ gh job view 0451 -e && echo "job pending or passed"
`),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.JobID = args[0]
} else if !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("job ID required when not running interactively")}
} else {
opts.Prompt = true
}
if runF != nil {
return runF(opts)
}
return runView(opts)
},
}
cmd.Flags().BoolVarP(&opts.Log, "log", "l", false, "Print full logs for job")
// TODO should we try and expose pending via another exit code?
cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if job failed")
return cmd
}
func runView(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("failed to create http client: %w", err)
}
client := api.NewClientFromHTTP(httpClient)
repo, err := opts.BaseRepo()
if err != nil {
return fmt.Errorf("failed to determine base repo: %w", err)
}
out := opts.IO.Out
cs := opts.IO.ColorScheme()
jobID := opts.JobID
if opts.Prompt {
runID, err := shared.PromptForRun(cs, client, repo)
if err != nil {
return err
}
// TODO I'd love to overwrite the result of the prompt since it adds visual noise but I'm not sure
// the cleanest way to do that.
fmt.Fprintln(out)
opts.IO.StartProgressIndicator()
defer opts.IO.StopProgressIndicator()
run, err := shared.GetRun(client, repo, runID)
if err != nil {
return fmt.Errorf("failed to get run: %w", err)
}
opts.IO.StopProgressIndicator()
jobID, err = promptForJob(*opts, client, repo, *run)
if err != nil {
return err
}
fmt.Fprintln(out)
}
opts.IO.StartProgressIndicator()
job, err := getJob(client, repo, jobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
if opts.Log {
r, err := jobLog(httpClient, repo, jobID)
if err != nil {
return err
}
opts.IO.StopProgressIndicator()
err = opts.IO.StartPager()
if err != nil {
return err
}
defer opts.IO.StopPager()
if _, err := io.Copy(opts.IO.Out, r); err != nil {
return fmt.Errorf("failed to read log: %w", err)
}
if opts.ExitStatus && shared.IsFailureState(job.Conclusion) {
return cmdutil.SilentError
}
return nil
}
annotations, err := shared.GetAnnotations(client, repo, *job)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to get annotations: %w", err)
}
elapsed := job.CompletedAt.Sub(job.StartedAt)
elapsedStr := fmt.Sprintf(" in %s", elapsed)
if elapsed < 0 {
elapsedStr = ""
}
symbol, symColor := shared.Symbol(cs, job.Status, job.Conclusion)
fmt.Fprintf(out, "%s (ID %s)\n", cs.Bold(job.Name), cs.Cyanf("%d", job.ID))
fmt.Fprintf(out, "%s %s ago%s\n",
symColor(symbol),
utils.FuzzyAgoAbbr(opts.Now(), job.StartedAt),
elapsedStr)
fmt.Fprintln(out)
for _, step := range job.Steps {
stepSym, stepSymColor := shared.Symbol(cs, step.Status, step.Conclusion)
fmt.Fprintf(out, "%s %s\n",
stepSymColor(stepSym),
step.Name)
}
if len(annotations) > 0 {
fmt.Fprintln(out)
fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
for _, a := range annotations {
fmt.Fprintf(out, "%s %s\n", shared.AnnotationSymbol(cs, a), a.Message)
fmt.Fprintln(out, cs.Grayf("%s#%d\n", a.Path, a.StartLine))
}
}
fmt.Fprintln(out)
fmt.Fprintf(out, "To see the full logs for this job, try: gh job view %s --log\n", jobID)
fmt.Fprintf(out, cs.Gray("View this job on GitHub: %s\n"), job.URL)
if opts.ExitStatus && shared.IsFailureState(job.Conclusion) {
return cmdutil.SilentError
}
return nil
}
func promptForJob(opts ViewOptions, client *api.Client, repo ghrepo.Interface, run shared.Run) (string, error) {
cs := opts.IO.ColorScheme()
jobs, err := shared.GetJobs(client, repo, run)
if err != nil {
return "", err
}
if len(jobs) == 1 {
return fmt.Sprintf("%d", jobs[0].ID), nil
}
var selected int
candidates := []string{}
for _, job := range jobs {
symbol, symColor := shared.Symbol(cs, job.Status, job.Conclusion)
candidates = append(candidates, fmt.Sprintf("%s %s", symColor(symbol), job.Name))
}
// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
// become contiguous
err = prompt.SurveyAskOne(&survey.Select{
Message: "Select a job to view",
Options: candidates,
PageSize: 10,
}, &selected)
if err != nil {
return "", err
}
return fmt.Sprintf("%d", jobs[selected].ID), nil
}

View file

@ -0,0 +1,351 @@
package view
import (
"bytes"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/run/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdView(t *testing.T) {
tests := []struct {
name string
cli string
wants ViewOptions
wantsErr bool
tty bool
}{
{
name: "blank tty",
tty: true,
wants: ViewOptions{
Prompt: true,
},
},
{
name: "blank nontty",
wantsErr: true,
},
{
name: "nontty jobID",
cli: "1234",
wants: ViewOptions{
JobID: "1234",
},
},
{
name: "log tty",
tty: true,
cli: "--log",
wants: ViewOptions{
Prompt: true,
Log: true,
},
},
{
name: "log nontty",
cli: "--log 1234",
wants: ViewOptions{
JobID: "1234",
Log: true,
},
},
{
name: "exit status",
cli: "--exit-status 1234",
wants: ViewOptions{
JobID: "1234",
ExitStatus: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ViewOptions
cmd := NewCmdView(f, func(opts *ViewOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.JobID, gotOpts.JobID)
assert.Equal(t, tt.wants.Log, gotOpts.Log)
assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt)
assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus)
})
}
}
func TestRunView(t *testing.T) {
tests := []struct {
name string
opts *ViewOptions
httpStubs func(*httpmock.Registry)
askStubs func(*prompt.AskStubber)
tty bool
wantErr bool
wantOut string
}{
{
name: "exit status respected with --log",
opts: &ViewOptions{
JobID: "20",
ExitStatus: true,
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"),
httpmock.JSONResponse(shared.FailedJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"),
httpmock.StringResponse("it's a log\nfor this job\nbeautiful log\n"))
},
wantErr: true,
wantOut: "it's a log\nfor this job\nbeautiful log\n",
},
{
name: "exit status respected",
opts: &ViewOptions{
JobID: "20",
ExitStatus: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"),
httpmock.JSONResponse(shared.FailedJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
httpmock.JSONResponse(shared.FailedJobAnnotations))
},
wantErr: true,
wantOut: "sad job (ID 20)\nX 59m ago in 4m34s\n\n✓ barf the quux\nX quux the barf\n\nANNOTATIONS\nX the job is sad\nblaze.py#420\n\n\nTo see the full logs for this job, try: gh job view 20 --log\nView this job on GitHub: jobs/20\n",
},
{
name: "interactive flow, multi-job",
tty: true,
opts: &ViewOptions{
Prompt: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJob,
shared.FailedJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
httpmock.JSONResponse(shared.SuccessfulJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
httpmock.JSONResponse([]shared.Annotation{}))
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(2)
as.StubOne(0)
},
wantOut: "\n\ncool job (ID 10)\n✓ 59m ago in 4m34s\n\n✓ fob the barz\n✓ barz the fob\n\nTo see the full logs for this job, try: gh job view 10 --log\nView this job on GitHub: jobs/10\n",
},
{
name: "interactive, run has only one job",
tty: true,
opts: &ViewOptions{
Prompt: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
httpmock.JSONResponse(shared.SuccessfulJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
httpmock.JSONResponse([]shared.Annotation{}))
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(2)
},
wantOut: "\n\ncool job (ID 10)\n✓ 59m ago in 4m34s\n\n✓ fob the barz\n✓ barz the fob\n\nTo see the full logs for this job, try: gh job view 10 --log\nView this job on GitHub: jobs/10\n",
},
{
name: "interactive with log",
tty: true,
opts: &ViewOptions{
Prompt: true,
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
httpmock.JSONResponse(shared.SuccessfulJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"),
httpmock.StringResponse("it's a log\nfor this job\nbeautiful log\n"))
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(2)
},
wantOut: "\n\nit's a log\nfor this job\nbeautiful log\n",
},
{
name: "noninteractive with log",
opts: &ViewOptions{
JobID: "10",
Prompt: false,
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
httpmock.JSONResponse(shared.SuccessfulJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"),
httpmock.StringResponse("it's a log\nfor this job\nbeautiful log\n"))
},
wantOut: "it's a log\nfor this job\nbeautiful log\n",
},
{
name: "noninteractive",
opts: &ViewOptions{
JobID: "10",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
httpmock.JSONResponse(shared.SuccessfulJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
httpmock.JSONResponse([]shared.Annotation{}))
},
wantOut: "cool job (ID 10)\n✓ 59m ago in 4m34s\n\n✓ fob the barz\n✓ barz the fob\n\nTo see the full logs for this job, try: gh job view 10 --log\nView this job on GitHub: jobs/10\n",
},
{
name: "shows annotations for failed job",
opts: &ViewOptions{
JobID: "20",
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"),
httpmock.JSONResponse(shared.FailedJob))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
httpmock.JSONResponse(shared.FailedJobAnnotations))
},
wantOut: "sad job (ID 20)\nX 59m ago in 4m34s\n\n✓ barf the quux\nX quux the barf\n\nANNOTATIONS\nX the job is sad\nblaze.py#420\n\n\nTo see the full logs for this job, try: gh job view 20 --log\nView this job on GitHub: jobs/20\n",
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
tt.httpStubs(reg)
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
tt.opts.Now = func() time.Time {
notnow, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 05:50:00")
return notnow
}
io, _, stdout, _ := iostreams.Test()
io.SetStdoutTTY(tt.tty)
tt.opts.IO = io
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
as, teardown := prompt.InitAskStubber()
defer teardown()
if tt.askStubs != nil {
tt.askStubs(as)
}
t.Run(tt.name, func(t *testing.T) {
err := runView(tt.opts)
if tt.wantErr {
assert.Error(t, err)
if !tt.opts.ExitStatus {
return
}
}
if !tt.opts.ExitStatus {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantOut, stdout.String())
reg.Verify(t)
})
}
}

View file

@ -99,9 +99,9 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin
fmt.Fprint(&b, formatCommentStatus(cs, comment.Status()))
}
if comment.Association() != "NONE" {
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.Title(strings.ToLower(comment.Association())))))
fmt.Fprint(&b, cs.Boldf(" (%s)", strings.Title(strings.ToLower(comment.Association()))))
}
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created()))))
fmt.Fprint(&b, cs.Boldf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created())))
if comment.IsEdited() {
fmt.Fprint(&b, cs.Bold(" • Edited"))
}

View file

@ -205,7 +205,7 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
if checks.Failing == checks.Total {
summary = cs.Red("× All checks failing")
} else {
summary = cs.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total))
summary = cs.Redf("× %d/%d checks failing", checks.Failing, checks.Total)
}
} else if checks.Pending > 0 {
summary = cs.Yellow("- Checks pending")

View file

@ -7,6 +7,7 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
actionsCmd "github.com/cli/cli/pkg/cmd/actions"
aliasCmd "github.com/cli/cli/pkg/cmd/alias"
apiCmd "github.com/cli/cli/pkg/cmd/api"
authCmd "github.com/cli/cli/pkg/cmd/auth"
@ -15,10 +16,12 @@ import (
"github.com/cli/cli/pkg/cmd/factory"
gistCmd "github.com/cli/cli/pkg/cmd/gist"
issueCmd "github.com/cli/cli/pkg/cmd/issue"
jobCmd "github.com/cli/cli/pkg/cmd/job"
prCmd "github.com/cli/cli/pkg/cmd/pr"
releaseCmd "github.com/cli/cli/pkg/cmd/release"
repoCmd "github.com/cli/cli/pkg/cmd/repo"
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
runCmd "github.com/cli/cli/pkg/cmd/run"
secretCmd "github.com/cli/cli/pkg/cmd/secret"
sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key"
versionCmd "github.com/cli/cli/pkg/cmd/version"
@ -79,6 +82,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(secretCmd.NewCmdSecret(f))
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
cmd.AddCommand(actionsCmd.NewCmdActions(f))
cmd.AddCommand(runCmd.NewCmdRun(f))
cmd.AddCommand(jobCmd.NewCmdJob(f))
// the `api` command should not inherit any extra HTTP headers
bareHTTPCmdFactory := *f
bareHTTPCmdFactory.HttpClient = bareHTTPClient(f, version)

136
pkg/cmd/run/list/list.go Normal file
View file

@ -0,0 +1,136 @@
package list
import (
"fmt"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/run/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
const (
defaultLimit = 10
)
type ListOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
PlainOutput bool
Limit int
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "list",
Short: "List recent workflow runs",
Args: cobra.NoArgs,
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
terminal := opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY()
opts.PlainOutput = !terminal
if opts.Limit < 1 {
return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.Limit)}
}
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of runs to fetch")
return cmd
}
func listRun(opts *ListOptions) error {
baseRepo, err := opts.BaseRepo()
if err != nil {
return fmt.Errorf("failed to determine base repo: %w", err)
}
c, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("failed to create http client: %w", err)
}
client := api.NewClientFromHTTP(c)
opts.IO.StartProgressIndicator()
runs, err := shared.GetRuns(client, baseRepo, opts.Limit)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to get runs: %w", err)
}
tp := utils.NewTablePrinter(opts.IO)
cs := opts.IO.ColorScheme()
if len(runs) == 0 {
if !opts.PlainOutput {
fmt.Fprintln(opts.IO.ErrOut, "No runs found")
}
return nil
}
out := opts.IO.Out
for _, run := range runs {
if opts.PlainOutput {
tp.AddField(string(run.Status), nil, nil)
tp.AddField(string(run.Conclusion), nil, nil)
} else {
symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion)
tp.AddField(symbol, nil, symbolColor)
}
tp.AddField(run.CommitMsg(), nil, cs.Bold)
tp.AddField(run.Name, nil, nil)
tp.AddField(run.HeadBranch, nil, cs.Bold)
tp.AddField(string(run.Event), nil, nil)
if opts.PlainOutput {
elapsed := run.UpdatedAt.Sub(run.CreatedAt)
if elapsed < 0 {
elapsed = 0
}
tp.AddField(elapsed.String(), nil, nil)
}
tp.AddField(fmt.Sprintf("%d", run.ID), nil, cs.Cyan)
tp.EndRow()
}
err = tp.Render()
if err != nil {
return err
}
if !opts.PlainOutput {
fmt.Fprintln(out)
fmt.Fprintln(out, "For details on a run, try: gh run view <run-id>")
}
return nil
}

View file

@ -0,0 +1,202 @@
package list
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/run/shared"
"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 TestNewCmdList(t *testing.T) {
tests := []struct {
name string
cli string
tty bool
wants ListOptions
wantsErr bool
}{
{
name: "blank",
wants: ListOptions{
Limit: defaultLimit,
},
},
{
name: "limit",
cli: "--limit 100",
wants: ListOptions{
Limit: 100,
},
},
{
name: "bad limit",
cli: "--limit hi",
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ListOptions
cmd := NewCmdList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.Equal(t, tt.wants.Limit, gotOpts.Limit)
})
}
}
func TestListRun(t *testing.T) {
tests := []struct {
name string
opts *ListOptions
wantOut string
wantErrOut string
stubs func(*httpmock.Registry)
nontty bool
}{
{
name: "blank tty",
opts: &ListOptions{
Limit: defaultLimit,
},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
},
wantOut: "X cool commit timed out trunk push 1\n- cool commit in progress trunk push 2\n✓ cool commit successful trunk push 3\n✓ cool commit cancelled trunk push 4\nX cool commit failed trunk push 1234\n✓ cool commit neutral trunk push 6\n✓ cool commit skipped trunk push 7\n- cool commit requested trunk push 8\n- cool commit queued trunk push 9\nX cool commit stale trunk push 10\n\nFor details on a run, try: gh run view <run-id>\n",
},
{
name: "blank nontty",
opts: &ListOptions{
Limit: defaultLimit,
PlainOutput: true,
},
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
},
wantOut: "completed\ttimed_out\tcool commit\ttimed out\ttrunk\tpush\t4m34s\t1\nin_progress\t\tcool commit\tin progress\ttrunk\tpush\t4m34s\t2\ncompleted\tsuccess\tcool commit\tsuccessful\ttrunk\tpush\t4m34s\t3\ncompleted\tcancelled\tcool commit\tcancelled\ttrunk\tpush\t4m34s\t4\ncompleted\tfailure\tcool commit\tfailed\ttrunk\tpush\t4m34s\t1234\ncompleted\tneutral\tcool commit\tneutral\ttrunk\tpush\t4m34s\t6\ncompleted\tskipped\tcool commit\tskipped\ttrunk\tpush\t4m34s\t7\nrequested\t\tcool commit\trequested\ttrunk\tpush\t4m34s\t8\nqueued\t\tcool commit\tqueued\ttrunk\tpush\t4m34s\t9\ncompleted\tstale\tcool commit\tstale\ttrunk\tpush\t4m34s\t10\n",
},
{
name: "pagination",
opts: &ListOptions{
Limit: 101,
},
stubs: func(reg *httpmock.Registry) {
runID := 0
runs := []shared.Run{}
for runID < 103 {
runs = append(runs, shared.TestRun(fmt.Sprintf("%d", runID), runID, shared.InProgress, ""))
runID++
}
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: runs[0:100],
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: runs[100:],
}))
},
wantOut: longRunOutput,
},
{
name: "no results nontty",
opts: &ListOptions{
Limit: defaultLimit,
PlainOutput: true,
},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{}),
)
},
nontty: true,
wantOut: "",
},
{
name: "no results tty",
opts: &ListOptions{
Limit: defaultLimit,
},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{}),
)
},
wantOut: "",
wantErrOut: "No runs found\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
tt.stubs(reg)
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(!tt.nontty)
tt.opts.IO = io
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
err := listRun(tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.wantOut, stdout.String())
assert.Equal(t, tt.wantErrOut, stderr.String())
reg.Verify(t)
})
}
}
const longRunOutput = "- cool commit 0 trunk push 0\n- cool commit 1 trunk push 1\n- cool commit 2 trunk push 2\n- cool commit 3 trunk push 3\n- cool commit 4 trunk push 4\n- cool commit 5 trunk push 5\n- cool commit 6 trunk push 6\n- cool commit 7 trunk push 7\n- cool commit 8 trunk push 8\n- cool commit 9 trunk push 9\n- cool commit 10 trunk push 10\n- cool commit 11 trunk push 11\n- cool commit 12 trunk push 12\n- cool commit 13 trunk push 13\n- cool commit 14 trunk push 14\n- cool commit 15 trunk push 15\n- cool commit 16 trunk push 16\n- cool commit 17 trunk push 17\n- cool commit 18 trunk push 18\n- cool commit 19 trunk push 19\n- cool commit 20 trunk push 20\n- cool commit 21 trunk push 21\n- cool commit 22 trunk push 22\n- cool commit 23 trunk push 23\n- cool commit 24 trunk push 24\n- cool commit 25 trunk push 25\n- cool commit 26 trunk push 26\n- cool commit 27 trunk push 27\n- cool commit 28 trunk push 28\n- cool commit 29 trunk push 29\n- cool commit 30 trunk push 30\n- cool commit 31 trunk push 31\n- cool commit 32 trunk push 32\n- cool commit 33 trunk push 33\n- cool commit 34 trunk push 34\n- cool commit 35 trunk push 35\n- cool commit 36 trunk push 36\n- cool commit 37 trunk push 37\n- cool commit 38 trunk push 38\n- cool commit 39 trunk push 39\n- cool commit 40 trunk push 40\n- cool commit 41 trunk push 41\n- cool commit 42 trunk push 42\n- cool commit 43 trunk push 43\n- cool commit 44 trunk push 44\n- cool commit 45 trunk push 45\n- cool commit 46 trunk push 46\n- cool commit 47 trunk push 47\n- cool commit 48 trunk push 48\n- cool commit 49 trunk push 49\n- cool commit 50 trunk push 50\n- cool commit 51 trunk push 51\n- cool commit 52 trunk push 52\n- cool commit 53 trunk push 53\n- cool commit 54 trunk push 54\n- cool commit 55 trunk push 55\n- cool commit 56 trunk push 56\n- cool commit 57 trunk push 57\n- cool commit 58 trunk push 58\n- cool commit 59 trunk push 59\n- cool commit 60 trunk push 60\n- cool commit 61 trunk push 61\n- cool commit 62 trunk push 62\n- cool commit 63 trunk push 63\n- cool commit 64 trunk push 64\n- cool commit 65 trunk push 65\n- cool commit 66 trunk push 66\n- cool commit 67 trunk push 67\n- cool commit 68 trunk push 68\n- cool commit 69 trunk push 69\n- cool commit 70 trunk push 70\n- cool commit 71 trunk push 71\n- cool commit 72 trunk push 72\n- cool commit 73 trunk push 73\n- cool commit 74 trunk push 74\n- cool commit 75 trunk push 75\n- cool commit 76 trunk push 76\n- cool commit 77 trunk push 77\n- cool commit 78 trunk push 78\n- cool commit 79 trunk push 79\n- cool commit 80 trunk push 80\n- cool commit 81 trunk push 81\n- cool commit 82 trunk push 82\n- cool commit 83 trunk push 83\n- cool commit 84 trunk push 84\n- cool commit 85 trunk push 85\n- cool commit 86 trunk push 86\n- cool commit 87 trunk push 87\n- cool commit 88 trunk push 88\n- cool commit 89 trunk push 89\n- cool commit 90 trunk push 90\n- cool commit 91 trunk push 91\n- cool commit 92 trunk push 92\n- cool commit 93 trunk push 93\n- cool commit 94 trunk push 94\n- cool commit 95 trunk push 95\n- cool commit 96 trunk push 96\n- cool commit 97 trunk push 97\n- cool commit 98 trunk push 98\n- cool commit 99 trunk push 99\n- cool commit 100 trunk push 100\n\nFor details on a run, try: gh run view <run-id>\n"

25
pkg/cmd/run/run.go Normal file
View file

@ -0,0 +1,25 @@
package run
import (
cmdList "github.com/cli/cli/pkg/cmd/run/list"
cmdView "github.com/cli/cli/pkg/cmd/run/view"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdRun(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "run <command>",
Short: "View details about workflow runs",
Hidden: true,
Long: "List, view, and watch recent workflow runs from GitHub Actions.",
// TODO i'd like to have all the actions commands sorted into their own zone which i think will
// require a new annotation
}
cmdutil.EnableRepoOverride(cmd, f)
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
return cmd
}

View file

@ -0,0 +1,272 @@
package shared
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
)
const (
// Run statuses
Queued Status = "queued"
Completed Status = "completed"
InProgress Status = "in_progress"
Requested Status = "requested"
Waiting Status = "waiting"
// Run conclusions
ActionRequired Conclusion = "action_required"
Cancelled Conclusion = "cancelled"
Failure Conclusion = "failure"
Neutral Conclusion = "neutral"
Skipped Conclusion = "skipped"
Stale Conclusion = "stale"
StartupFailure Conclusion = "startup_failure"
Success Conclusion = "success"
TimedOut Conclusion = "timed_out"
AnnotationFailure Level = "failure"
AnnotationWarning Level = "warning"
)
type Status string
type Conclusion string
type Level string
type Run struct {
Name string
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Status Status
Conclusion Conclusion
Event string
ID int
HeadBranch string `json:"head_branch"`
JobsURL string `json:"jobs_url"`
HeadCommit Commit `json:"head_commit"`
HeadSha string `json:"head_sha"`
URL string `json:"html_url"`
HeadRepository Repo `json:"head_repository"`
}
type Repo struct {
Owner struct {
Login string
}
Name string
}
type Commit struct {
Message string
}
func (r Run) CommitMsg() string {
commitLines := strings.Split(r.HeadCommit.Message, "\n")
if len(commitLines) > 0 {
return commitLines[0]
} else {
return r.HeadSha[0:8]
}
}
type Job struct {
ID int
Status Status
Conclusion Conclusion
Name string
Steps []Step
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
URL string `json:"html_url"`
}
type Step struct {
Name string
Status Status
Conclusion Conclusion
Number int
}
type Annotation struct {
JobName string
Message string
Path string
Level Level `json:"annotation_level"`
StartLine int `json:"start_line"`
}
func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string {
switch a.Level {
case AnnotationFailure:
return cs.FailureIcon()
case AnnotationWarning:
return cs.WarningIcon()
default:
return "-"
}
}
type CheckRun struct {
ID int
}
func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) {
var result []*Annotation
path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID)
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
return nil, err
}
out := []Annotation{}
for _, annotation := range result {
annotation.JobName = job.Name
out = append(out, *annotation)
}
return out, nil
}
func IsFailureState(c Conclusion) bool {
switch c {
case ActionRequired, Failure, StartupFailure, TimedOut:
return true
default:
return false
}
}
type RunsPayload struct {
WorkflowRuns []Run `json:"workflow_runs"`
}
func GetRuns(client *api.Client, repo ghrepo.Interface, limit int) ([]Run, error) {
perPage := limit
page := 1
if limit > 100 {
perPage = 100
}
runs := []Run{}
for len(runs) < limit {
var result RunsPayload
path := fmt.Sprintf("repos/%s/actions/runs?per_page=%d&page=%d", ghrepo.FullName(repo), perPage, page)
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
return nil, err
}
if len(result.WorkflowRuns) == 0 {
break
}
for _, run := range result.WorkflowRuns {
runs = append(runs, run)
if len(runs) == limit {
break
}
}
if len(result.WorkflowRuns) < perPage {
break
}
page++
}
return runs, nil
}
type JobsPayload struct {
Jobs []Job
}
func GetJobs(client *api.Client, repo ghrepo.Interface, run Run) ([]Job, error) {
var result JobsPayload
parsed, err := url.Parse(run.JobsURL)
if err != nil {
return nil, err
}
err = client.REST(repo.RepoHost(), "GET", parsed.Path[1:], nil, &result)
if err != nil {
return nil, err
}
return result.Jobs, nil
}
func PromptForRun(cs *iostreams.ColorScheme, client *api.Client, repo ghrepo.Interface) (string, error) {
// TODO arbitrary limit
runs, err := GetRuns(client, repo, 10)
if err != nil {
return "", err
}
var selected int
candidates := []string{}
for _, run := range runs {
symbol, _ := Symbol(cs, run.Status, run.Conclusion)
candidates = append(candidates,
fmt.Sprintf("%s %s, %s (%s)", symbol, run.CommitMsg(), run.Name, run.HeadBranch))
}
// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
// become contiguous
err = prompt.SurveyAskOne(&survey.Select{
Message: "Select a workflow run",
Options: candidates,
PageSize: 10,
}, &selected)
if err != nil {
return "", err
}
return fmt.Sprintf("%d", runs[selected].ID), nil
}
func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) {
var result Run
path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID)
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
return nil, err
}
return &result, nil
}
type colorFunc func(string) string
func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) {
noColor := func(s string) string { return s }
if status == Completed {
switch conclusion {
case Success:
return cs.SuccessIconWithColor(noColor), cs.Green
case Skipped, Cancelled, Neutral:
return cs.SuccessIconWithColor(noColor), cs.Gray
default:
return cs.FailureIconWithColor(noColor), cs.Red
}
}
return "-", cs.Yellow
}

114
pkg/cmd/run/shared/test.go Normal file
View file

@ -0,0 +1,114 @@
package shared
import (
"fmt"
"time"
)
// Test data for use in the various run and job tests
func created() time.Time {
created, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 04:51:00")
return created
}
func updated() time.Time {
updated, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 04:55:34")
return updated
}
func TestRun(name string, id int, s Status, c Conclusion) Run {
return Run{
Name: name,
ID: id,
CreatedAt: created(),
UpdatedAt: updated(),
Status: s,
Conclusion: c,
Event: "push",
HeadBranch: "trunk",
JobsURL: fmt.Sprintf("/runs/%d/jobs", id),
HeadCommit: Commit{
Message: "cool commit",
},
HeadSha: "1234567890",
URL: fmt.Sprintf("runs/%d", id),
HeadRepository: Repo{
Owner: struct{ Login string }{Login: "OWNER"},
Name: "REPO",
},
}
}
var SuccessfulRun Run = TestRun("successful", 3, Completed, Success)
var FailedRun Run = TestRun("failed", 1234, Completed, Failure)
var TestRuns []Run = []Run{
TestRun("timed out", 1, Completed, TimedOut),
TestRun("in progress", 2, InProgress, ""),
SuccessfulRun,
TestRun("cancelled", 4, Completed, Cancelled),
FailedRun,
TestRun("neutral", 6, Completed, Neutral),
TestRun("skipped", 7, Completed, Skipped),
TestRun("requested", 8, Requested, ""),
TestRun("queued", 9, Queued, ""),
TestRun("stale", 10, Completed, Stale),
}
var SuccessfulJob Job = Job{
ID: 10,
Status: Completed,
Conclusion: Success,
Name: "cool job",
StartedAt: created(),
CompletedAt: updated(),
URL: "jobs/10",
Steps: []Step{
{
Name: "fob the barz",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "barz the fob",
Status: Completed,
Conclusion: Success,
Number: 2,
},
},
}
var FailedJob Job = Job{
ID: 20,
Status: Completed,
Conclusion: Failure,
Name: "sad job",
StartedAt: created(),
CompletedAt: updated(),
URL: "jobs/20",
Steps: []Step{
{
Name: "barf the quux",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "quux the barf",
Status: Completed,
Conclusion: Failure,
Number: 2,
},
},
}
var FailedJobAnnotations []Annotation = []Annotation{
{
JobName: "sad job",
Message: "the job is sad",
Path: "blaze.py",
Level: "failure",
StartLine: 420,
},
}

269
pkg/cmd/run/view/view.go Normal file
View file

@ -0,0 +1,269 @@
package view
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/run/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ViewOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
RunID string
Verbose bool
ExitStatus bool
Prompt bool
Now func() time.Time
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Now: time.Now,
}
cmd := &cobra.Command{
Use: "view [<run-id>]",
Short: "View a summary of a workflow run",
Args: cobra.MaximumNArgs(1),
Hidden: true,
Example: heredoc.Doc(`
# Interactively select a run to view
$ gh run view
# View a specific run
$ gh run view 0451
# Exit non-zero if a run failed
$ gh run view 0451 -e && echo "job pending or passed"
`),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.RunID = args[0]
} else if !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("run ID required when not running interactively")}
} else {
opts.Prompt = true
}
if runF != nil {
return runF(opts)
}
return runView(opts)
},
}
cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show job steps")
// TODO should we try and expose pending via another exit code?
cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run failed")
return cmd
}
func runView(opts *ViewOptions) error {
c, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("failed to create http client: %w", err)
}
client := api.NewClientFromHTTP(c)
repo, err := opts.BaseRepo()
if err != nil {
return fmt.Errorf("failed to determine base repo: %w", err)
}
runID := opts.RunID
if opts.Prompt {
cs := opts.IO.ColorScheme()
runID, err = shared.PromptForRun(cs, client, repo)
if err != nil {
return err
}
}
opts.IO.StartProgressIndicator()
defer opts.IO.StopProgressIndicator()
run, err := shared.GetRun(client, repo, runID)
if err != nil {
return fmt.Errorf("failed to get run: %w", err)
}
prNumber := ""
number, err := prForRun(client, repo, *run)
if err == nil {
prNumber = fmt.Sprintf(" #%d", number)
}
jobs, err := shared.GetJobs(client, repo, *run)
if err != nil {
return fmt.Errorf("failed to get jobs: %w", err)
}
var annotations []shared.Annotation
var annotationErr error
var as []shared.Annotation
for _, job := range jobs {
as, annotationErr = shared.GetAnnotations(client, repo, job)
if annotationErr != nil {
break
}
annotations = append(annotations, as...)
}
opts.IO.StopProgressIndicator()
if annotationErr != nil {
return fmt.Errorf("failed to get annotations: %w", annotationErr)
}
out := opts.IO.Out
cs := opts.IO.ColorScheme()
title := fmt.Sprintf("%s %s%s",
cs.Bold(run.HeadBranch), run.Name, prNumber)
symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion)
id := cs.Cyanf("%d", run.ID)
fmt.Fprintln(out)
fmt.Fprintf(out, "%s %s · %s\n", symbolColor(symbol), title, id)
ago := opts.Now().Sub(run.CreatedAt)
fmt.Fprintf(out, "Triggered via %s %s\n", run.Event, utils.FuzzyAgo(ago))
fmt.Fprintln(out)
if len(jobs) == 0 && run.Conclusion == shared.Failure {
fmt.Fprintf(out, "%s %s\n",
cs.FailureIcon(),
cs.Bold("This run likely failed because of a workflow file issue."))
fmt.Fprintln(out)
fmt.Fprintf(out, "For more information, see: %s\n", cs.Bold(run.URL))
if opts.ExitStatus {
return cmdutil.SilentError
}
return nil
}
fmt.Fprintln(out, cs.Bold("JOBS"))
for _, job := range jobs {
symbol, symbolColor := shared.Symbol(cs, job.Status, job.Conclusion)
id := cs.Cyanf("%d", job.ID)
fmt.Fprintf(out, "%s %s (ID %s)\n", symbolColor(symbol), job.Name, id)
if opts.Verbose || shared.IsFailureState(job.Conclusion) {
for _, step := range job.Steps {
stepSymbol, stepSymColor := shared.Symbol(cs, step.Status, step.Conclusion)
fmt.Fprintf(out, " %s %s\n", stepSymColor(stepSymbol), step.Name)
}
}
}
if len(annotations) > 0 {
fmt.Fprintln(out)
fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
for _, a := range annotations {
fmt.Fprintf(out, "%s %s\n", shared.AnnotationSymbol(cs, a), a.Message)
fmt.Fprintln(out, cs.Grayf("%s: %s#%d\n",
a.JobName, a.Path, a.StartLine))
}
}
fmt.Fprintln(out)
fmt.Fprintln(out, "For more information about a job, try: gh job view <job-id>")
fmt.Fprintf(out, cs.Gray("view this run on GitHub: %s\n"), run.URL)
if opts.ExitStatus && shared.IsFailureState(run.Conclusion) {
return cmdutil.SilentError
}
return nil
}
func prForRun(client *api.Client, repo ghrepo.Interface, run shared.Run) (int, error) {
type response struct {
Repository struct {
PullRequests struct {
Nodes []struct {
Number int
HeadRepository struct {
Owner struct {
Login string
}
Name string
}
}
}
}
Number int
}
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"headRefName": run.HeadBranch,
}
query := `
query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
nodes {
number
headRepository {
owner {
login
}
name
}
}
}
}
}`
var resp response
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return -1, err
}
prs := resp.Repository.PullRequests.Nodes
if len(prs) == 0 {
return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
}
number := -1
for _, pr := range prs {
if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name {
number = pr.Number
}
}
if number == -1 {
return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
}
return number, nil
}

View file

@ -0,0 +1,313 @@
package view
import (
"bytes"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/run/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdView(t *testing.T) {
tests := []struct {
name string
cli string
tty bool
wants ViewOptions
wantsErr bool
}{
{
name: "blank nontty",
wantsErr: true,
},
{
name: "blank tty",
tty: true,
wants: ViewOptions{
Prompt: true,
},
},
{
name: "exit status",
cli: "--exit-status 1234",
wants: ViewOptions{
RunID: "1234",
ExitStatus: true,
},
},
{
name: "verbosity",
cli: "-v",
tty: true,
wants: ViewOptions{
Verbose: true,
Prompt: true,
},
},
{
name: "with arg nontty",
cli: "1234",
wants: ViewOptions{
RunID: "1234",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ViewOptions
cmd := NewCmdView(f, func(opts *ViewOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.RunID, gotOpts.RunID)
assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt)
assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus)
assert.Equal(t, tt.wants.Verbose, gotOpts.Verbose)
})
}
}
func TestViewRun(t *testing.T) {
tests := []struct {
name string
httpStubs func(*httpmock.Registry)
askStubs func(*prompt.AskStubber)
opts *ViewOptions
tty bool
wantErr bool
wantOut string
}{
{
name: "associate with PR",
tty: true,
opts: &ViewOptions{
RunID: "3",
Prompt: false,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.GraphQL(`query PullRequestForRun`),
httpmock.StringResponse(`{"data": {
"repository": {
"pullRequests": {
"nodes": [
{"number": 2898,
"headRepository": {
"owner": {
"login": "OWNER"
},
"name": "REPO"}}
]}}}}`))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
httpmock.JSONResponse([]shared.Annotation{}))
},
wantOut: "\n✓ trunk successful #2898 · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/3\n",
},
{
name: "exit status, failed run",
opts: &ViewOptions{
RunID: "1234",
ExitStatus: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.GraphQL(`query PullRequestForRun`),
httpmock.StringResponse(``))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.FailedJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
httpmock.JSONResponse(shared.FailedJobAnnotations))
},
wantOut: "\nX trunk failed · 1234\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job (ID 20)\n ✓ barf the quux\n X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/1234\n",
wantErr: true,
},
{
name: "exit status, successful run",
opts: &ViewOptions{
RunID: "3",
ExitStatus: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.GraphQL(`query PullRequestForRun`),
httpmock.StringResponse(``))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
httpmock.JSONResponse([]shared.Annotation{}))
},
wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/3\n",
},
{
name: "verbose",
tty: true,
opts: &ViewOptions{
RunID: "1234",
Prompt: false,
Verbose: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.GraphQL(`query PullRequestForRun`),
httpmock.StringResponse(``))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJob,
shared.FailedJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
httpmock.JSONResponse([]shared.Annotation{}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
httpmock.JSONResponse(shared.FailedJobAnnotations))
},
wantOut: "\nX trunk failed · 1234\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n ✓ fob the barz\n ✓ barz the fob\nX sad job (ID 20)\n ✓ barf the quux\n X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/1234\n",
},
{
name: "prompts for choice",
tty: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.GraphQL(`query PullRequestForRun`),
httpmock.StringResponse(``))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJob,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
httpmock.JSONResponse([]shared.Annotation{}))
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(2)
},
opts: &ViewOptions{
Prompt: true,
},
wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/3\n",
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
tt.httpStubs(reg)
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
tt.opts.Now = func() time.Time {
notnow, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 05:50:00")
return notnow
}
io, _, stdout, _ := iostreams.Test()
io.SetStdoutTTY(tt.tty)
tt.opts.IO = io
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
}
as, teardown := prompt.InitAskStubber()
defer teardown()
if tt.askStubs != nil {
tt.askStubs(as)
}
t.Run(tt.name, func(t *testing.T) {
err := runView(tt.opts)
if tt.wantErr {
assert.Error(t, err)
if !tt.opts.ExitStatus {
return
}
}
if !tt.opts.ExitStatus {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantOut, stdout.String())
reg.Verify(t)
})
}
}

View file

@ -63,6 +63,10 @@ func (c *ColorScheme) Bold(t string) string {
return bold(t)
}
func (c *ColorScheme) Boldf(t string, args ...interface{}) string {
return c.Bold(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) Red(t string) string {
if !c.enabled {
return t
@ -70,6 +74,10 @@ func (c *ColorScheme) Red(t string) string {
return red(t)
}
func (c *ColorScheme) Redf(t string, args ...interface{}) string {
return c.Red(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) Yellow(t string) string {
if !c.enabled {
return t
@ -77,6 +85,10 @@ func (c *ColorScheme) Yellow(t string) string {
return yellow(t)
}
func (c *ColorScheme) Yellowf(t string, args ...interface{}) string {
return c.Yellow(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) Green(t string) string {
if !c.enabled {
return t
@ -84,6 +96,10 @@ func (c *ColorScheme) Green(t string) string {
return green(t)
}
func (c *ColorScheme) Greenf(t string, args ...interface{}) string {
return c.Green(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) Gray(t string) string {
if !c.enabled {
return t
@ -94,6 +110,10 @@ func (c *ColorScheme) Gray(t string) string {
return gray(t)
}
func (c *ColorScheme) Grayf(t string, args ...interface{}) string {
return c.Gray(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) Magenta(t string) string {
if !c.enabled {
return t
@ -101,6 +121,10 @@ func (c *ColorScheme) Magenta(t string) string {
return magenta(t)
}
func (c *ColorScheme) Magentaf(t string, args ...interface{}) string {
return c.Magenta(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) Cyan(t string) string {
if !c.enabled {
return t
@ -108,6 +132,10 @@ func (c *ColorScheme) Cyan(t string) string {
return cyan(t)
}
func (c *ColorScheme) Cyanf(t string, args ...interface{}) string {
return c.Cyan(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) CyanBold(t string) string {
if !c.enabled {
return t
@ -122,6 +150,10 @@ func (c *ColorScheme) Blue(t string) string {
return blue(t)
}
func (c *ColorScheme) Bluef(t string, args ...interface{}) string {
return c.Blue(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) SuccessIcon() string {
return c.SuccessIconWithColor(c.Green)
}
@ -135,7 +167,11 @@ func (c *ColorScheme) WarningIcon() string {
}
func (c *ColorScheme) FailureIcon() string {
return c.Red("X")
return c.FailureIconWithColor(c.Red)
}
func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string {
return colo("X")
}
func (c *ColorScheme) ColorFromString(s string) func(string) string {

View file

@ -44,6 +44,7 @@ func (t ttyTablePrinter) IsTTY() bool {
return true
}
// Never pass pre-colorized text to AddField; always specify colorFunc. Otherwise, the table printer can't correctly compute the width of its columns.
func (t *ttyTablePrinter) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) {
if truncateFunc == nil {
truncateFunc = text.Truncate