diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index 4886e89f7..c483bafe3 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -23,9 +23,14 @@ func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string) s func RenderJobs(cs *iostreams.ColorScheme, jobs []Job, verbose bool) string { lines := []string{} for _, job := range jobs { + elapsed := job.CompletedAt.Sub(job.StartedAt) + elapsedStr := fmt.Sprintf(" in %s", elapsed) + if elapsed < 0 { + elapsedStr = "" + } symbol, symbolColor := Symbol(cs, job.Status, job.Conclusion) id := cs.Cyanf("%d", job.ID) - lines = append(lines, fmt.Sprintf("%s %s (ID %s)", symbolColor(symbol), job.Name, id)) + lines = append(lines, fmt.Sprintf("%s %s%s (ID %s)", symbolColor(symbol), cs.Bold(job.Name), elapsedStr, id)) if verbose || IsFailureState(job.Conclusion) { for _, step := range job.Steps { stepSymbol, stepSymColor := Symbol(cs, step.Status, step.Conclusion) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 1ad63cd89..79489e61d 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -85,6 +85,7 @@ type Job struct { StartedAt time.Time `json:"started_at"` CompletedAt time.Time `json:"completed_at"` URL string `json:"html_url"` + RunID int `json:"run_id"` } type Step struct { @@ -226,6 +227,7 @@ func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { for _, run := range runs { symbol, _ := Symbol(cs, run.Status, run.Conclusion) candidates = append(candidates, + // TODO truncate commit message, long ones look terrible fmt.Sprintf("%s %s, %s (%s)", symbol, run.CommitMsg(), run.Name, run.HeadBranch)) } diff --git a/pkg/cmd/run/shared/test.go b/pkg/cmd/run/shared/test.go index 8b8e67b42..e0373d828 100644 --- a/pkg/cmd/run/shared/test.go +++ b/pkg/cmd/run/shared/test.go @@ -69,6 +69,7 @@ var SuccessfulJob Job = Job{ StartedAt: created(), CompletedAt: updated(), URL: "jobs/10", + RunID: 3, Steps: []Step{ { Name: "fob the barz", @@ -93,6 +94,7 @@ var FailedJob Job = Job{ StartedAt: created(), CompletedAt: updated(), URL: "jobs/20", + RunID: 1234, Steps: []Step{ { Name: "barf the quux", diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 2bbf26480..d04668ae8 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -3,15 +3,19 @@ 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/ghinstance" "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" ) @@ -22,8 +26,10 @@ type ViewOptions struct { BaseRepo func() (ghrepo.Interface, error) RunID string + JobID string Verbose bool ExitStatus bool + Log bool Prompt bool @@ -42,25 +48,42 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman Args: cobra.MaximumNArgs(1), Hidden: true, Example: heredoc.Doc(` - # Interactively select a run to view + # Interactively select a run to view, optionally drilling down to a job $ gh run view # View a specific run - $ gh run view 0451 + $ gh run view 12345 + + # View a specific job within a run + $ gh run view --job 456789 + + # View the full log for a specific job + $ gh run view --log --job 456789 # Exit non-zero if a run failed - $ gh run view 0451 -e && echo "job pending or passed" + $ gh run view 0451 -e && echo "run pending or passed" `), + // TODO should exit status respect only a selected job if --job is passed? RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - if len(args) > 0 { + if len(args) == 0 && opts.JobID == "" { + if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("run or job ID required when not running interactively")} + } else { + opts.Prompt = true + } + } else 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 opts.RunID != "" && opts.JobID != "" { + opts.RunID = "" + if opts.IO.CanPrompt() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s both run and job IDs specified; ignoring run ID\n", cs.WarningIcon()) + } } if runF != nil { @@ -72,28 +95,50 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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") + cmd.Flags().StringVarP(&opts.JobID, "job", "j", "", "View a specific job ID from a run") + cmd.Flags().BoolVar(&opts.Log, "log", false, "View full log for either a run or specific job") return cmd } func runView(opts *ViewOptions) error { - c, err := opts.HttpClient() + httpClient, err := opts.HttpClient() if err != nil { return fmt.Errorf("failed to create http client: %w", err) } - client := api.NewClientFromHTTP(c) + client := api.NewClientFromHTTP(httpClient) repo, err := opts.BaseRepo() if err != nil { return fmt.Errorf("failed to determine base repo: %w", err) } + jobID := opts.JobID runID := opts.RunID + var selectedJob *shared.Job + var run *shared.Run + var jobs []shared.Job + + defer opts.IO.StopProgressIndicator() + + if jobID != "" { + opts.IO.StartProgressIndicator() + selectedJob, err = getJob(client, repo, jobID) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + // TODO once more stuff is merged, standardize on using ints + runID = fmt.Sprintf("%d", selectedJob.RunID) + } + + cs := opts.IO.ColorScheme() if opts.Prompt { - cs := opts.IO.ColorScheme() // TODO arbitrary limit + opts.IO.StartProgressIndicator() runs, err := shared.GetRuns(client, repo, 10) + opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get runs: %w", err) } @@ -104,24 +149,71 @@ func runView(opts *ViewOptions) error { } opts.IO.StartProgressIndicator() - defer opts.IO.StopProgressIndicator() - - run, err := shared.GetRun(client, repo, runID) + run, err = shared.GetRun(client, repo, runID) + opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run: %w", err) } + if opts.Prompt { + opts.IO.StartProgressIndicator() + jobs, err = shared.GetJobs(client, repo, *run) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + if len(jobs) > 1 { + selectedJob, err = promptForJob(cs, jobs) + if err != nil { + return err + } + } + } + + opts.IO.StartProgressIndicator() + + if opts.Log && selectedJob != nil { + r, err := jobLog(httpClient, repo, selectedJob.ID) + 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(run.Conclusion) { + return cmdutil.SilentError + } + + return nil + } + + // TODO support --log without selectedJob + + if selectedJob == nil && len(jobs) == 0 { + jobs, err = shared.GetJobs(client, repo, *run) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get jobs: %w", err) + } + } else if selectedJob != nil { + jobs = []shared.Job{*selectedJob} + } + prNumber := "" number, err := shared.PullRequestForRun(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 @@ -135,12 +227,12 @@ func runView(opts *ViewOptions) error { } opts.IO.StopProgressIndicator() + if annotationErr != nil { return fmt.Errorf("failed to get annotations: %w", annotationErr) } out := opts.IO.Out - cs := opts.IO.ColorScheme() ago := opts.Now().Sub(run.CreatedAt) @@ -162,9 +254,12 @@ func runView(opts *ViewOptions) error { return nil } - fmt.Fprintln(out, cs.Bold("JOBS")) - - fmt.Fprintln(out, shared.RenderJobs(cs, jobs, opts.Verbose)) + if selectedJob == nil { + fmt.Fprintln(out, cs.Bold("JOBS")) + fmt.Fprintln(out, shared.RenderJobs(cs, jobs, opts.Verbose)) + } else { + fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true)) + } if len(annotations) > 0 { fmt.Fprintln(out) @@ -172,9 +267,17 @@ func runView(opts *ViewOptions) error { fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations)) } - 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 selectedJob == nil { + fmt.Fprintln(out) + fmt.Fprintln(out, "For more information about a job, try: gh run view --job=") + // TODO note about run view --log when that exists + fmt.Fprintf(out, cs.Gray("view this run on GitHub: %s\n"), run.URL) + } else { + fmt.Fprintln(out) + // TODO this does not exist yet + fmt.Fprintf(out, "To see the full job log, try: gh run view --log --job=%d\n", selectedJob.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 @@ -182,3 +285,62 @@ func runView(opts *ViewOptions) error { return 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 +} + +func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID int) (io.ReadCloser, error) { + url := fmt.Sprintf("%srepos/%s/actions/jobs/%d/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 promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, error) { + candidates := []string{"View all jobs in this run"} + for _, job := range jobs { + symbol, _ := shared.Symbol(cs, job.Status, job.Conclusion) + candidates = append(candidates, fmt.Sprintf("%s %s", symbol, job.Name)) + } + + var selected int + err := prompt.SurveyAskOne(&survey.Select{ + Message: "View a specific job in this run?", + Options: candidates, + PageSize: 12, + }, &selected) + if err != nil { + return nil, err + } + + if selected > 0 { + return &jobs[selected-1], nil + } + + // User wants to see all jobs + return nil, nil +} diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index a42b49043..3149957c0 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -60,6 +60,29 @@ func TestNewCmdView(t *testing.T) { RunID: "1234", }, }, + { + name: "job id passed", + cli: "--job 1234", + wants: ViewOptions{ + JobID: "1234", + }, + }, + { + name: "log passed", + tty: true, + cli: "--log", + wants: ViewOptions{ + Prompt: true, + Log: true, + }, + }, + { + name: "tolerates both run and job id", + cli: "1234 --job 4567", + wants: ViewOptions{ + JobID: "4567", + }, + }, } for _, tt := range tests { @@ -147,7 +170,7 @@ func TestViewRun(t *testing.T) { 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", + wantOut: "\n✓ trunk successful #2898 · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about a job, try: gh run view --job=\nview this run on GitHub: runs/3\n", }, { name: "exit status, failed run", @@ -173,7 +196,7 @@ func TestViewRun(t *testing.T) { 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", + wantOut: "\nX trunk failed · 1234\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (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 run view --job=\nview this run on GitHub: runs/1234\n", wantErr: true, }, { @@ -200,7 +223,7 @@ func TestViewRun(t *testing.T) { 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", + wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about a job, try: gh run view --job=\nview this run on GitHub: runs/3\n", }, { name: "verbose", @@ -232,10 +255,10 @@ func TestViewRun(t *testing.T) { 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", + wantOut: "\nX trunk failed · 1234\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\nX sad job in 4m34s (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 run view --job=\nview this run on GitHub: runs/1234\n", }, { - name: "prompts for choice", + name: "prompts for choice, one job", tty: true, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -266,7 +289,147 @@ func TestViewRun(t *testing.T) { 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", + wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about a job, try: gh run view --job=\nview this run on GitHub: runs/3\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, + shared.FailedJob, + }, + })) + 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) + as.StubOne(1) + }, + wantOut: "it's a log\nfor this job\nbeautiful log\n", + }, + { + name: "noninteractive with log", + opts: &ViewOptions{ + JobID: "10", + 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/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + 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 with job", + 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/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + 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\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\nTo see the full job log, try: gh run view --log --job=10\nview this run on GitHub: runs/3\n", + }, + { + name: "interactive, multiple jobs, choose all jobs", + 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/check-runs/10/annotations"), + httpmock.JSONResponse([]shared.Annotation{})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"), + httpmock.JSONResponse(shared.FailedJobAnnotations)) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(2) + as.StubOne(0) + }, + wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\nX sad job in 4m34s (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 run view --job=\nview this run on GitHub: runs/3\n", + }, + { + name: "interactive, multiple jobs, choose specific jobs", + 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/check-runs/10/annotations"), + httpmock.JSONResponse([]shared.Annotation{})) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(2) + as.StubOne(1) + }, + wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\nTo see the full job log, try: gh run view --log --job=10\nview this run on GitHub: runs/3\n", }, }