From b5fc794b7843f148d7ea75f49dbf7d942af4d0bb Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 5 Apr 2021 14:37:21 -0500 Subject: [PATCH] support --log for runs --- pkg/cmd/run/view/view.go | 67 +++++++++++++++++++--- pkg/cmd/run/view/view_test.go | 104 ++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 694b3713e..4ab8b2ed9 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "net/http" + "os" + "path" "time" "github.com/AlecAivazis/survey/v2" @@ -39,7 +41,8 @@ type ViewOptions struct { Prompt bool - Now func() time.Time + Now func() time.Time + CreateFile func(string) (io.Writer, error) } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -48,6 +51,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman HttpClient: f.HttpClient, Now: time.Now, Browser: f.Browser, + CreateFile: func(fullPath string) (io.Writer, error) { + return os.Create(fullPath) + }, } cmd := &cobra.Command{ Use: "view []", @@ -196,6 +202,10 @@ func runView(opts *ViewOptions) error { opts.IO.StartProgressIndicator() if opts.Log && selectedJob != nil { + 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 := jobLog(httpClient, repo, selectedJob.ID) if err != nil { return err @@ -219,7 +229,40 @@ func runView(opts *ViewOptions) error { 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) + } + + filename := fmt.Sprintf("gh-run-log-%d.zip", run.ID) + dir := os.TempDir() + fullpath := path.Join(dir, filename) + f, err := opts.CreateFile(fullpath) + if err != nil { + return fmt.Errorf("failed to open %s: %w", fullpath, err) + } + + r, err := runLog(httpClient, repo, run.ID) + if err != nil { + return fmt.Errorf("failed to get run log: %w", err) + } + + if _, err := io.Copy(f, r); err != nil { + return fmt.Errorf("failed to download log: %w", err) + } + + opts.IO.StopProgressIndicator() + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Downloaded logs to %s\n", cs.SuccessIcon(), fullpath) + } + + if opts.ExitStatus && shared.IsFailureState(run.Conclusion) { + return cmdutil.SilentError + } + + return nil + } if selectedJob == nil && len(jobs) == 0 { jobs, err = shared.GetJobs(client, repo, *run) @@ -324,10 +367,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 } @@ -338,7 +379,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) } @@ -346,6 +387,18 @@ func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID int) (io.ReadC return resp.Body, nil } +func runLog(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 jobLog(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 { diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 49eff0fa9..34c4cd56e 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "io" "io/ioutil" "net/http" "testing" @@ -158,6 +159,8 @@ func TestViewRun(t *testing.T) { wantErr bool wantOut string browsedURL string + wantWrite string + errMsg string }{ { name: "associate with PR", @@ -368,6 +371,96 @@ 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.StringResponse("pretend these bytes constitute a zip file")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(2) + as.StubOne(0) + }, + wantOut: "✓ Downloaded logs to /tmp/gh-run-log-3.zip\n", + wantWrite: "pretend these bytes constitute a zip file", + }, + { + 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.StringResponse("pretend these bytes constitute a zip file")) + }, + wantOut: "✓ Downloaded logs to /tmp/gh-run-log-3.zip\n", + wantWrite: "pretend these bytes constitute a zip file", + }, + { + 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{ @@ -502,6 +595,11 @@ func TestViewRun(t *testing.T) { return notnow } + fileBuff := bytes.Buffer{} + tt.opts.CreateFile = func(fullPath string) (io.Writer, error) { + return &fileBuff, nil + } + io, _, stdout, _ := iostreams.Test() io.SetStdoutTTY(tt.tty) tt.opts.IO = io @@ -522,6 +620,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 } @@ -533,6 +634,9 @@ func TestViewRun(t *testing.T) { if tt.browsedURL != "" { assert.Equal(t, tt.browsedURL, browser.BrowsedURL()) } + if tt.wantWrite != "" { + assert.Equal(t, tt.wantWrite, fileBuff.String()) + } reg.Verify(t) }) }