diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index fedd38af4..f0e3509a4 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -46,34 +46,16 @@ func actionsRun(opts ActionsOptions) { %s gh run list: List recent workflow runs - gh run view: View details for a given workflow run + gh run view: View details for a workflow run or one of its jobs + gh run watch: Watch a workflow run while it executes + gh run rerun: Rerun a failed workflow run %s - gh job view: View details for a given job + gh workflow list: List all the workflow files in your repository + gh workflow enable: Enable a workflow file + gh workflow disable: Disable a workflow file + gh workflow run: Trigger a workflow_dispatch run for a workflow file `, - 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"))) - */ + cs.Bold("Interacting with workflow runs"), + cs.Bold("Interacting with workflow files"))) } diff --git a/pkg/cmd/job/job.go b/pkg/cmd/job/job.go deleted file mode 100644 index d420b610e..000000000 --- a/pkg/cmd/job/job.go +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 0541da996..000000000 --- a/pkg/cmd/job/view/http.go +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index 099cd0ccf..000000000 --- a/pkg/cmd/job/view/view.go +++ /dev/null @@ -1,242 +0,0 @@ -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 { - // TODO arbitrary limit - runs, err := shared.GetRuns(client, repo, 10) - if err != nil { - return fmt.Errorf("failed to get runs: %w", err) - } - runID, err := shared.PromptForRun(cs, runs) - 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 deleted file mode 100644 index 5d134320d..000000000 --- a/pkg/cmd/job/view/view_test.go +++ /dev/null @@ -1,351 +0,0 @@ -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/root/root.go b/pkg/cmd/root/root.go index 038fa57d7..180a5f8fa 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -16,7 +16,6 @@ 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" @@ -85,7 +84,6 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(actionsCmd.NewCmdActions(f)) cmd.AddCommand(runCmd.NewCmdRun(f)) - cmd.AddCommand(jobCmd.NewCmdJob(f)) cmd.AddCommand(workflowCmd.NewCmdWorkflow(f)) // the `api` command should not inherit any extra HTTP headers 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", }, }