Skip to content

Commit c8e481e

Browse files
committed
gh run watch
1 parent b2e32a5 commit c8e481e

File tree

6 files changed

+578
-0
lines changed

6 files changed

+578
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ require (
3333
github.com/stretchr/testify v1.6.1
3434
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
3535
golang.org/x/sync v0.0.0-20190423024810-112230192c58
36+
golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d
3637
golang.org/x/text v0.3.4 // indirect
3738
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
3839
)

pkg/cmd/run/run.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
cmdList "github.com/cli/cli/pkg/cmd/run/list"
55
cmdRerun "github.com/cli/cli/pkg/cmd/run/rerun"
66
cmdView "github.com/cli/cli/pkg/cmd/run/view"
7+
cmdWatch "github.com/cli/cli/pkg/cmd/run/watch"
78
"github.com/cli/cli/pkg/cmdutil"
89
"github.com/spf13/cobra"
910
)
@@ -23,6 +24,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command {
2324
cmd.AddCommand(cmdList.NewCmdList(f, nil))
2425
cmd.AddCommand(cmdView.NewCmdView(f, nil))
2526
cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil))
27+
cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil))
2628

2729
return cmd
2830
}

pkg/cmd/run/watch/watch.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package watch
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
"runtime"
8+
"time"
9+
10+
"github.com/cli/cli/api"
11+
"github.com/cli/cli/internal/ghrepo"
12+
"github.com/cli/cli/pkg/cmd/run/shared"
13+
"github.com/cli/cli/pkg/cmdutil"
14+
"github.com/cli/cli/pkg/iostreams"
15+
"github.com/cli/cli/utils"
16+
"github.com/spf13/cobra"
17+
)
18+
19+
const defaultInterval int = 3
20+
21+
type WatchOptions struct {
22+
IO *iostreams.IOStreams
23+
HttpClient func() (*http.Client, error)
24+
BaseRepo func() (ghrepo.Interface, error)
25+
26+
RunID string
27+
Interval int
28+
ExitStatus bool
29+
30+
Prompt bool
31+
32+
Now func() time.Time
33+
}
34+
35+
func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Command {
36+
opts := &WatchOptions{
37+
IO: f.IOStreams,
38+
HttpClient: f.HttpClient,
39+
Now: time.Now,
40+
}
41+
42+
cmd := &cobra.Command{
43+
Use: "watch <run-selector>",
44+
Short: "Runs until a run completes, showing its progress",
45+
Annotations: map[string]string{
46+
"IsActions": "true",
47+
},
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
// support `-R, --repo` override
50+
opts.BaseRepo = f.BaseRepo
51+
52+
if len(args) > 0 {
53+
opts.RunID = args[0]
54+
} else if !opts.IO.CanPrompt() {
55+
return &cmdutil.FlagError{Err: errors.New("run ID required when not running interactively")}
56+
} else {
57+
opts.Prompt = true
58+
}
59+
60+
if runF != nil {
61+
return runF(opts)
62+
}
63+
64+
return watchRun(opts)
65+
},
66+
}
67+
cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run fails")
68+
cmd.Flags().IntVarP(&opts.Interval, "interval", "i", defaultInterval, "Refresh interval in seconds")
69+
70+
return cmd
71+
}
72+
73+
func watchRun(opts *WatchOptions) error {
74+
c, err := opts.HttpClient()
75+
if err != nil {
76+
return fmt.Errorf("failed to create http client: %w", err)
77+
}
78+
client := api.NewClientFromHTTP(c)
79+
80+
repo, err := opts.BaseRepo()
81+
if err != nil {
82+
return fmt.Errorf("failed to determine base repo: %w", err)
83+
}
84+
85+
runID := opts.RunID
86+
var run *shared.Run
87+
88+
if opts.Prompt {
89+
cs := opts.IO.ColorScheme()
90+
runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool {
91+
return run.Status != shared.Completed
92+
})
93+
if err != nil {
94+
return fmt.Errorf("failed to get runs: %w", err)
95+
}
96+
if len(runs) == 0 {
97+
return fmt.Errorf("found no in progress runs to watch")
98+
}
99+
runID, err = shared.PromptForRun(cs, runs)
100+
if err != nil {
101+
return err
102+
}
103+
// TODO silly stopgap until dust settles and PromptForRun can just return a run
104+
for _, r := range runs {
105+
if fmt.Sprintf("%d", r.ID) == runID {
106+
run = &r
107+
break
108+
}
109+
}
110+
} else {
111+
run, err = shared.GetRun(client, repo, runID)
112+
if err != nil {
113+
return fmt.Errorf("failed to get run: %w", err)
114+
}
115+
}
116+
117+
if run.Status == shared.Completed {
118+
return nil
119+
}
120+
121+
prNumber := ""
122+
number, err := shared.PullRequestForRun(client, repo, *run)
123+
if err == nil {
124+
prNumber = fmt.Sprintf(" #%d", number)
125+
}
126+
127+
if runtime.GOOS == "windows" {
128+
opts.IO.EnableVirtualTerminalProcessing()
129+
}
130+
// clear entire screen
131+
fmt.Fprintf(opts.IO.Out, "\x1b[2J")
132+
133+
annotationCache := map[int][]shared.Annotation{}
134+
135+
duration, err := time.ParseDuration(fmt.Sprintf("%ds", opts.Interval))
136+
if err != nil {
137+
return fmt.Errorf("could not parse interval: %w", err)
138+
}
139+
140+
for run.Status != shared.Completed {
141+
run, err = renderRun(*opts, client, repo, run, prNumber, annotationCache)
142+
if err != nil {
143+
return err
144+
}
145+
time.Sleep(duration)
146+
}
147+
148+
if opts.ExitStatus && run.Conclusion != shared.Success {
149+
return cmdutil.SilentError
150+
}
151+
152+
return nil
153+
}
154+
155+
func renderRun(opts WatchOptions, client *api.Client, repo ghrepo.Interface, run *shared.Run, prNumber string, annotationCache map[int][]shared.Annotation) (*shared.Run, error) {
156+
out := opts.IO.Out
157+
cs := opts.IO.ColorScheme()
158+
159+
var err error
160+
161+
run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID))
162+
if err != nil {
163+
return run, fmt.Errorf("failed to get run: %w", err)
164+
}
165+
166+
ago := opts.Now().Sub(run.CreatedAt)
167+
168+
jobs, err := shared.GetJobs(client, repo, *run)
169+
if err != nil {
170+
return run, fmt.Errorf("failed to get jobs: %w", err)
171+
}
172+
173+
var annotations []shared.Annotation
174+
175+
var annotationErr error
176+
var as []shared.Annotation
177+
for _, job := range jobs {
178+
if as, ok := annotationCache[job.ID]; ok {
179+
annotations = as
180+
continue
181+
}
182+
183+
as, annotationErr = shared.GetAnnotations(client, repo, job)
184+
if annotationErr != nil {
185+
break
186+
}
187+
annotations = append(annotations, as...)
188+
189+
if job.Status != shared.InProgress {
190+
annotationCache[job.ID] = annotations
191+
}
192+
}
193+
194+
if annotationErr != nil {
195+
return run, fmt.Errorf("failed to get annotations: %w", annotationErr)
196+
}
197+
198+
if runtime.GOOS == "windows" {
199+
// Just clear whole screen; I wasn't able to get the nicer cursor movement thing working
200+
fmt.Fprintf(opts.IO.Out, "\x1b[2J")
201+
} else {
202+
// Move cursor to 0,0
203+
fmt.Fprint(opts.IO.Out, "\x1b[0;0H")
204+
// Clear from cursor to bottom of screen
205+
fmt.Fprint(opts.IO.Out, "\x1b[J")
206+
}
207+
208+
fmt.Fprintln(out, cs.Boldf("Refreshing run status every %d seconds. Press Ctrl+C to quit.", opts.Interval))
209+
fmt.Fprintln(out)
210+
fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, utils.FuzzyAgo(ago), prNumber))
211+
fmt.Fprintln(out)
212+
213+
if len(jobs) == 0 && run.Conclusion == shared.Failure {
214+
return run, nil
215+
}
216+
217+
fmt.Fprintln(out, cs.Bold("JOBS"))
218+
219+
fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true))
220+
221+
if len(annotations) > 0 {
222+
fmt.Fprintln(out)
223+
fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
224+
fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations))
225+
}
226+
227+
return run, nil
228+
}

0 commit comments

Comments
 (0)