Merge pull request #3368 from cli/display-all-the-logs

Display all run logs
This commit is contained in:
Nate Smith 2021-04-07 20:20:52 -05:00 committed by GitHub
commit d78e215c19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 276 additions and 14 deletions

Binary file not shown.

View file

@ -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 [<run-id>]",
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=<job-id>")
// 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
}

View file

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