Repository: cli/cli
Pull request #7240 (2023-03-28 15:41:03)

fix: throw error for non-existing org / repo with non-zero status in repo list
<!--
  Thank you for contributing to GitHub CLI!
  To reference an open issue, please write this in your description: `Fixes #NUMBER`
-->

fixes: #7231


Patch:
diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go
index f9c0ca8ac..e5b031794 100644
--- a/pkg/cmd/repo/list/list.go
+++ b/pkg/cmd/repo/list/list.go
@@ -154,6 +154,10 @@ func listRun(opts *ListOptions) error {
 		return err
 	}
 
+	if opts.Owner != "" && listResult.Owner == "" && !listResult.FromSearch {
+		return fmt.Errorf("the owner handle %q was not recognized as either a GitHub user or an organization", opts.Owner)
+	}
+
 	if err := opts.IO.StartPager(); err != nil {
 		fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
 	}
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/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 {


Test patch:
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=<job-id>
+				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{