github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/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/abdfnx/gh-api/api"
    11  	"github.com/abdfnx/gh-api/internal/ghrepo"
    12  	"github.com/abdfnx/gh-api/pkg/cmd/run/shared"
    13  	"github.com/abdfnx/gh-api/pkg/cmdutil"
    14  	"github.com/abdfnx/gh-api/pkg/iostreams"
    15  	"github.com/abdfnx/gh-api/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: "Watch a run until it 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  	out := opts.IO.Out
    86  	cs := opts.IO.ColorScheme()
    87  
    88  	runID := opts.RunID
    89  	var run *shared.Run
    90  
    91  	if opts.Prompt {
    92  		runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool {
    93  			return run.Status != shared.Completed
    94  		})
    95  		if err != nil {
    96  			return fmt.Errorf("failed to get runs: %w", err)
    97  		}
    98  		if len(runs) == 0 {
    99  			return fmt.Errorf("found no in progress runs to watch")
   100  		}
   101  		runID, err = shared.PromptForRun(cs, runs)
   102  		if err != nil {
   103  			return err
   104  		}
   105  		// TODO silly stopgap until dust settles and PromptForRun can just return a run
   106  		for _, r := range runs {
   107  			if fmt.Sprintf("%d", r.ID) == runID {
   108  				run = &r
   109  				break
   110  			}
   111  		}
   112  	} else {
   113  		run, err = shared.GetRun(client, repo, runID)
   114  		if err != nil {
   115  			return fmt.Errorf("failed to get run: %w", err)
   116  		}
   117  	}
   118  
   119  	if run.Status == shared.Completed {
   120  		fmt.Fprintf(out, "Run %s (%s) has already completed with '%s'\n", cs.Bold(run.Name), cs.Cyanf("%d", run.ID), run.Conclusion)
   121  		return nil
   122  	}
   123  
   124  	prNumber := ""
   125  	number, err := shared.PullRequestForRun(client, repo, *run)
   126  	if err == nil {
   127  		prNumber = fmt.Sprintf(" #%d", number)
   128  	}
   129  
   130  	opts.IO.EnableVirtualTerminalProcessing()
   131  	// clear entire screen
   132  	fmt.Fprintf(out, "\x1b[2J")
   133  
   134  	annotationCache := map[int][]shared.Annotation{}
   135  
   136  	duration, err := time.ParseDuration(fmt.Sprintf("%ds", opts.Interval))
   137  	if err != nil {
   138  		return fmt.Errorf("could not parse interval: %w", err)
   139  	}
   140  
   141  	for run.Status != shared.Completed {
   142  		run, err = renderRun(*opts, client, repo, run, prNumber, annotationCache)
   143  		if err != nil {
   144  			return err
   145  		}
   146  		time.Sleep(duration)
   147  	}
   148  
   149  	symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion)
   150  	id := cs.Cyanf("%d", run.ID)
   151  
   152  	if opts.IO.IsStdoutTTY() {
   153  		fmt.Fprintln(out)
   154  		fmt.Fprintf(out, "%s Run %s (%s) completed with '%s'\n", symbolColor(symbol), cs.Bold(run.Name), id, run.Conclusion)
   155  	}
   156  
   157  	if opts.ExitStatus && run.Conclusion != shared.Success {
   158  		return cmdutil.SilentError
   159  	}
   160  
   161  	return nil
   162  }
   163  
   164  func renderRun(opts WatchOptions, client *api.Client, repo ghrepo.Interface, run *shared.Run, prNumber string, annotationCache map[int][]shared.Annotation) (*shared.Run, error) {
   165  	out := opts.IO.Out
   166  	cs := opts.IO.ColorScheme()
   167  
   168  	var err error
   169  
   170  	run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID))
   171  	if err != nil {
   172  		return nil, fmt.Errorf("failed to get run: %w", err)
   173  	}
   174  
   175  	ago := opts.Now().Sub(run.CreatedAt)
   176  
   177  	jobs, err := shared.GetJobs(client, repo, *run)
   178  	if err != nil {
   179  		return nil, fmt.Errorf("failed to get jobs: %w", err)
   180  	}
   181  
   182  	var annotations []shared.Annotation
   183  
   184  	var annotationErr error
   185  	var as []shared.Annotation
   186  	for _, job := range jobs {
   187  		if as, ok := annotationCache[job.ID]; ok {
   188  			annotations = as
   189  			continue
   190  		}
   191  
   192  		as, annotationErr = shared.GetAnnotations(client, repo, job)
   193  		if annotationErr != nil {
   194  			break
   195  		}
   196  		annotations = append(annotations, as...)
   197  
   198  		if job.Status != shared.InProgress {
   199  			annotationCache[job.ID] = annotations
   200  		}
   201  	}
   202  
   203  	if annotationErr != nil {
   204  		return nil, fmt.Errorf("failed to get annotations: %w", annotationErr)
   205  	}
   206  
   207  	if runtime.GOOS == "windows" {
   208  		// Just clear whole screen; I wasn't able to get the nicer cursor movement thing working
   209  		fmt.Fprintf(opts.IO.Out, "\x1b[2J")
   210  	} else {
   211  		// Move cursor to 0,0
   212  		fmt.Fprint(opts.IO.Out, "\x1b[0;0H")
   213  		// Clear from cursor to bottom of screen
   214  		fmt.Fprint(opts.IO.Out, "\x1b[J")
   215  	}
   216  
   217  	fmt.Fprintln(out, cs.Boldf("Refreshing run status every %d seconds. Press Ctrl+C to quit.", opts.Interval))
   218  	fmt.Fprintln(out)
   219  	fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, utils.FuzzyAgo(ago), prNumber))
   220  	fmt.Fprintln(out)
   221  
   222  	if len(jobs) == 0 {
   223  		return run, nil
   224  	}
   225  
   226  	fmt.Fprintln(out, cs.Bold("JOBS"))
   227  
   228  	fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true))
   229  
   230  	if len(annotations) > 0 {
   231  		fmt.Fprintln(out)
   232  		fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
   233  		fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations))
   234  	}
   235  
   236  	return run, nil
   237  }