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