Merge pull request #7232 from wingkwong/feat/run-view-attempt

feat: gh run view --attempt
This commit is contained in:
Nate Smith 2023-03-28 09:50:02 -07:00 committed by GitHub
commit 2f0bc86740
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 288 additions and 12 deletions

View file

@ -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) {

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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{

View file

@ -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 {