diff --git a/pkg/cmd/run/view/fixtures/run_log.zip b/pkg/cmd/run/view/fixtures/run_log.zip new file mode 100644 index 000000000..d30f08893 Binary files /dev/null and b/pkg/cmd/run/view/fixtures/run_log.zip differ diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 1204d31d6..ce61d01ff 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -1,11 +1,18 @@ package view import ( + "archive/zip" + "bufio" + "bytes" "errors" "fmt" "io" + "io/ioutil" "net/http" + "path/filepath" + "sort" "strconv" + "strings" "time" "github.com/AlecAivazis/survey/v2" @@ -25,6 +32,19 @@ type browser interface { Browse(string) error } +type runLog map[string]*job + +type job struct { + name string + steps []step +} + +type step struct { + order int + name string + logs string +} + type ViewOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -50,6 +70,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman Now: time.Now, Browser: f.Browser, } + cmd := &cobra.Command{ Use: "view []", Short: "View a summary of a workflow run", @@ -71,7 +92,6 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman # Exit non-zero if a run failed $ 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 @@ -198,7 +218,11 @@ func runView(opts *ViewOptions) error { opts.IO.StartProgressIndicator() if opts.Log && selectedJob != nil { - r, err := jobLog(httpClient, repo, selectedJob.ID) + if selectedJob.Status != shared.Completed { + return fmt.Errorf("job %d is still in progress; logs will be available when it is complete", selectedJob.ID) + } + + r, err := getJobLog(httpClient, repo, selectedJob.ID) if err != nil { return err } @@ -214,14 +238,31 @@ func runView(opts *ViewOptions) error { return fmt.Errorf("failed to read log: %w", err) } - if opts.ExitStatus && shared.IsFailureState(run.Conclusion) { + if opts.ExitStatus && shared.IsFailureState(selectedJob.Conclusion) { return cmdutil.SilentError } return nil } - // TODO support --log without selectedJob + if opts.Log { + if run.Status != shared.Completed { + return fmt.Errorf("run %d is still in progress; logs will be available when it is complete", run.ID) + } + + runLogZip, err := getRunLog(httpClient, repo, run.ID) + if err != nil { + return fmt.Errorf("failed to get run log: %w", err) + } + opts.IO.StopProgressIndicator() + + runLog, err := readRunLog(runLogZip) + if err != nil { + return err + } + + return displayRunLog(opts.IO, runLog) + } if selectedJob == nil && len(jobs) == 0 { jobs, err = shared.GetJobs(client, repo, *run) @@ -317,15 +358,17 @@ func runView(opts *ViewOptions) error { 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) + if opts.ExitStatus && shared.IsFailureState(run.Conclusion) { + return cmdutil.SilentError + } } 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 + if opts.ExitStatus && shared.IsFailureState(selectedJob.Conclusion) { + return cmdutil.SilentError + } } return nil @@ -343,10 +386,8 @@ func getJob(client *api.Client, repo ghrepo.Interface, jobID string) (*shared.Jo 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) +func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) { + req, err := http.NewRequest("GET", logURL, nil) if err != nil { return nil, err } @@ -357,7 +398,7 @@ func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID int) (io.ReadC } if resp.StatusCode == 404 { - return nil, errors.New("job not found") + return nil, errors.New("log not found") } else if resp.StatusCode != 200 { return nil, api.HandleHTTPError(resp) } @@ -365,6 +406,18 @@ func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID int) (io.ReadC return resp.Body, nil } +func getRunLog(httpClient *http.Client, repo ghrepo.Interface, runID int) (io.ReadCloser, error) { + logURL := fmt.Sprintf("%srepos/%s/actions/runs/%d/logs", + ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), runID) + return getLog(httpClient, logURL) +} + +func getJobLog(httpClient *http.Client, repo ghrepo.Interface, jobID int) (io.ReadCloser, error) { + logURL := fmt.Sprintf("%srepos/%s/actions/jobs/%d/logs", + ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), jobID) + return getLog(httpClient, logURL) +} + func promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, error) { candidates := []string{"View all jobs in this run"} for _, job := range jobs { @@ -389,3 +442,106 @@ func promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, er // User wants to see all jobs return nil, nil } + +// Structure of log zip file +// zip/ +// ├── jobname1/ +// │ ├── 1_stepname.txt +// │ ├── 2_anotherstepname.txt +// │ ├── 3_stepstepname.txt +// │ └── 4_laststepname.txt +// └── jobname2/ +// ├── 1_stepname.txt +// └── 2_somestepname.txt +func readRunLog(rlz io.ReadCloser) (runLog, error) { + rl := make(runLog) + defer rlz.Close() + z, err := ioutil.ReadAll(rlz) + if err != nil { + return rl, err + } + + zipReader, err := zip.NewReader(bytes.NewReader(z), int64(len(z))) + if err != nil { + return rl, err + } + + for _, zipFile := range zipReader.File { + dir, file := filepath.Split(zipFile.Name) + ext := filepath.Ext(zipFile.Name) + + // Skip all top level files and non-text files + if dir != "" && ext == ".txt" { + split := strings.Split(file, "_") + if len(split) != 2 { + return rl, errors.New("invalid step log filename") + } + + jobName := strings.TrimSuffix(dir, "/") + stepName := strings.TrimSuffix(split[1], ".txt") + stepOrder, err := strconv.Atoi(split[0]) + if err != nil { + return rl, errors.New("invalid step log filename") + } + + stepLogs, err := readZipFile(zipFile) + if err != nil { + return rl, err + } + + st := step{ + order: stepOrder, + name: stepName, + logs: string(stepLogs), + } + + if j, ok := rl[jobName]; !ok { + rl[jobName] = &job{name: jobName, steps: []step{st}} + } else { + j.steps = append(j.steps, st) + } + } + } + + return rl, nil +} + +func readZipFile(zf *zip.File) ([]byte, error) { + f, err := zf.Open() + if err != nil { + return nil, err + } + defer f.Close() + return ioutil.ReadAll(f) +} + +func displayRunLog(io *iostreams.IOStreams, rl runLog) error { + err := io.StartPager() + if err != nil { + return err + } + defer io.StopPager() + + var jobNames []string + for name := range rl { + jobNames = append(jobNames, name) + } + sort.Strings(jobNames) + + for _, name := range jobNames { + job := rl[name] + steps := job.steps + sort.Slice(steps, func(i, j int) bool { + return steps[i].order < steps[j].order + }) + for _, step := range steps { + prefix := fmt.Sprintf("%s\t%s\t", job.name, step.name) + scanner := bufio.NewScanner(strings.NewReader(step.logs)) + for scanner.Scan() { + fmt.Fprintf(io.Out, "%s%s\n", prefix, scanner.Text()) + } + } + } + + return nil +} diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 657bd0869..067e318ea 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -149,7 +149,6 @@ func TestNewCmdView(t *testing.T) { } func TestViewRun(t *testing.T) { - tests := []struct { name string httpStubs func(*httpmock.Registry) @@ -159,6 +158,7 @@ func TestViewRun(t *testing.T) { wantErr bool wantOut string browsedURL string + errMsg string }{ { name: "associate with PR", @@ -426,6 +426,94 @@ func TestViewRun(t *testing.T) { }, wantOut: "it's a log\nfor this job\nbeautiful log\n", }, + { + name: "interactive with run 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/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(2) + as.StubOne(0) + }, + wantOut: expectedRunLogOutput, + }, + { + name: "noninteractive with run log", + tty: true, + 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", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + }, + wantOut: expectedRunLogOutput, + }, + { + name: "run log but run is not done", + tty: true, + opts: &ViewOptions{ + RunID: "2", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(shared.TestRun("in progress", 2, shared.InProgress, ""))) + }, + wantErr: true, + errMsg: "run 2 is still in progress; logs will be available when it is complete", + }, + { + name: "job log but job is not done", + tty: true, + opts: &ViewOptions{ + JobID: "20", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"), + httpmock.JSONResponse(shared.Job{ + ID: 20, + Status: shared.InProgress, + RunID: 2, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(shared.TestRun("in progress", 2, shared.InProgress, ""))) + }, + wantErr: true, + errMsg: "job 20 is still in progress; logs will be available when it is complete", + }, { name: "noninteractive with job", opts: &ViewOptions{ @@ -583,6 +671,9 @@ func TestViewRun(t *testing.T) { err := runView(tt.opts) if tt.wantErr { assert.Error(t, err) + if tt.errMsg != "" { + assert.Equal(t, tt.errMsg, err.Error()) + } if !tt.opts.ExitStatus { return } @@ -598,3 +689,18 @@ func TestViewRun(t *testing.T) { }) } } + +var expectedRunLogOutput = heredoc.Doc(` +job1 step1 log line 1 +job1 step1 log line 2 +job1 step1 log line 3 +job1 step2 log line 1 +job1 step2 log line 2 +job1 step2 log line 3 +job2 step1 log line 1 +job2 step1 log line 2 +job2 step1 log line 3 +job2 step2 log line 1 +job2 step2 log line 2 +job2 step2 log line 3 +`)