diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 39891986c..ca4521b1d 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -95,7 +95,7 @@ func runCancel(opts *CancelOptions) error { } } } else { - run, err = shared.GetRun(client, repo, runID) + run, err = shared.GetRun(client, repo, runID, 0) if err != nil { var httpErr api.HTTPError if errors.As(err, &httpErr) { diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index f439a2304..2522e632c 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -139,7 +139,7 @@ func runRerun(opts *RerunOptions) error { } } else { opts.IO.StartProgressIndicator() - run, err := shared.GetRun(client, repo, runID) + run, err := shared.GetRun(client, repo, runID, 0) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run: %w", err) diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index 900bd07a1..2ec149729 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -7,14 +7,19 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" ) -func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string) string { +func RenderRunHeader(cs *iostreams.ColorScheme, run Run, ago, prNumber string, attempt uint64) string { title := fmt.Sprintf("%s %s%s", cs.Bold(run.HeadBranch), run.WorkflowName(), prNumber) symbol, symbolColor := Symbol(cs, run.Status, run.Conclusion) id := cs.Cyanf("%d", run.ID) + attemptLabel := "" + if attempt > 0 { + attemptLabel = fmt.Sprintf(" (Attempt #%d)", attempt) + } + header := "" - header += fmt.Sprintf("%s %s · %s\n", symbolColor(symbol), title, id) + header += fmt.Sprintf("%s %s · %s%s\n", symbolColor(symbol), title, id, attemptLabel) header += fmt.Sprintf("Triggered via %s %s", run.Event, ago) return header diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index a1a71c4f9..db347eee5 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -449,16 +449,27 @@ func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { return fmt.Sprintf("%d", runs[selected].ID), nil } -func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) { +func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) { var result Run path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) + if attempt > 0 { + path = fmt.Sprintf("repos/%s/actions/runs/%s/attempts/%d?exclude_pull_requests=true", ghrepo.FullName(repo), runID, attempt) + } + err := client.REST(repo.RepoHost(), "GET", path, nil, &result) if err != nil { return nil, err } + if attempt > 0 { + result.URL, err = url.JoinPath(result.URL, fmt.Sprintf("/attempts/%d", attempt)) + if err != nil { + return nil, err + } + } + // Set name to workflow name workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID) if err != nil { diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 895823ade..34c5fcbc7 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -74,6 +74,7 @@ type ViewOptions struct { Log bool LogFailed bool Web bool + Attempt uint64 Prompt bool Exporter cmdutil.Exporter @@ -101,6 +102,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman # View a specific run $ gh run view 12345 + # View a specific run with specific attempt number + $ gh run view 12345 --attempt 3 + # View a specific job within a run $ gh run view --job 456789 @@ -153,6 +157,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVar(&opts.Log, "log", false, "View full log for either a run or specific job") cmd.Flags().BoolVar(&opts.LogFailed, "log-failed", false, "View the log for any failed steps in a run or specific job") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open run in the browser") + cmd.Flags().Uint64VarP(&opts.Attempt, "attempt", "a", 0, "The attempt number of the workflow run") cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.SingleRunFields) return cmd @@ -172,6 +177,7 @@ func runView(opts *ViewOptions) error { jobID := opts.JobID runID := opts.RunID + attempt := opts.Attempt var selectedJob *shared.Job var run *shared.Run var jobs []shared.Job @@ -206,7 +212,7 @@ func runView(opts *ViewOptions) error { } opts.IO.StartProgressIndicator() - run, err = shared.GetRun(client, repo, runID) + run, err = shared.GetRun(client, repo, runID, attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run: %w", err) @@ -271,7 +277,7 @@ func runView(opts *ViewOptions) error { } opts.IO.StartProgressIndicator() - runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run) + runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run, attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run log: %w", err) @@ -318,7 +324,7 @@ func runView(opts *ViewOptions) error { out := opts.IO.Out fmt.Fprintln(out) - fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber)) + fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber, attempt)) fmt.Fprintln(out) if len(jobs) == 0 && run.Conclusion == shared.Failure || run.Conclusion == shared.StartupFailure { @@ -425,7 +431,7 @@ func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) { return resp.Body, nil } -func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run) (*zip.ReadCloser, error) { +func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run, attempt uint64) (*zip.ReadCloser, error) { filename := fmt.Sprintf("run-log-%d-%d.zip", run.ID, run.StartedTime().Unix()) filepath := filepath.Join(os.TempDir(), "gh-cli-cache", filename) if !cache.Exists(filepath) { @@ -433,6 +439,11 @@ func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface logURL := fmt.Sprintf("%srepos/%s/actions/runs/%d/logs", ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID) + if attempt > 0 { + logURL = fmt.Sprintf("%srepos/%s/actions/runs/%d/attempts/%d/logs", + ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID, attempt) + } + resp, err := getLog(httpClient, logURL) if err != nil { return nil, err diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index ee1fca6ee..99a9ef30e 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -117,6 +117,14 @@ func TestNewCmdView(t *testing.T) { JobID: "4567", }, }, + { + name: "run id with attempt", + cli: "1234 --attempt 2", + wants: ViewOptions{ + RunID: "1234", + Attempt: 2, + }, + }, } for _, tt := range tests { @@ -154,6 +162,7 @@ func TestNewCmdView(t *testing.T) { assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt) assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus) assert.Equal(t, tt.wants.Verbose, gotOpts.Verbose) + assert.Equal(t, tt.wants.Attempt, gotOpts.Attempt) }) } } @@ -213,6 +222,50 @@ func TestViewRun(t *testing.T) { }, wantOut: "\n✓ trunk CI #2898 · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3\n", }, + { + name: "associate with PR with attempt", + tty: true, + opts: &ViewOptions{ + RunID: "3", + Attempt: 3, + Prompt: false, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"), + httpmock.StringResponse(`{}`)) + reg.Register( + httpmock.GraphQL(`query PullRequestForRun`), + httpmock.StringResponse(`{"data": { + "repository": { + "pullRequests": { + "nodes": [ + {"number": 2898, + "headRepository": { + "owner": { + "login": "OWNER" + }, + "name": "REPO"}} + ]}}}}`)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"), + httpmock.JSONResponse([]shared.Annotation{})) + }, + wantOut: "\n✓ trunk CI #2898 · 3 (Attempt #3)\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3/attempts/3\n", + }, { name: "exit status, failed run", opts: &ViewOptions{ @@ -291,6 +344,52 @@ func TestViewRun(t *testing.T) { View this run on GitHub: https://github.com/runs/3 `), }, + { + name: "with artifacts and attempt", + opts: &ViewOptions{ + RunID: "3", + Attempt: 3, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"), + httpmock.JSONResponse(map[string][]shared.Artifact{ + "artifacts": { + shared.Artifact{Name: "artifact-1", Expired: false}, + shared.Artifact{Name: "artifact-2", Expired: true}, + shared.Artifact{Name: "artifact-3", Expired: false}, + }, + })) + reg.Register( + httpmock.GraphQL(`query PullRequestForRun`), + httpmock.StringResponse(``)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: heredoc.Doc(` + + ✓ trunk CI · 3 (Attempt #3) + Triggered via push about 59 minutes ago + + JOBS + + + ARTIFACTS + artifact-1 + artifact-2 (expired) + artifact-3 + + For more information about a job, try: gh run view --job= + View this run on GitHub: https://github.com/runs/3/attempts/3 + `), + }, { name: "exit status, successful run", opts: &ViewOptions{ @@ -323,6 +422,39 @@ func TestViewRun(t *testing.T) { }, wantOut: "\n✓ trunk CI · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3\n", }, + { + name: "exit status, successful run, with attempt", + opts: &ViewOptions{ + RunID: "3", + Attempt: 3, + ExitStatus: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/artifacts"), + httpmock.StringResponse(`{}`)) + reg.Register( + httpmock.GraphQL(`query PullRequestForRun`), + httpmock.StringResponse(``)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"), + httpmock.JSONResponse([]shared.Annotation{})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: "\n✓ trunk CI · 3 (Attempt #3)\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n\nFor more information about the job, try: gh run view --job=10\nView this run on GitHub: https://github.com/runs/3/attempts/3\n", + }, { name: "verbose", tty: true, @@ -455,6 +587,53 @@ func TestViewRun(t *testing.T) { }, wantOut: coolJobRunLogOutput, }, + { + name: "interactive with log and attempt", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Attempt: 3, + 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/attempts/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/runs/3/attempts/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + askStubs: func(as *prompt.AskStubber) { + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(2) + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(1) + }, + wantOut: coolJobRunLogOutput, + }, { name: "noninteractive with log", opts: &ViewOptions{ @@ -477,6 +656,29 @@ func TestViewRun(t *testing.T) { }, wantOut: coolJobRunLogOutput, }, + { + name: "noninteractive with log and attempt", + opts: &ViewOptions{ + JobID: "10", + Attempt: 3, + 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/attempts/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/attempts/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: coolJobRunLogOutput, + }, { name: "interactive with run log", tty: true, @@ -597,6 +799,53 @@ func TestViewRun(t *testing.T) { }, wantOut: quuxTheBarfLogOutput, }, + { + name: "interactive with log-failed with attempt", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Attempt: 3, + LogFailed: 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/attempts/3"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/attempts/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + askStubs: func(as *prompt.AskStubber) { + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(4) + //nolint:staticcheck // SA1019: as.StubOne is deprecated: use StubPrompt + as.StubOne(2) + }, + wantOut: quuxTheBarfLogOutput, + }, { name: "noninteractive with log-failed", opts: &ViewOptions{ diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go index d56c42405..05b207693 100644 --- a/pkg/cmd/run/watch/watch.go +++ b/pkg/cmd/run/watch/watch.go @@ -114,7 +114,7 @@ func watchRun(opts *WatchOptions) error { } } } else { - run, err = shared.GetRun(client, repo, runID) + run, err = shared.GetRun(client, repo, runID, 0) if err != nil { return fmt.Errorf("failed to get run: %w", err) } @@ -201,7 +201,7 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo var err error - run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID)) + run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID), 0) if err != nil { return nil, fmt.Errorf("failed to get run: %w", err) } @@ -236,7 +236,7 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo return nil, fmt.Errorf("failed to get annotations: %w", annotationErr) } - fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber)) + fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber, 0)) fmt.Fprintln(out) if len(jobs) == 0 {