github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/run/watch/watch.go (about) 1 package watch 2 3 import ( 4 "bytes" 5 "fmt" 6 "io" 7 "net/http" 8 "time" 9 10 "github.com/MakeNowJust/heredoc" 11 "github.com/ungtb10d/cli/v2/api" 12 "github.com/ungtb10d/cli/v2/internal/ghrepo" 13 "github.com/ungtb10d/cli/v2/internal/text" 14 "github.com/ungtb10d/cli/v2/pkg/cmd/run/shared" 15 "github.com/ungtb10d/cli/v2/pkg/cmdutil" 16 "github.com/ungtb10d/cli/v2/pkg/iostreams" 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.FlagErrorf("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 cs := opts.IO.ColorScheme() 91 92 runID := opts.RunID 93 var run *shared.Run 94 95 if opts.Prompt { 96 runs, err := shared.GetRunsWithFilter(client, repo, nil, 10, func(run shared.Run) bool { 97 return run.Status != shared.Completed 98 }) 99 if err != nil { 100 return fmt.Errorf("failed to get runs: %w", err) 101 } 102 if len(runs) == 0 { 103 return fmt.Errorf("found no in progress runs to watch") 104 } 105 runID, err = shared.PromptForRun(cs, runs) 106 if err != nil { 107 return err 108 } 109 // TODO silly stopgap until dust settles and PromptForRun can just return a run 110 for _, r := range runs { 111 if fmt.Sprintf("%d", r.ID) == runID { 112 run = &r 113 break 114 } 115 } 116 } else { 117 run, err = shared.GetRun(client, repo, runID) 118 if err != nil { 119 return fmt.Errorf("failed to get run: %w", err) 120 } 121 } 122 123 if run.Status == shared.Completed { 124 fmt.Fprintf(opts.IO.Out, "Run %s (%s) has already completed with '%s'\n", cs.Bold(run.WorkflowName()), cs.Cyanf("%d", run.ID), run.Conclusion) 125 if opts.ExitStatus && run.Conclusion != shared.Success { 126 return cmdutil.SilentError 127 } 128 return nil 129 } 130 131 prNumber := "" 132 number, err := shared.PullRequestForRun(client, repo, *run) 133 if err == nil { 134 prNumber = fmt.Sprintf(" #%d", number) 135 } 136 137 annotationCache := map[int64][]shared.Annotation{} 138 139 duration, err := time.ParseDuration(fmt.Sprintf("%ds", opts.Interval)) 140 if err != nil { 141 return fmt.Errorf("could not parse interval: %w", err) 142 } 143 144 out := &bytes.Buffer{} 145 opts.IO.StartAlternateScreenBuffer() 146 for run.Status != shared.Completed { 147 // Write to a temporary buffer to reduce total number of fetches 148 run, err = renderRun(out, *opts, client, repo, run, prNumber, annotationCache) 149 if err != nil { 150 break 151 } 152 153 if run.Status == shared.Completed { 154 break 155 } 156 157 // If not completed, refresh the screen buffer and write the temporary buffer to stdout 158 opts.IO.RefreshScreen() 159 160 fmt.Fprintln(opts.IO.Out, cs.Boldf("Refreshing run status every %d seconds. Press Ctrl+C to quit.", opts.Interval)) 161 fmt.Fprintln(opts.IO.Out) 162 163 _, err = io.Copy(opts.IO.Out, out) 164 out.Reset() 165 166 if err != nil { 167 break 168 } 169 170 time.Sleep(duration) 171 } 172 opts.IO.StopAlternateScreenBuffer() 173 174 if err != nil { 175 return nil 176 } 177 178 // Write the last temporary buffer one last time 179 _, err = io.Copy(opts.IO.Out, out) 180 if err != nil { 181 return err 182 } 183 184 symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion) 185 id := cs.Cyanf("%d", run.ID) 186 187 if opts.IO.IsStdoutTTY() { 188 fmt.Fprintln(opts.IO.Out) 189 fmt.Fprintf(opts.IO.Out, "%s Run %s (%s) completed with '%s'\n", symbolColor(symbol), cs.Bold(run.WorkflowName()), id, run.Conclusion) 190 } 191 192 if opts.ExitStatus && run.Conclusion != shared.Success { 193 return cmdutil.SilentError 194 } 195 196 return nil 197 } 198 199 func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo.Interface, run *shared.Run, prNumber string, annotationCache map[int64][]shared.Annotation) (*shared.Run, error) { 200 cs := opts.IO.ColorScheme() 201 202 var err error 203 204 run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID)) 205 if err != nil { 206 return nil, fmt.Errorf("failed to get run: %w", err) 207 } 208 209 jobs, err := shared.GetJobs(client, repo, run) 210 if err != nil { 211 return nil, fmt.Errorf("failed to get jobs: %w", err) 212 } 213 214 var annotations []shared.Annotation 215 216 var annotationErr error 217 var as []shared.Annotation 218 for _, job := range jobs { 219 if as, ok := annotationCache[job.ID]; ok { 220 annotations = as 221 continue 222 } 223 224 as, annotationErr = shared.GetAnnotations(client, repo, job) 225 if annotationErr != nil { 226 break 227 } 228 annotations = append(annotations, as...) 229 230 if job.Status != shared.InProgress { 231 annotationCache[job.ID] = annotations 232 } 233 } 234 235 if annotationErr != nil { 236 return nil, fmt.Errorf("failed to get annotations: %w", annotationErr) 237 } 238 239 fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber)) 240 fmt.Fprintln(out) 241 242 if len(jobs) == 0 { 243 return run, nil 244 } 245 246 fmt.Fprintln(out, cs.Bold("JOBS")) 247 248 fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true)) 249 250 if len(annotations) > 0 { 251 fmt.Fprintln(out) 252 fmt.Fprintln(out, cs.Bold("ANNOTATIONS")) 253 fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations)) 254 } 255 256 return run, nil 257 }