diff --git a/cmd/gh/main.go b/cmd/gh/main.go index a32ae863a..3ba5da9cf 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -18,6 +18,7 @@ import ( "github.com/cli/cli/internal/build" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" "github.com/cli/cli/internal/update" "github.com/cli/cli/pkg/cmd/alias/expand" @@ -100,6 +101,11 @@ func mainRun() exitCode { cmdFactory.IOStreams.SetPager(pager) } + // TODO: remove after FromFullName has been revisited + if host, err := cfg.DefaultHost(); err == nil { + ghrepo.SetDefaultHost(host) + } + expandedArgs := []string{} if len(os.Args) > 0 { expandedArgs = os.Args[1:] diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index 997a5621d..84125a5b9 100644 --- a/internal/ghrepo/repo.go +++ b/internal/ghrepo/repo.go @@ -35,6 +35,21 @@ func FullName(r Interface) string { return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName()) } +var defaultHostOverride string + +func defaultHost() string { + if defaultHostOverride != "" { + return defaultHostOverride + } + return ghinstance.Default() +} + +// SetDefaultHost overrides the default GitHub hostname for FromFullName. +// TODO: remove after FromFullName approach is revisited +func SetDefaultHost(host string) { + defaultHostOverride = host +} + // FromFullName extracts the GitHub repository information from the following // formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL. func FromFullName(nwo string) (Interface, error) { @@ -54,9 +69,9 @@ func FromFullName(nwo string) (Interface, error) { } switch len(parts) { case 3: - return NewWithHost(parts[1], parts[2], normalizeHostname(parts[0])), nil + return NewWithHost(parts[1], parts[2], parts[0]), nil case 2: - return New(parts[0], parts[1]), nil + return NewWithHost(parts[0], parts[1], defaultHost()), nil default: return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo) } diff --git a/internal/ghrepo/repo_test.go b/internal/ghrepo/repo_test.go index 7d796f1cb..46fa37827 100644 --- a/internal/ghrepo/repo_test.go +++ b/internal/ghrepo/repo_test.go @@ -117,12 +117,13 @@ func Test_repoFromURL(t *testing.T) { func TestFromFullName(t *testing.T) { tests := []struct { - name string - input string - wantOwner string - wantName string - wantHost string - wantErr error + name string + input string + hostOverride string + wantOwner string + wantName string + wantHost string + wantErr error }{ { name: "OWNER/REPO combo", @@ -171,9 +172,30 @@ func TestFromFullName(t *testing.T) { wantName: "REPO", wantErr: nil, }, + { + name: "OWNER/REPO with default host override", + input: "OWNER/REPO", + hostOverride: "override.com", + wantHost: "override.com", + wantOwner: "OWNER", + wantName: "REPO", + wantErr: nil, + }, + { + name: "HOST/OWNER/REPO with default host override", + input: "example.com/OWNER/REPO", + hostOverride: "override.com", + wantHost: "example.com", + wantOwner: "OWNER", + wantName: "REPO", + wantErr: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.hostOverride != "" { + SetDefaultHost(tt.hostOverride) + } r, err := FromFullName(tt.input) if tt.wantErr != nil { if err == nil { diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index 0bcd5118d..f0e3509a4 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -26,6 +26,9 @@ func NewCmdActions(f *cmdutil.Factory) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { actionsRun(opts) }, + Annotations: map[string]string{ + "IsActions": "true", + }, } return cmd @@ -43,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/help.go b/pkg/cmd/root/help.go index 80b61341e..552c2a451 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -88,6 +88,7 @@ func rootHelpFunc(cs *iostreams.ColorScheme, command *cobra.Command, args []stri } coreCommands := []string{} + actionsCommands := []string{} additionalCommands := []string{} for _, c := range command.Commands() { if c.Short == "" { @@ -100,6 +101,8 @@ func rootHelpFunc(cs *iostreams.ColorScheme, command *cobra.Command, args []stri s := rpad(c.Name()+":", c.NamePadding()) + c.Short if _, ok := c.Annotations["IsCore"]; ok { coreCommands = append(coreCommands, s) + } else if _, ok := c.Annotations["IsActions"]; ok { + actionsCommands = append(actionsCommands, s) } else { additionalCommands = append(additionalCommands, s) } @@ -126,6 +129,9 @@ func rootHelpFunc(cs *iostreams.ColorScheme, command *cobra.Command, args []stri if len(coreCommands) > 0 { helpEntries = append(helpEntries, helpEntry{"CORE COMMANDS", strings.Join(coreCommands, "\n")}) } + if len(actionsCommands) > 0 { + helpEntries = append(helpEntries, helpEntry{"ACTIONS COMMANDS", strings.Join(actionsCommands, "\n")}) + } if len(additionalCommands) > 0 { helpEntries = append(helpEntries, helpEntry{"ADDITIONAL COMMANDS", strings.Join(additionalCommands, "\n")}) } 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/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go new file mode 100644 index 000000000..45bc320e1 --- /dev/null +++ b/pkg/cmd/run/rerun/rerun.go @@ -0,0 +1,120 @@ +package rerun + +import ( + "errors" + "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/spf13/cobra" +) + +type RerunOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RunID string + + Prompt bool +} + +func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Command { + opts := &RerunOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "rerun []", + Short: "Rerun a given run", + Args: cobra.MaximumNArgs(1), + 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 runRerun(opts) + }, + } + + return cmd +} + +func runRerun(opts *RerunOptions) 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() + runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool { + if run.Status != shared.Completed { + return false + } + // TODO StartupFailure indiciates a bad yaml file; such runs can never be + // rerun. But hiding them from the prompt might confuse people? + return run.Conclusion != shared.Success && run.Conclusion != shared.StartupFailure + }) + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + if len(runs) == 0 { + return errors.New("no recent runs have failed; please specify a specific run ID") + } + runID, err = shared.PromptForRun(cs, runs) + if err != nil { + return err + } + } + + opts.IO.StartProgressIndicator() + run, err := shared.GetRun(client, repo, runID) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + path := fmt.Sprintf("repos/%s/actions/runs/%d/rerun", ghrepo.FullName(repo), run.ID) + + err = client.REST(repo.RepoHost(), "POST", path, nil, nil) + if err != nil { + var httpError api.HTTPError + if errors.As(err, &httpError) && httpError.StatusCode == 403 { + return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken.", run.ID) + } + return fmt.Errorf("failed to rerun: %w", err) + } + + if opts.IO.CanPrompt() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Requested rerun of run %s\n", + cs.SuccessIcon(), + cs.Cyanf("%d", run.ID)) + } + + return nil +} diff --git a/pkg/cmd/run/rerun/rerun_test.go b/pkg/cmd/run/rerun/rerun_test.go new file mode 100644 index 000000000..585fa93f3 --- /dev/null +++ b/pkg/cmd/run/rerun/rerun_test.go @@ -0,0 +1,214 @@ +package rerun + +import ( + "bytes" + "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/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdRerun(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants RerunOptions + wantsErr bool + }{ + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "blank tty", + tty: true, + wants: RerunOptions{ + Prompt: true, + }, + }, + { + name: "with arg nontty", + cli: "1234", + wants: RerunOptions{ + RunID: "1234", + }, + }, + { + name: "with arg tty", + tty: true, + cli: "1234", + wants: RerunOptions{ + 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 *RerunOptions + cmd := NewCmdRerun(f, func(opts *RerunOptions) 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) + }) + } + +} + +func TestRerun(t *testing.T) { + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + opts *RerunOptions + tty bool + wantErr bool + ErrOut string + wantOut string + }{ + { + name: "arg", + tty: true, + opts: &RerunOptions{ + RunID: "1234", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun"), + httpmock.StringResponse("{}")) + }, + wantOut: "✓ Requested rerun of run 1234\n", + }, + { + name: "prompt", + tty: true, + opts: &RerunOptions{ + 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/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun"), + httpmock.StringResponse("{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(2) + }, + wantOut: "✓ Requested rerun of run 1234\n", + }, + { + name: "prompt but no failed runs", + tty: true, + opts: &RerunOptions{ + Prompt: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.SuccessfulRun, + shared.TestRun("in progress", 2, shared.InProgress, ""), + }})) + }, + wantErr: true, + ErrOut: "no recent runs have failed; please specify a specific run ID", + }, + { + name: "unrerunnable", + tty: true, + opts: &RerunOptions{ + RunID: "3", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/3/rerun"), + httpmock.StatusStringResponse(403, "no")) + }, + wantErr: true, + ErrOut: "run 3 cannot be rerun; its workflow file may be broken.", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + tt.httpStubs(reg) + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + 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 := runRerun(tt.opts) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.ErrOut, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go index 716f35ea6..5ee80f513 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -3,6 +3,7 @@ package run import ( cmdDownload "github.com/cli/cli/pkg/cmd/run/download" cmdList "github.com/cli/cli/pkg/cmd/run/list" + cmdRerun "github.com/cli/cli/pkg/cmd/run/rerun" cmdView "github.com/cli/cli/pkg/cmd/run/view" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -12,15 +13,17 @@ 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 + Hidden: true, + Annotations: map[string]string{ + "IsActions": "true", + }, } cmdutil.EnableRepoOverride(cmd, f) cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil)) cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) return cmd 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..45f98abc3 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 { @@ -150,6 +151,25 @@ type RunsPayload struct { WorkflowRuns []Run `json:"workflow_runs"` } +func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, limit int, f func(Run) bool) ([]Run, error) { + path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo)) + runs, err := getRuns(client, repo, path, 50) + if err != nil { + return nil, err + } + filtered := []Run{} + for _, run := range runs { + if f(run) { + filtered = append(filtered, run) + } + if len(filtered) == limit { + break + } + } + + return filtered, nil +} + func GetRunsByWorkflow(client *api.Client, repo ghrepo.Interface, limit, workflowID int) ([]Run, error) { path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), workflowID) return getRuns(client, repo, path, limit) @@ -172,9 +192,17 @@ func getRuns(client *api.Client, repo ghrepo.Interface, path string, limit int) for len(runs) < limit { var result RunsPayload - pagedPath := fmt.Sprintf("%s?per_page=%d&page=%d", path, perPage, page) + parsed, err := url.Parse(path) + if err != nil { + return nil, err + } + query := parsed.Query() + query.Set("per_page", fmt.Sprintf("%d", perPage)) + query.Set("page", fmt.Sprintf("%d", page)) + parsed.RawQuery = query.Encode() + pagedPath := parsed.String() - err := client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result) + err = client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result) if err != nil { return nil, err } @@ -226,6 +254,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 5a56ab25b..a7446b4bb 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -3,17 +3,21 @@ package view import ( "errors" "fmt" + "io" "net/http" "strconv" "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/download" "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" ) @@ -24,8 +28,10 @@ type ViewOptions struct { BaseRepo func() (ghrepo.Interface, error) RunID string + JobID string Verbose bool ExitStatus bool + Log bool Prompt bool @@ -44,25 +50,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 { @@ -74,28 +97,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) } @@ -106,27 +151,77 @@ 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) - } - - artifacts, err := download.ListArtifacts(c, repo, strconv.Itoa(run.ID)) - if err != nil { - return fmt.Errorf("failed to get artifacts: %w", err) + var artifacts []download.Artifact + if selectedJob == nil { + artifacts, err = download.ListArtifacts(httpClient, repo, strconv.Itoa(run.ID)) + if err != nil { + return fmt.Errorf("failed to get artifacts: %w", err) + } } var annotations []shared.Annotation @@ -142,12 +237,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) @@ -169,9 +264,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) @@ -179,23 +277,31 @@ func runView(opts *ViewOptions) error { fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations)) } - var validArtifacts []download.Artifact - for _, a := range artifacts { - if !a.Expired { - validArtifacts = append(validArtifacts, a) + if selectedJob == nil { + var validArtifacts []download.Artifact + for _, a := range artifacts { + if !a.Expired { + validArtifacts = append(validArtifacts, a) + } } - } - if len(validArtifacts) > 0 { - fmt.Fprintln(out) - fmt.Fprintln(out, cs.Bold("ARTIFACTS")) - for _, a := range validArtifacts { - fmt.Fprintln(out, a.Name) + if len(validArtifacts) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, cs.Bold("ARTIFACTS")) + for _, a := range validArtifacts { + fmt.Fprintln(out, a.Name) + } } - } - 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) + 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 @@ -203,3 +309,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 1fe515aaa..6ab114979 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 { @@ -150,7 +173,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", @@ -179,7 +202,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, }, { @@ -209,7 +232,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", @@ -244,10 +267,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( @@ -281,7 +304,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", }, } diff --git a/pkg/cmd/workflow/workflow.go b/pkg/cmd/workflow/workflow.go index b3632ce17..a61e28ca4 100644 --- a/pkg/cmd/workflow/workflow.go +++ b/pkg/cmd/workflow/workflow.go @@ -12,10 +12,11 @@ func NewCmdWorkflow(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "workflow ", Short: "View details about GitHub Actions workflows", - Hidden: true, Long: "List, view, and run workflows in 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 + Hidden: true, + Annotations: map[string]string{ + "IsActions": "true", + }, } cmdutil.EnableRepoOverride(cmd, f)