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 }