diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go new file mode 100644 index 000000000..0bcd5118d --- /dev/null +++ b/pkg/cmd/actions/actions.go @@ -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"))) + */ +} diff --git a/pkg/cmd/job/job.go b/pkg/cmd/job/job.go new file mode 100644 index 000000000..d420b610e --- /dev/null +++ b/pkg/cmd/job/job.go @@ -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 ", + 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 +} diff --git a/pkg/cmd/job/view/http.go b/pkg/cmd/job/view/http.go new file mode 100644 index 000000000..0541da996 --- /dev/null +++ b/pkg/cmd/job/view/http.go @@ -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 +} diff --git a/pkg/cmd/job/view/view.go b/pkg/cmd/job/view/view.go new file mode 100644 index 000000000..61ca75197 --- /dev/null +++ b/pkg/cmd/job/view/view.go @@ -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 []", + 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 +} diff --git a/pkg/cmd/job/view/view_test.go b/pkg/cmd/job/view/view_test.go new file mode 100644 index 000000000..5d134320d --- /dev/null +++ b/pkg/cmd/job/view/view_test.go @@ -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) + }) + } + +} diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index 1b16753f1..ac3fbe677 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -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")) } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 74b2f4e8a..b43bf8cfb 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -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") diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ba05f902c..046e30820 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -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) diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go new file mode 100644 index 000000000..77e4ec05f --- /dev/null +++ b/pkg/cmd/run/list/list.go @@ -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 ") + } + + return nil +} diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go new file mode 100644 index 000000000..b93102002 --- /dev/null +++ b/pkg/cmd/run/list/list_test.go @@ -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 \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 \n" diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go new file mode 100644 index 000000000..a1620e3b4 --- /dev/null +++ b/pkg/cmd/run/run.go @@ -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 ", + 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 +} diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go new file mode 100644 index 000000000..d3ca228c1 --- /dev/null +++ b/pkg/cmd/run/shared/shared.go @@ -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 +} diff --git a/pkg/cmd/run/shared/test.go b/pkg/cmd/run/shared/test.go new file mode 100644 index 000000000..89a1bae6d --- /dev/null +++ b/pkg/cmd/run/shared/test.go @@ -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, + }, +} diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go new file mode 100644 index 000000000..f4b6ca453 --- /dev/null +++ b/pkg/cmd/run/view/view.go @@ -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 []", + 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 ") + 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 +} diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go new file mode 100644 index 000000000..a42b49043 --- /dev/null +++ b/pkg/cmd/run/view/view_test.go @@ -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 \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 \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 \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 \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 \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) + }) + } +} diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 46cfc3a3c..2dedbdbd5 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -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 { diff --git a/utils/table_printer.go b/utils/table_printer.go index 3047e2d81..078ede9f8 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -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