Merge pull request #10740 from cli/babakks/fallback-to-job-run-logs

Fallback to job run logs when step logs are missing
This commit is contained in:
William Martin 2025-04-11 11:39:07 +02:00 committed by GitHub
commit 83bd23b080
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 941 additions and 46 deletions

View file

@ -230,6 +230,8 @@ type Job struct {
CompletedAt time.Time `json:"completed_at"`
URL string `json:"html_url"`
RunID int64 `json:"run_id"`
Log *zip.File
}
type Step struct {
@ -239,7 +241,8 @@ type Step struct {
Number int
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
Log *zip.File
Log *zip.File
}
type Steps []Step

View file

@ -104,6 +104,60 @@ var SuccessfulJob Job = Job{
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var SuccessfulJobWithoutStepLogs Job = Job{
ID: 11,
Status: Completed,
Conclusion: Success,
Name: "cool job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/11",
RunID: 3,
Steps: []Step{
{
Name: "fob the barz",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "barz the fob",
Status: Completed,
Conclusion: Success,
Number: 2,
},
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var LegacySuccessfulJobWithoutStepLogs Job = Job{
ID: 12,
Status: Completed,
Conclusion: Success,
Name: "legacy cool job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/12",
RunID: 3,
Steps: []Step{
{
Name: "fob the barz",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "barz the fob",
Status: Completed,
Conclusion: Success,
Number: 2,
},
},
}
var FailedJob Job = Job{
ID: 20,
Status: Completed,
@ -129,6 +183,60 @@ var FailedJob Job = Job{
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var FailedJobWithoutStepLogs Job = Job{
ID: 21,
Status: Completed,
Conclusion: Failure,
Name: "sad job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/21",
RunID: 1234,
Steps: []Step{
{
Name: "barf the quux",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "quux the barf",
Status: Completed,
Conclusion: Failure,
Number: 2,
},
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var LegacyFailedJobWithoutStepLogs Job = Job{
ID: 22,
Status: Completed,
Conclusion: Failure,
Name: "legacy sad job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/22",
RunID: 1234,
Steps: []Step{
{
Name: "barf the quux",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "quux the barf",
Status: Completed,
Conclusion: Failure,
Number: 2,
},
},
}
var SuccessfulJobAnnotations []Annotation = []Annotation{
{
JobName: "cool job",

View file

@ -533,7 +533,7 @@ func promptForJob(prompter shared.Prompter, cs *iostreams.ColorScheme, jobs []sh
const JOB_NAME_MAX_LENGTH = 90
func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
func getJobNameForLogFilename(name string) string {
// As described in https://github.com/cli/cli/issues/5011#issuecomment-1570713070, there are a number of steps
// the server can take when producing the downloaded zip file that can result in a mismatch between the job name
// and the filename in the zip including:
@ -545,9 +545,20 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
// * Strip `/` which occur when composite action job names are constructed of the form `<JOB_NAME`> / <ACTION_NAME>`
// * Truncate long job names
//
sanitizedJobName := strings.ReplaceAll(job.Name, "/", "")
sanitizedJobName := strings.ReplaceAll(name, "/", "")
sanitizedJobName = strings.ReplaceAll(sanitizedJobName, ":", "")
sanitizedJobName = truncateAsUTF16(sanitizedJobName, JOB_NAME_MAX_LENGTH)
return sanitizedJobName
}
func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp {
sanitizedJobName := getJobNameForLogFilename(job.Name)
re := fmt.Sprintf(`^-?\d+_%s\.txt`, regexp.QuoteMeta(sanitizedJobName))
return regexp.MustCompile(re)
}
func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
sanitizedJobName := getJobNameForLogFilename(job.Name)
re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number)
return regexp.MustCompile(re)
}
@ -627,17 +638,36 @@ func truncateAsUTF16(str string, max int) string {
// │ ├── 2_anotherstepname.txt
// │ ├── 3_stepstepname.txt
// │ └── 4_laststepname.txt
// └── jobname2/
// ├── 1_stepname.txt
// └── 2_somestepname.txt
// ├── jobname2/
// | ├── 1_stepname.txt
// | └── 2_somestepname.txt
// ├── 0_jobname1.txt
// ├── 1_jobname2.txt
// └── -9999999999_jobname3.txt
//
// It iterates through the list of jobs and tries to find the matching
// log in the zip file. If the matching log is found it is attached
// to the job.
//
// The top-level .txt files include the logs for an entire job run. Note that
// the prefixed number is either:
// - An ordinal and cannot be mapped to the corresponding job's ID.
// - A negative integer which is the ID of the job in the old Actions service.
// The service right now tries to get logs and use an ordinal in a loop.
// However, if it doesn't get the logs, it falls back to an old service
// where the ID can apparently be negative.
func attachRunLog(rlz *zip.Reader, jobs []shared.Job) {
for i, job := range jobs {
re := jobLogFilenameRegexp(job)
for _, file := range rlz.File {
if re.MatchString(file.Name) {
jobs[i].Log = file
break
}
}
for j, step := range job.Steps {
re := logFilenameRegexp(job, step)
re := stepLogFilenameRegexp(job, step)
for _, file := range rlz.File {
if re.MatchString(file.Name) {
jobs[i].Steps[j].Log = file
@ -650,6 +680,13 @@ func attachRunLog(rlz *zip.Reader, jobs []shared.Job) {
func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
for _, job := range jobs {
// To display a run log, we first try to compile it from individual step
// logs, because this way we can prepend lines with the corresponding
// step name. However, at the time of writing, logs are sometimes being
// served by a service that doesnt include the step logs (none of them),
// in which case we fall back to print the entire job run log.
var hasStepLogs bool
steps := job.Steps
sort.Sort(steps)
for _, step := range steps {
@ -659,18 +696,49 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
if step.Log == nil {
continue
}
hasStepLogs = true
prefix := fmt.Sprintf("%s\t%s\t", job.Name, step.Name)
f, err := step.Log.Open()
if err != nil {
if err := printZIPFile(w, step.Log, prefix); err != nil {
return err
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
}
f.Close()
}
if hasStepLogs {
continue
}
if failed && !shared.IsFailureState(job.Conclusion) {
continue
}
if job.Log == nil {
continue
}
// Here, we fall back to the job run log, which means we do not know
// the step name of lines. However, we want to keep the same line
// formatting to avoid breaking any code or script that rely on the
// tab-delimited formatting. So, an unknown-step placeholder is used
// instead of the actual step name.
prefix := fmt.Sprintf("%s\tUNKNOWN STEP\t", job.Name)
if err := printZIPFile(w, job.Log, prefix); err != nil {
return err
}
}
return nil
}
func printZIPFile(w io.Writer, file *zip.File, prefix string) error {
f, err := file.Open()
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
}
return nil
}

View file

@ -990,6 +990,619 @@ func TestViewRun(t *testing.T) {
},
wantOut: quuxTheBarfLogOutput,
},
{
name: "interactive with log, with no step logs available (#10551)",
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.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool job with no step logs")
})
},
wantOut: coolJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log, with no step logs available (#10551)",
opts: &ViewOptions{
JobID: "11",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/11"),
httpmock.JSONResponse(shared.SuccessfulJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: coolJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with log-failed, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
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"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "X sad job with no step logs")
})
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log-failed, with no step logs available (#10551)",
opts: &ViewOptions{
JobID: "21",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/21"),
httpmock.JSONResponse(shared.FailedJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with run log, with no step logs available (#10551)",
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.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: expectedRunLogOutputWithNoSteps,
},
{
name: "noninteractive with run log, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "3",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
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.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: expectedRunLogOutputWithNoSteps,
},
{
name: "interactive with run log-failed, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
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"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with run log-failed, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "1234",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with log, legacy service data, with no step logs available",
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.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ legacy cool job with no step logs")
})
},
wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log, legacy service data, with no step logs available",
opts: &ViewOptions{
JobID: "12",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/12"),
httpmock.JSONResponse(shared.LegacySuccessfulJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with log-failed, legacy service data, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
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"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "X legacy sad job with no step logs")
})
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log-failed, legacy service data, with no step logs available (#10551)",
opts: &ViewOptions{
JobID: "22",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/22"),
httpmock.JSONResponse(shared.LegacyFailedJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with run log, legacy service data, with no step logs available (#10551)",
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.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: expectedLegacyRunLogOutputWithNoSteps,
},
{
name: "noninteractive with run log, legacy service data, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "3",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
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.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: expectedLegacyRunLogOutputWithNoSteps,
},
{
name: "interactive with run log-failed, legacy service data, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
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"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/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,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with run log-failed, legacy service data, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "1234",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "run log but run is not done",
tty: true,
@ -1419,14 +2032,23 @@ func TestViewRun(t *testing.T) {
// ├── sad job/
// │ ├── 1_barf the quux.txt
// │ └── 2_quux the barf.txt
// └── ad job/
// └── 1_barf the quux.txt
// ├── ad job/
// | └── 1_barf the quux.txt
// ├── 0_cool job.txt
// ├── 1_sad job.txt
// ├── 2_cool job with no step logs.txt
// ├── 3_sad job with no step logs.txt
// ├── -9999999999_legacy cool job with no step logs.txt
// └── -9999999999_legacy sad job with no step logs.txt
func Test_attachRunLog(t *testing.T) {
tests := []struct {
name string
job shared.Job
wantMatch bool
wantFilename string
name string
job shared.Job
wantJobMatch bool
wantJobFilename string
wantStepMatch bool
wantStepFilename string
}{
{
name: "matching job name and step number 1",
@ -1437,8 +2059,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "cool job/1_fob the barz.txt",
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: true,
wantStepFilename: "cool job/1_fob the barz.txt",
},
{
name: "matching job name and step number 2",
@ -1449,8 +2073,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 2,
}},
},
wantMatch: true,
wantFilename: "cool job/2_barz the fob.txt",
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: true,
wantStepFilename: "cool job/2_barz the fob.txt",
},
{
name: "matching job name and step number and mismatch step name",
@ -1461,8 +2087,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "cool job/1_fob the barz.txt",
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: true,
wantStepFilename: "cool job/1_fob the barz.txt",
},
{
name: "matching job name and mismatch step number",
@ -1473,7 +2101,53 @@ func Test_attachRunLog(t *testing.T) {
Number: 3,
}},
},
wantMatch: false,
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: false,
},
{
name: "matching job name with no step logs",
job: shared.Job{
Name: "cool job with no step logs",
Steps: []shared.Step{{
Name: "fob the barz",
Number: 1,
}},
},
wantJobMatch: true,
wantJobFilename: "2_cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "matching job name with no step data",
job: shared.Job{
Name: "cool job with no step logs",
},
wantJobMatch: true,
wantJobFilename: "2_cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "matching job name with legacy filename and no step logs",
job: shared.Job{
Name: "legacy cool job with no step logs",
Steps: []shared.Step{{
Name: "fob the barz",
Number: 1,
}},
},
wantJobMatch: true,
wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "matching job name with legacy filename and no step data",
job: shared.Job{
Name: "legacy cool job with no step logs",
},
wantJobMatch: true,
wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "one job name is a suffix of another",
@ -1484,8 +2158,8 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "ad job/1_barf the quux.txt",
wantStepMatch: true,
wantStepFilename: "ad job/1_barf the quux.txt",
},
{
name: "escape metacharacters in job name",
@ -1496,7 +2170,8 @@ func Test_attachRunLog(t *testing.T) {
Number: 0,
}},
},
wantMatch: false,
wantJobMatch: false,
wantStepMatch: false,
},
{
name: "mismatching job name",
@ -1507,7 +2182,8 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: false,
wantJobMatch: false,
wantStepMatch: false,
},
{
name: "job name with forward slash matches dir with slash removed",
@ -1518,9 +2194,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantJobMatch: false,
wantStepMatch: true,
// not the double space in the dir name, as the slash has been removed
wantFilename: "cool job with slash/1_fob the barz.txt",
wantStepFilename: "cool job with slash/1_fob the barz.txt",
},
{
name: "job name with colon matches dir with colon removed",
@ -1531,8 +2208,9 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "cool job with colon/1_fob the barz.txt",
wantJobMatch: false,
wantStepMatch: true,
wantStepFilename: "cool job with colon/1_fob the barz.txt",
},
{
name: "Job name with really long name (over the ZIP limit)",
@ -1543,8 +2221,9 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
wantJobMatch: false,
wantStepMatch: true,
wantStepFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
},
{
name: "Job name that would be truncated by the C# server to split a grapheme",
@ -1555,8 +2234,9 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅<F09F9885>/1_Emoji Job.txt",
wantJobMatch: false,
wantStepMatch: true,
wantStepFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅<F09F9885>/1_Emoji Job.txt",
},
}
@ -1566,17 +2246,27 @@ func Test_attachRunLog(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobs := []shared.Job{tt.job}
attachRunLog(&run_log_zip_reader.Reader, []shared.Job{tt.job})
attachRunLog(&run_log_zip_reader.Reader, jobs)
t.Logf("Job details: ")
for _, step := range tt.job.Steps {
log := step.Log
logPresent := log != nil
require.Equal(t, tt.wantMatch, logPresent, "log not present")
if logPresent {
require.Equal(t, tt.wantFilename, log.Name, "Filename mismatch")
job := jobs[0]
jobLog := job.Log
jobLogPresent := jobLog != nil
require.Equal(t, tt.wantJobMatch, jobLogPresent, "job log not present")
if jobLogPresent {
require.Equal(t, tt.wantJobFilename, jobLog.Name, "job log filename mismatch")
}
for _, step := range job.Steps {
stepLog := step.Log
stepLogPresent := stepLog != nil
require.Equal(t, tt.wantStepMatch, stepLogPresent, "step log not present")
if stepLogPresent {
require.Equal(t, tt.wantStepFilename, stepLog.Name, "step log filename mismatch")
}
}
})
@ -1607,9 +2297,35 @@ sad job quux the barf log line 2
sad job quux the barf log line 3
`)
var coolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
cool job with no step logs UNKNOWN STEP log line 1
cool job with no step logs UNKNOWN STEP log line 2
cool job with no step logs UNKNOWN STEP log line 3
`)
var legacyCoolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
legacy cool job with no step logs UNKNOWN STEP log line 1
legacy cool job with no step logs UNKNOWN STEP log line 2
legacy cool job with no step logs UNKNOWN STEP log line 3
`)
var sadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
sad job with no step logs UNKNOWN STEP log line 1
sad job with no step logs UNKNOWN STEP log line 2
sad job with no step logs UNKNOWN STEP log line 3
`)
var legacySadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
legacy sad job with no step logs UNKNOWN STEP log line 1
legacy sad job with no step logs UNKNOWN STEP log line 2
legacy sad job with no step logs UNKNOWN STEP log line 3
`)
var coolJobRunLogOutput = fmt.Sprintf("%s%s", fobTheBarzLogOutput, barfTheFobLogOutput)
var sadJobRunLogOutput = fmt.Sprintf("%s%s", barfTheQuuxLogOutput, quuxTheBarfLogOutput)
var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLogOutput)
var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput)
var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput)
func TestRunLog(t *testing.T) {
t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) {