github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/run/view/view.go (about)

     1  package view
     2  
     3  import (
     4  	"archive/zip"
     5  	"bufio"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"os"
    11  	"path/filepath"
    12  	"regexp"
    13  	"sort"
    14  	"strconv"
    15  	"time"
    16  
    17  	"github.com/AlecAivazis/survey/v2"
    18  	"github.com/MakeNowJust/heredoc"
    19  	"github.com/ungtb10d/cli/v2/api"
    20  	"github.com/ungtb10d/cli/v2/internal/browser"
    21  	"github.com/ungtb10d/cli/v2/internal/ghinstance"
    22  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    23  	"github.com/ungtb10d/cli/v2/internal/text"
    24  	"github.com/ungtb10d/cli/v2/pkg/cmd/run/shared"
    25  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    26  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    27  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    28  	"github.com/spf13/cobra"
    29  )
    30  
    31  type runLogCache interface {
    32  	Exists(string) bool
    33  	Create(string, io.ReadCloser) error
    34  	Open(string) (*zip.ReadCloser, error)
    35  }
    36  
    37  type rlc struct{}
    38  
    39  func (rlc) Exists(path string) bool {
    40  	if _, err := os.Stat(path); err != nil {
    41  		return false
    42  	}
    43  	return true
    44  }
    45  func (rlc) Create(path string, content io.ReadCloser) error {
    46  	err := os.MkdirAll(filepath.Dir(path), 0755)
    47  	if err != nil {
    48  		return fmt.Errorf("could not create cache: %w", err)
    49  	}
    50  
    51  	out, err := os.Create(path)
    52  	if err != nil {
    53  		return err
    54  	}
    55  	defer out.Close()
    56  	_, err = io.Copy(out, content)
    57  	return err
    58  }
    59  func (rlc) Open(path string) (*zip.ReadCloser, error) {
    60  	return zip.OpenReader(path)
    61  }
    62  
    63  type ViewOptions struct {
    64  	HttpClient  func() (*http.Client, error)
    65  	IO          *iostreams.IOStreams
    66  	BaseRepo    func() (ghrepo.Interface, error)
    67  	Browser     browser.Browser
    68  	RunLogCache runLogCache
    69  
    70  	RunID      string
    71  	JobID      string
    72  	Verbose    bool
    73  	ExitStatus bool
    74  	Log        bool
    75  	LogFailed  bool
    76  	Web        bool
    77  
    78  	Prompt   bool
    79  	Exporter cmdutil.Exporter
    80  
    81  	Now func() time.Time
    82  }
    83  
    84  func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
    85  	opts := &ViewOptions{
    86  		IO:          f.IOStreams,
    87  		HttpClient:  f.HttpClient,
    88  		Now:         time.Now,
    89  		Browser:     f.Browser,
    90  		RunLogCache: rlc{},
    91  	}
    92  
    93  	cmd := &cobra.Command{
    94  		Use:   "view [<run-id>]",
    95  		Short: "View a summary of a workflow run",
    96  		Args:  cobra.MaximumNArgs(1),
    97  		Example: heredoc.Doc(`
    98  			# Interactively select a run to view, optionally selecting a single job
    99  			$ gh run view
   100  
   101  			# View a specific run
   102  			$ gh run view 12345
   103  
   104  			# View a specific job within a run
   105  			$ gh run view --job 456789
   106  
   107  			# View the full log for a specific job
   108  			$ gh run view --log --job 456789
   109  
   110  			# Exit non-zero if a run failed
   111  			$ gh run view 0451 --exit-status && echo "run pending or passed"
   112  		`),
   113  		RunE: func(cmd *cobra.Command, args []string) error {
   114  			// support `-R, --repo` override
   115  			opts.BaseRepo = f.BaseRepo
   116  
   117  			if len(args) == 0 && opts.JobID == "" {
   118  				if !opts.IO.CanPrompt() {
   119  					return cmdutil.FlagErrorf("run or job ID required when not running interactively")
   120  				} else {
   121  					opts.Prompt = true
   122  				}
   123  			} else if len(args) > 0 {
   124  				opts.RunID = args[0]
   125  			}
   126  
   127  			if opts.RunID != "" && opts.JobID != "" {
   128  				opts.RunID = ""
   129  				if opts.IO.CanPrompt() {
   130  					cs := opts.IO.ColorScheme()
   131  					fmt.Fprintf(opts.IO.ErrOut, "%s both run and job IDs specified; ignoring run ID\n", cs.WarningIcon())
   132  				}
   133  			}
   134  
   135  			if opts.Web && opts.Log {
   136  				return cmdutil.FlagErrorf("specify only one of --web or --log")
   137  			}
   138  
   139  			if opts.Log && opts.LogFailed {
   140  				return cmdutil.FlagErrorf("specify only one of --log or --log-failed")
   141  			}
   142  
   143  			if runF != nil {
   144  				return runF(opts)
   145  			}
   146  			return runView(opts)
   147  		},
   148  	}
   149  	cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show job steps")
   150  	// TODO should we try and expose pending via another exit code?
   151  	cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run failed")
   152  	cmd.Flags().StringVarP(&opts.JobID, "job", "j", "", "View a specific job ID from a run")
   153  	cmd.Flags().BoolVar(&opts.Log, "log", false, "View full log for either a run or specific job")
   154  	cmd.Flags().BoolVar(&opts.LogFailed, "log-failed", false, "View the log for any failed steps in a run or specific job")
   155  	cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open run in the browser")
   156  	cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.SingleRunFields)
   157  
   158  	return cmd
   159  }
   160  
   161  func runView(opts *ViewOptions) error {
   162  	httpClient, err := opts.HttpClient()
   163  	if err != nil {
   164  		return fmt.Errorf("failed to create http client: %w", err)
   165  	}
   166  	client := api.NewClientFromHTTP(httpClient)
   167  
   168  	repo, err := opts.BaseRepo()
   169  	if err != nil {
   170  		return fmt.Errorf("failed to determine base repo: %w", err)
   171  	}
   172  
   173  	jobID := opts.JobID
   174  	runID := opts.RunID
   175  	var selectedJob *shared.Job
   176  	var run *shared.Run
   177  	var jobs []shared.Job
   178  
   179  	defer opts.IO.StopProgressIndicator()
   180  
   181  	if jobID != "" {
   182  		opts.IO.StartProgressIndicator()
   183  		selectedJob, err = shared.GetJob(client, repo, jobID)
   184  		opts.IO.StopProgressIndicator()
   185  		if err != nil {
   186  			return fmt.Errorf("failed to get job: %w", err)
   187  		}
   188  		// TODO once more stuff is merged, standardize on using ints
   189  		runID = fmt.Sprintf("%d", selectedJob.RunID)
   190  	}
   191  
   192  	cs := opts.IO.ColorScheme()
   193  
   194  	if opts.Prompt {
   195  		// TODO arbitrary limit
   196  		opts.IO.StartProgressIndicator()
   197  		runs, err := shared.GetRuns(client, repo, nil, 10)
   198  		opts.IO.StopProgressIndicator()
   199  		if err != nil {
   200  			return fmt.Errorf("failed to get runs: %w", err)
   201  		}
   202  		runID, err = shared.PromptForRun(cs, runs.WorkflowRuns)
   203  		if err != nil {
   204  			return err
   205  		}
   206  	}
   207  
   208  	opts.IO.StartProgressIndicator()
   209  	run, err = shared.GetRun(client, repo, runID)
   210  	opts.IO.StopProgressIndicator()
   211  	if err != nil {
   212  		return fmt.Errorf("failed to get run: %w", err)
   213  	}
   214  
   215  	if shouldFetchJobs(opts) {
   216  		opts.IO.StartProgressIndicator()
   217  		jobs, err = shared.GetJobs(client, repo, run)
   218  		opts.IO.StopProgressIndicator()
   219  		if err != nil {
   220  			return err
   221  		}
   222  	}
   223  
   224  	if opts.Prompt && len(jobs) > 1 {
   225  		selectedJob, err = promptForJob(cs, jobs)
   226  		if err != nil {
   227  			return err
   228  		}
   229  	}
   230  
   231  	if err := opts.IO.StartPager(); err == nil {
   232  		defer opts.IO.StopPager()
   233  	} else {
   234  		fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
   235  	}
   236  
   237  	if opts.Exporter != nil {
   238  		return opts.Exporter.Write(opts.IO, run)
   239  	}
   240  
   241  	if opts.Web {
   242  		url := run.URL
   243  		if selectedJob != nil {
   244  			url = selectedJob.URL + "?check_suite_focus=true"
   245  		}
   246  		if opts.IO.IsStdoutTTY() {
   247  			fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(url))
   248  		}
   249  
   250  		return opts.Browser.Browse(url)
   251  	}
   252  
   253  	if selectedJob == nil && len(jobs) == 0 {
   254  		opts.IO.StartProgressIndicator()
   255  		jobs, err = shared.GetJobs(client, repo, run)
   256  		opts.IO.StopProgressIndicator()
   257  		if err != nil {
   258  			return fmt.Errorf("failed to get jobs: %w", err)
   259  		}
   260  	} else if selectedJob != nil {
   261  		jobs = []shared.Job{*selectedJob}
   262  	}
   263  
   264  	if opts.Log || opts.LogFailed {
   265  		if selectedJob != nil && selectedJob.Status != shared.Completed {
   266  			return fmt.Errorf("job %d is still in progress; logs will be available when it is complete", selectedJob.ID)
   267  		}
   268  
   269  		if run.Status != shared.Completed {
   270  			return fmt.Errorf("run %d is still in progress; logs will be available when it is complete", run.ID)
   271  		}
   272  
   273  		opts.IO.StartProgressIndicator()
   274  		runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run)
   275  		opts.IO.StopProgressIndicator()
   276  		if err != nil {
   277  			return fmt.Errorf("failed to get run log: %w", err)
   278  		}
   279  		defer runLogZip.Close()
   280  
   281  		attachRunLog(runLogZip, jobs)
   282  
   283  		return displayRunLog(opts.IO.Out, jobs, opts.LogFailed)
   284  	}
   285  
   286  	prNumber := ""
   287  	number, err := shared.PullRequestForRun(client, repo, *run)
   288  	if err == nil {
   289  		prNumber = fmt.Sprintf(" #%d", number)
   290  	}
   291  
   292  	var artifacts []shared.Artifact
   293  	if selectedJob == nil {
   294  		artifacts, err = shared.ListArtifacts(httpClient, repo, strconv.FormatInt(int64(run.ID), 10))
   295  		if err != nil {
   296  			return fmt.Errorf("failed to get artifacts: %w", err)
   297  		}
   298  	}
   299  
   300  	var annotations []shared.Annotation
   301  
   302  	var annotationErr error
   303  	var as []shared.Annotation
   304  	for _, job := range jobs {
   305  		as, annotationErr = shared.GetAnnotations(client, repo, job)
   306  		if annotationErr != nil {
   307  			break
   308  		}
   309  		annotations = append(annotations, as...)
   310  	}
   311  
   312  	opts.IO.StopProgressIndicator()
   313  
   314  	if annotationErr != nil {
   315  		return fmt.Errorf("failed to get annotations: %w", annotationErr)
   316  	}
   317  
   318  	out := opts.IO.Out
   319  
   320  	fmt.Fprintln(out)
   321  	fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, text.FuzzyAgo(opts.Now(), run.StartedTime()), prNumber))
   322  	fmt.Fprintln(out)
   323  
   324  	if len(jobs) == 0 && run.Conclusion == shared.Failure || run.Conclusion == shared.StartupFailure {
   325  		fmt.Fprintf(out, "%s %s\n",
   326  			cs.FailureIcon(),
   327  			cs.Bold("This run likely failed because of a workflow file issue."))
   328  
   329  		fmt.Fprintln(out)
   330  		fmt.Fprintf(out, "For more information, see: %s\n", cs.Bold(run.URL))
   331  
   332  		if opts.ExitStatus {
   333  			return cmdutil.SilentError
   334  		}
   335  		return nil
   336  	}
   337  
   338  	if selectedJob == nil {
   339  		fmt.Fprintln(out, cs.Bold("JOBS"))
   340  		fmt.Fprintln(out, shared.RenderJobs(cs, jobs, opts.Verbose))
   341  	} else {
   342  		fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true))
   343  	}
   344  
   345  	if len(annotations) > 0 {
   346  		fmt.Fprintln(out)
   347  		fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
   348  		fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations))
   349  	}
   350  
   351  	if selectedJob == nil {
   352  		if len(artifacts) > 0 {
   353  			fmt.Fprintln(out)
   354  			fmt.Fprintln(out, cs.Bold("ARTIFACTS"))
   355  			for _, a := range artifacts {
   356  				expiredBadge := ""
   357  				if a.Expired {
   358  					expiredBadge = cs.Gray(" (expired)")
   359  				}
   360  				fmt.Fprintf(out, "%s%s\n", a.Name, expiredBadge)
   361  			}
   362  		}
   363  
   364  		fmt.Fprintln(out)
   365  		if shared.IsFailureState(run.Conclusion) {
   366  			fmt.Fprintf(out, "To see what failed, try: gh run view %d --log-failed\n", run.ID)
   367  		} else if len(jobs) == 1 {
   368  			fmt.Fprintf(out, "For more information about the job, try: gh run view --job=%d\n", jobs[0].ID)
   369  		} else {
   370  			fmt.Fprintf(out, "For more information about a job, try: gh run view --job=<job-id>\n")
   371  		}
   372  		fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL)
   373  
   374  		if opts.ExitStatus && shared.IsFailureState(run.Conclusion) {
   375  			return cmdutil.SilentError
   376  		}
   377  	} else {
   378  		fmt.Fprintln(out)
   379  		if shared.IsFailureState(selectedJob.Conclusion) {
   380  			fmt.Fprintf(out, "To see the logs for the failed steps, try: gh run view --log-failed --job=%d\n", selectedJob.ID)
   381  		} else {
   382  			fmt.Fprintf(out, "To see the full job log, try: gh run view --log --job=%d\n", selectedJob.ID)
   383  		}
   384  		fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL)
   385  
   386  		if opts.ExitStatus && shared.IsFailureState(selectedJob.Conclusion) {
   387  			return cmdutil.SilentError
   388  		}
   389  	}
   390  
   391  	return nil
   392  }
   393  
   394  func shouldFetchJobs(opts *ViewOptions) bool {
   395  	if opts.Prompt {
   396  		return true
   397  	}
   398  	if opts.Exporter != nil {
   399  		for _, f := range opts.Exporter.Fields() {
   400  			if f == "jobs" {
   401  				return true
   402  			}
   403  		}
   404  	}
   405  	return false
   406  }
   407  
   408  func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) {
   409  	req, err := http.NewRequest("GET", logURL, nil)
   410  	if err != nil {
   411  		return nil, err
   412  	}
   413  
   414  	resp, err := httpClient.Do(req)
   415  	if err != nil {
   416  		return nil, err
   417  	}
   418  
   419  	if resp.StatusCode == 404 {
   420  		return nil, errors.New("log not found")
   421  	} else if resp.StatusCode != 200 {
   422  		return nil, api.HandleHTTPError(resp)
   423  	}
   424  
   425  	return resp.Body, nil
   426  }
   427  
   428  func getRunLog(cache runLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run) (*zip.ReadCloser, error) {
   429  	filename := fmt.Sprintf("run-log-%d-%d.zip", run.ID, run.StartedTime().Unix())
   430  	filepath := filepath.Join(os.TempDir(), "gh-cli-cache", filename)
   431  	if !cache.Exists(filepath) {
   432  		// Run log does not exist in cache so retrieve and store it
   433  		logURL := fmt.Sprintf("%srepos/%s/actions/runs/%d/logs",
   434  			ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), run.ID)
   435  
   436  		resp, err := getLog(httpClient, logURL)
   437  		if err != nil {
   438  			return nil, err
   439  		}
   440  		defer resp.Close()
   441  
   442  		err = cache.Create(filepath, resp)
   443  		if err != nil {
   444  			return nil, err
   445  		}
   446  	}
   447  
   448  	return cache.Open(filepath)
   449  }
   450  
   451  func promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, error) {
   452  	candidates := []string{"View all jobs in this run"}
   453  	for _, job := range jobs {
   454  		symbol, _ := shared.Symbol(cs, job.Status, job.Conclusion)
   455  		candidates = append(candidates, fmt.Sprintf("%s %s", symbol, job.Name))
   456  	}
   457  
   458  	var selected int
   459  	//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   460  	err := prompt.SurveyAskOne(&survey.Select{
   461  		Message:  "View a specific job in this run?",
   462  		Options:  candidates,
   463  		PageSize: 12,
   464  	}, &selected)
   465  	if err != nil {
   466  		return nil, err
   467  	}
   468  
   469  	if selected > 0 {
   470  		return &jobs[selected-1], nil
   471  	}
   472  
   473  	// User wants to see all jobs
   474  	return nil, nil
   475  }
   476  
   477  func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
   478  	re := fmt.Sprintf(`%s\/%d_.*\.txt`, regexp.QuoteMeta(job.Name), step.Number)
   479  	return regexp.MustCompile(re)
   480  }
   481  
   482  // This function takes a zip file of logs and a list of jobs.
   483  // Structure of zip file
   484  //
   485  //	zip/
   486  //	├── jobname1/
   487  //	│   ├── 1_stepname.txt
   488  //	│   ├── 2_anotherstepname.txt
   489  //	│   ├── 3_stepstepname.txt
   490  //	│   └── 4_laststepname.txt
   491  //	└── jobname2/
   492  //	    ├── 1_stepname.txt
   493  //	    └── 2_somestepname.txt
   494  //
   495  // It iterates through the list of jobs and trys to find the matching
   496  // log in the zip file. If the matching log is found it is attached
   497  // to the job.
   498  func attachRunLog(rlz *zip.ReadCloser, jobs []shared.Job) {
   499  	for i, job := range jobs {
   500  		for j, step := range job.Steps {
   501  			re := logFilenameRegexp(job, step)
   502  			for _, file := range rlz.File {
   503  				if re.MatchString(file.Name) {
   504  					jobs[i].Steps[j].Log = file
   505  					break
   506  				}
   507  			}
   508  		}
   509  	}
   510  }
   511  
   512  func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
   513  	for _, job := range jobs {
   514  		steps := job.Steps
   515  		sort.Sort(steps)
   516  		for _, step := range steps {
   517  			if failed && !shared.IsFailureState(step.Conclusion) {
   518  				continue
   519  			}
   520  			if step.Log == nil {
   521  				continue
   522  			}
   523  			prefix := fmt.Sprintf("%s\t%s\t", job.Name, step.Name)
   524  			f, err := step.Log.Open()
   525  			if err != nil {
   526  				return err
   527  			}
   528  			scanner := bufio.NewScanner(f)
   529  			for scanner.Scan() {
   530  				fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
   531  			}
   532  			f.Close()
   533  		}
   534  	}
   535  
   536  	return nil
   537  }