From c8e481e165271cc8238e87cc4a75e18ea1578e38 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 2 Apr 2021 12:37:01 -0500 Subject: [PATCH] gh run watch --- go.mod | 1 + pkg/cmd/run/run.go | 2 + pkg/cmd/run/watch/watch.go | 228 ++++++++++++++++++++++ pkg/cmd/run/watch/watch_test.go | 321 +++++++++++++++++++++++++++++++ pkg/iostreams/console.go | 5 + pkg/iostreams/console_windows.go | 21 ++ 6 files changed, 578 insertions(+) create mode 100644 pkg/cmd/run/watch/watch.go create mode 100644 pkg/cmd/run/watch/watch_test.go create mode 100644 pkg/iostreams/console.go create mode 100644 pkg/iostreams/console_windows.go diff --git a/go.mod b/go.mod index 0739022ca..d354aad8d 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/stretchr/testify v1.6.1 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 golang.org/x/sync v0.0.0-20190423024810-112230192c58 + golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d golang.org/x/text v0.3.4 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go index 59b6549d4..7e203c893 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -4,6 +4,7 @@ import ( cmdList "github.com/cli/cli/pkg/cmd/run/list" cmdRerun "github.com/cli/cli/pkg/cmd/run/rerun" cmdView "github.com/cli/cli/pkg/cmd/run/view" + cmdWatch "github.com/cli/cli/pkg/cmd/run/watch" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -23,6 +24,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil)) + cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil)) return cmd } diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go new file mode 100644 index 000000000..86e71e88c --- /dev/null +++ b/pkg/cmd/run/watch/watch.go @@ -0,0 +1,228 @@ +package watch + +import ( + "errors" + "fmt" + "net/http" + "runtime" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/run/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +const defaultInterval int = 3 + +type WatchOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + + RunID string + Interval int + ExitStatus bool + + Prompt bool + + Now func() time.Time +} + +func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Command { + opts := &WatchOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Now: time.Now, + } + + cmd := &cobra.Command{ + Use: "watch ", + Short: "Runs until a run completes, showing its progress", + Annotations: map[string]string{ + "IsActions": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.RunID = args[0] + } else if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("run ID required when not running interactively")} + } else { + opts.Prompt = true + } + + if runF != nil { + return runF(opts) + } + + return watchRun(opts) + }, + } + cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run fails") + cmd.Flags().IntVarP(&opts.Interval, "interval", "i", defaultInterval, "Refresh interval in seconds") + + return cmd +} + +func watchRun(opts *WatchOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repo: %w", err) + } + + runID := opts.RunID + var run *shared.Run + + if opts.Prompt { + cs := opts.IO.ColorScheme() + runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool { + return run.Status != shared.Completed + }) + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + if len(runs) == 0 { + return fmt.Errorf("found no in progress runs to watch") + } + runID, err = shared.PromptForRun(cs, runs) + if err != nil { + return err + } + // TODO silly stopgap until dust settles and PromptForRun can just return a run + for _, r := range runs { + if fmt.Sprintf("%d", r.ID) == runID { + run = &r + break + } + } + } else { + run, err = shared.GetRun(client, repo, runID) + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + } + + if run.Status == shared.Completed { + return nil + } + + prNumber := "" + number, err := shared.PullRequestForRun(client, repo, *run) + if err == nil { + prNumber = fmt.Sprintf(" #%d", number) + } + + if runtime.GOOS == "windows" { + opts.IO.EnableVirtualTerminalProcessing() + } + // clear entire screen + fmt.Fprintf(opts.IO.Out, "\x1b[2J") + + annotationCache := map[int][]shared.Annotation{} + + duration, err := time.ParseDuration(fmt.Sprintf("%ds", opts.Interval)) + if err != nil { + return fmt.Errorf("could not parse interval: %w", err) + } + + for run.Status != shared.Completed { + run, err = renderRun(*opts, client, repo, run, prNumber, annotationCache) + if err != nil { + return err + } + time.Sleep(duration) + } + + if opts.ExitStatus && run.Conclusion != shared.Success { + return cmdutil.SilentError + } + + return nil +} + +func renderRun(opts WatchOptions, client *api.Client, repo ghrepo.Interface, run *shared.Run, prNumber string, annotationCache map[int][]shared.Annotation) (*shared.Run, error) { + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + var err error + + run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID)) + if err != nil { + return run, fmt.Errorf("failed to get run: %w", err) + } + + ago := opts.Now().Sub(run.CreatedAt) + + jobs, err := shared.GetJobs(client, repo, *run) + if err != nil { + return run, fmt.Errorf("failed to get jobs: %w", err) + } + + var annotations []shared.Annotation + + var annotationErr error + var as []shared.Annotation + for _, job := range jobs { + if as, ok := annotationCache[job.ID]; ok { + annotations = as + continue + } + + as, annotationErr = shared.GetAnnotations(client, repo, job) + if annotationErr != nil { + break + } + annotations = append(annotations, as...) + + if job.Status != shared.InProgress { + annotationCache[job.ID] = annotations + } + } + + if annotationErr != nil { + return run, fmt.Errorf("failed to get annotations: %w", annotationErr) + } + + if runtime.GOOS == "windows" { + // Just clear whole screen; I wasn't able to get the nicer cursor movement thing working + fmt.Fprintf(opts.IO.Out, "\x1b[2J") + } else { + // Move cursor to 0,0 + fmt.Fprint(opts.IO.Out, "\x1b[0;0H") + // Clear from cursor to bottom of screen + fmt.Fprint(opts.IO.Out, "\x1b[J") + } + + fmt.Fprintln(out, cs.Boldf("Refreshing run status every %d seconds. Press Ctrl+C to quit.", opts.Interval)) + fmt.Fprintln(out) + fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, utils.FuzzyAgo(ago), prNumber)) + fmt.Fprintln(out) + + if len(jobs) == 0 && run.Conclusion == shared.Failure { + return run, nil + } + + fmt.Fprintln(out, cs.Bold("JOBS")) + + fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true)) + + if len(annotations) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, cs.Bold("ANNOTATIONS")) + fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations)) + } + + return run, nil +} diff --git a/pkg/cmd/run/watch/watch_test.go b/pkg/cmd/run/watch/watch_test.go new file mode 100644 index 000000000..b399dacf4 --- /dev/null +++ b/pkg/cmd/run/watch/watch_test.go @@ -0,0 +1,321 @@ +package watch + +import ( + "bytes" + "io/ioutil" + "net/http" + "runtime" + "testing" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/run/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdWatch(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants WatchOptions + wantsErr bool + }{ + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "blank tty", + tty: true, + wants: WatchOptions{ + Prompt: true, + Interval: defaultInterval, + }, + }, + { + name: "interval", + tty: true, + cli: "-i10", + wants: WatchOptions{ + Interval: 10, + Prompt: true, + }, + }, + { + name: "exit status", + cli: "1234 --exit-status", + wants: WatchOptions{ + Interval: defaultInterval, + RunID: "1234", + ExitStatus: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *WatchOptions + cmd := NewCmdWatch(f, func(opts *WatchOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + assert.Equal(t, tt.wants.RunID, gotOpts.RunID) + assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt) + assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus) + assert.Equal(t, tt.wants.Interval, gotOpts.Interval) + }) + } +} + +func TestWatchRun(t *testing.T) { + failedRunStubs := func(reg *httpmock.Registry) { + inProgressRun := shared.TestRun("more runs", 2, shared.InProgress, "") + completedRun := shared.TestRun("more runs", 2, shared.Completed, shared.Failure) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.TestRun("run", 1, shared.InProgress, ""), + inProgressRun, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(inProgressRun)) + reg.Register( + httpmock.REST("GET", "runs/2/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{}})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(completedRun)) + reg.Register( + httpmock.REST("GET", "runs/2/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.FailedJob, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"), + httpmock.JSONResponse(shared.FailedJobAnnotations)) + } + successfulRunStubs := func(reg *httpmock.Registry) { + inProgressRun := shared.TestRun("more runs", 2, shared.InProgress, "") + completedRun := shared.TestRun("more runs", 2, shared.Completed, shared.Success) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.TestRun("run", 1, shared.InProgress, ""), + inProgressRun, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(inProgressRun)) + reg.Register( + httpmock.REST("GET", "runs/2/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/runs/2"), + httpmock.JSONResponse(completedRun)) + reg.Register( + httpmock.REST("GET", "runs/2/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + }, + })) + } + + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + opts *WatchOptions + tty bool + wantErr bool + errMsg string + wantOut string + onlyWindows bool + skipWindows bool + }{ + // TODO exit status respected + { + name: "run ID provided run already completed", + opts: &WatchOptions{ + RunID: "1234", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + }, + wantOut: "", + }, + { + name: "prompt, no in progress runs", + opts: &WatchOptions{ + Prompt: true, + }, + wantErr: true, + errMsg: "found no in progress runs to watch", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.FailedRun, + shared.SuccessfulRun, + }, + })) + }, + }, + { + name: "interval respected", + skipWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + }, + httpStubs: successfulRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n✓ trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n", + }, + { + name: "interval respected, windows", + onlyWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + }, + httpStubs: successfulRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n✓ trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n", + }, + { + name: "exit status respected", + skipWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + ExitStatus: true, + }, + httpStubs: failedRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (ID 20)\n ✓ barf the quux\n X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n", + wantErr: true, + errMsg: "SilentError", + }, + { + name: "exit status respected, windows", + onlyWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + ExitStatus: true, + }, + httpStubs: failedRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n\n\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (ID 20)\n ✓ barf the quux\n X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n", + wantErr: true, + errMsg: "SilentError", + }, + } + + for _, tt := range tests { + if runtime.GOOS == "windows" { + if tt.skipWindows { + continue + } + } else if tt.onlyWindows { + continue + } + + reg := &httpmock.Registry{} + tt.httpStubs(reg) + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + tt.opts.Now = func() time.Time { + notnow, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 05:50:00") + return notnow + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(tt.tty) + tt.opts.IO = io + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + t.Run(tt.name, func(t *testing.T) { + err := watchRun(tt.opts) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.errMsg, err.Error()) + if !tt.opts.ExitStatus { + return + } + } + if !tt.opts.ExitStatus { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/iostreams/console.go b/pkg/iostreams/console.go new file mode 100644 index 000000000..6fe541508 --- /dev/null +++ b/pkg/iostreams/console.go @@ -0,0 +1,5 @@ +// +build !windows + +package iostreams + +func (s *IOStreams) EnableVirtualTerminalProcessing() {} diff --git a/pkg/iostreams/console_windows.go b/pkg/iostreams/console_windows.go new file mode 100644 index 000000000..fbe8e16d1 --- /dev/null +++ b/pkg/iostreams/console_windows.go @@ -0,0 +1,21 @@ +// +build windows + +package iostreams + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func (s *IOStreams) EnableVirtualTerminalProcessing() { + if !s.IsStdoutTTY() { + return + } + + stdout := windows.Handle(s.originalOut.(*os.File).Fd()) + + var originalMode uint32 + windows.GetConsoleMode(stdout, &originalMode) + windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) +}