11package view
22
33import (
4+ "archive/zip"
5+ "bufio"
6+ "bytes"
47 "errors"
58 "fmt"
69 "io"
10+ "io/ioutil"
711 "net/http"
12+ "path/filepath"
13+ "sort"
814 "strconv"
15+ "strings"
916 "time"
1017
1118 "github.com/AlecAivazis/survey/v2"
@@ -25,6 +32,19 @@ type browser interface {
2532 Browse (string ) error
2633}
2734
35+ type runLog map [string ]* job
36+
37+ type job struct {
38+ name string
39+ steps []step
40+ }
41+
42+ type step struct {
43+ order int
44+ name string
45+ logs string
46+ }
47+
2848type ViewOptions struct {
2949 HttpClient func () (* http.Client , error )
3050 IO * iostreams.IOStreams
@@ -50,6 +70,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
5070 Now : time .Now ,
5171 Browser : f .Browser ,
5272 }
73+
5374 cmd := & cobra.Command {
5475 Use : "view [<run-id>]" ,
5576 Short : "View a summary of a workflow run" ,
@@ -71,7 +92,6 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
7192 # Exit non-zero if a run failed
7293 $ gh run view 0451 -e && echo "run pending or passed"
7394 ` ),
74- // TODO should exit status respect only a selected job if --job is passed?
7595 RunE : func (cmd * cobra.Command , args []string ) error {
7696 // support `-R, --repo` override
7797 opts .BaseRepo = f .BaseRepo
@@ -198,7 +218,11 @@ func runView(opts *ViewOptions) error {
198218 opts .IO .StartProgressIndicator ()
199219
200220 if opts .Log && selectedJob != nil {
201- r , err := jobLog (httpClient , repo , selectedJob .ID )
221+ if selectedJob .Status != shared .Completed {
222+ return fmt .Errorf ("job %d is still in progress; logs will be available when it is complete" , selectedJob .ID )
223+ }
224+
225+ r , err := getJobLog (httpClient , repo , selectedJob .ID )
202226 if err != nil {
203227 return err
204228 }
@@ -214,14 +238,31 @@ func runView(opts *ViewOptions) error {
214238 return fmt .Errorf ("failed to read log: %w" , err )
215239 }
216240
217- if opts .ExitStatus && shared .IsFailureState (run .Conclusion ) {
241+ if opts .ExitStatus && shared .IsFailureState (selectedJob .Conclusion ) {
218242 return cmdutil .SilentError
219243 }
220244
221245 return nil
222246 }
223247
224- // TODO support --log without selectedJob
248+ if opts .Log {
249+ if run .Status != shared .Completed {
250+ return fmt .Errorf ("run %d is still in progress; logs will be available when it is complete" , run .ID )
251+ }
252+
253+ runLogZip , err := getRunLog (httpClient , repo , run .ID )
254+ if err != nil {
255+ return fmt .Errorf ("failed to get run log: %w" , err )
256+ }
257+ opts .IO .StopProgressIndicator ()
258+
259+ runLog , err := readRunLog (runLogZip )
260+ if err != nil {
261+ return err
262+ }
263+
264+ return displayRunLog (opts .IO , runLog )
265+ }
225266
226267 if selectedJob == nil && len (jobs ) == 0 {
227268 jobs , err = shared .GetJobs (client , repo , * run )
@@ -317,15 +358,17 @@ func runView(opts *ViewOptions) error {
317358 fmt .Fprintln (out , "For more information about a job, try: gh run view --job=<job-id>" )
318359 // TODO note about run view --log when that exists
319360 fmt .Fprintf (out , cs .Gray ("view this run on GitHub: %s\n " ), run .URL )
361+ if opts .ExitStatus && shared .IsFailureState (run .Conclusion ) {
362+ return cmdutil .SilentError
363+ }
320364 } else {
321365 fmt .Fprintln (out )
322- // TODO this does not exist yet
323366 fmt .Fprintf (out , "To see the full job log, try: gh run view --log --job=%d\n " , selectedJob .ID )
324367 fmt .Fprintf (out , cs .Gray ("view this run on GitHub: %s\n " ), run .URL )
325- }
326368
327- if opts .ExitStatus && shared .IsFailureState (run .Conclusion ) {
328- return cmdutil .SilentError
369+ if opts .ExitStatus && shared .IsFailureState (selectedJob .Conclusion ) {
370+ return cmdutil .SilentError
371+ }
329372 }
330373
331374 return nil
@@ -343,10 +386,8 @@ func getJob(client *api.Client, repo ghrepo.Interface, jobID string) (*shared.Jo
343386 return & result , nil
344387}
345388
346- func jobLog (httpClient * http.Client , repo ghrepo.Interface , jobID int ) (io.ReadCloser , error ) {
347- url := fmt .Sprintf ("%srepos/%s/actions/jobs/%d/logs" ,
348- ghinstance .RESTPrefix (repo .RepoHost ()), ghrepo .FullName (repo ), jobID )
349- req , err := http .NewRequest ("GET" , url , nil )
389+ func getLog (httpClient * http.Client , logURL string ) (io.ReadCloser , error ) {
390+ req , err := http .NewRequest ("GET" , logURL , nil )
350391 if err != nil {
351392 return nil , err
352393 }
@@ -357,14 +398,26 @@ func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID int) (io.ReadC
357398 }
358399
359400 if resp .StatusCode == 404 {
360- return nil , errors .New ("job not found" )
401+ return nil , errors .New ("log not found" )
361402 } else if resp .StatusCode != 200 {
362403 return nil , api .HandleHTTPError (resp )
363404 }
364405
365406 return resp .Body , nil
366407}
367408
409+ func getRunLog (httpClient * http.Client , repo ghrepo.Interface , runID int ) (io.ReadCloser , error ) {
410+ logURL := fmt .Sprintf ("%srepos/%s/actions/runs/%d/logs" ,
411+ ghinstance .RESTPrefix (repo .RepoHost ()), ghrepo .FullName (repo ), runID )
412+ return getLog (httpClient , logURL )
413+ }
414+
415+ func getJobLog (httpClient * http.Client , repo ghrepo.Interface , jobID int ) (io.ReadCloser , error ) {
416+ logURL := fmt .Sprintf ("%srepos/%s/actions/jobs/%d/logs" ,
417+ ghinstance .RESTPrefix (repo .RepoHost ()), ghrepo .FullName (repo ), jobID )
418+ return getLog (httpClient , logURL )
419+ }
420+
368421func promptForJob (cs * iostreams.ColorScheme , jobs []shared.Job ) (* shared.Job , error ) {
369422 candidates := []string {"View all jobs in this run" }
370423 for _ , job := range jobs {
@@ -389,3 +442,106 @@ func promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, er
389442 // User wants to see all jobs
390443 return nil , nil
391444}
445+
446+ // Structure of log zip file
447+ // zip/
448+ // ├── jobname1/
449+ // │ ├── 1_stepname.txt
450+ // │ ├── 2_anotherstepname.txt
451+ // │ ├── 3_stepstepname.txt
452+ // │ └── 4_laststepname.txt
453+ // └── jobname2/
454+ // ├── 1_stepname.txt
455+ // └── 2_somestepname.txt
456+ func readRunLog (rlz io.ReadCloser ) (runLog , error ) {
457+ rl := make (runLog )
458+ defer rlz .Close ()
459+ z , err := ioutil .ReadAll (rlz )
460+ if err != nil {
461+ return rl , err
462+ }
463+
464+ zipReader , err := zip .NewReader (bytes .NewReader (z ), int64 (len (z )))
465+ if err != nil {
466+ return rl , err
467+ }
468+
469+ for _ , zipFile := range zipReader .File {
470+ dir , file := filepath .Split (zipFile .Name )
471+ ext := filepath .Ext (zipFile .Name )
472+
473+ // Skip all top level files and non-text files
474+ if dir != "" && ext == ".txt" {
475+ split := strings .Split (file , "_" )
476+ if len (split ) != 2 {
477+ return rl , errors .New ("invalid step log filename" )
478+ }
479+
480+ jobName := strings .TrimSuffix (dir , "/" )
481+ stepName := strings .TrimSuffix (split [1 ], ".txt" )
482+ stepOrder , err := strconv .Atoi (split [0 ])
483+ if err != nil {
484+ return rl , errors .New ("invalid step log filename" )
485+ }
486+
487+ stepLogs , err := readZipFile (zipFile )
488+ if err != nil {
489+ return rl , err
490+ }
491+
492+ st := step {
493+ order : stepOrder ,
494+ name : stepName ,
495+ logs : string (stepLogs ),
496+ }
497+
498+ if j , ok := rl [jobName ]; ! ok {
499+ rl [jobName ] = & job {name : jobName , steps : []step {st }}
500+ } else {
501+ j .steps = append (j .steps , st )
502+ }
503+ }
504+ }
505+
506+ return rl , nil
507+ }
508+
509+ func readZipFile (zf * zip.File ) ([]byte , error ) {
510+ f , err := zf .Open ()
511+ if err != nil {
512+ return nil , err
513+ }
514+ defer f .Close ()
515+ return ioutil .ReadAll (f )
516+ }
517+
518+ func displayRunLog (io * iostreams.IOStreams , rl runLog ) error {
519+ err := io .StartPager ()
520+ if err != nil {
521+ return err
522+ }
523+ defer io .StopPager ()
524+
525+ var jobNames []string
526+ for name := range rl {
527+ jobNames = append (jobNames , name )
528+ }
529+ sort .Strings (jobNames )
530+
531+ for _ , name := range jobNames {
532+ job := rl [name ]
533+ steps := job .steps
534+ sort .Slice (steps , func (i , j int ) bool {
535+ return steps [i ].order < steps [j ].order
536+ })
537+ for _ , step := range steps {
538+ prefix := fmt .Sprintf ("%s\t %s\t " , job .name , step .name )
539+ scanner := bufio .NewScanner (strings .NewReader (step .logs ))
540+ for scanner .Scan () {
541+ fmt .Fprintf (io .Out , "%s%s\n " , prefix , scanner .Text ())
542+ }
543+ }
544+ }
545+
546+ return nil
547+ }
0 commit comments