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  }