@@ -3,15 +3,19 @@ package view
33import (
44 "errors"
55 "fmt"
6+ "io"
67 "net/http"
78 "time"
89
10+ "github.com/AlecAivazis/survey/v2"
911 "github.com/MakeNowJust/heredoc"
1012 "github.com/cli/cli/api"
13+ "github.com/cli/cli/internal/ghinstance"
1114 "github.com/cli/cli/internal/ghrepo"
1215 "github.com/cli/cli/pkg/cmd/run/shared"
1316 "github.com/cli/cli/pkg/cmdutil"
1417 "github.com/cli/cli/pkg/iostreams"
18+ "github.com/cli/cli/pkg/prompt"
1519 "github.com/cli/cli/utils"
1620 "github.com/spf13/cobra"
1721)
@@ -22,8 +26,10 @@ type ViewOptions struct {
2226 BaseRepo func () (ghrepo.Interface , error )
2327
2428 RunID string
29+ JobID string
2530 Verbose bool
2631 ExitStatus bool
32+ Log bool
2733
2834 Prompt bool
2935
@@ -42,25 +48,42 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
4248 Args : cobra .MaximumNArgs (1 ),
4349 Hidden : true ,
4450 Example : heredoc .Doc (`
45- # Interactively select a run to view
51+ # Interactively select a run to view, optionally drilling down to a job
4652 $ gh run view
4753
4854 # View a specific run
49- $ gh run view 0451
55+ $ gh run view 12345
56+
57+ # View a specific job within a run
58+ $ gh run view --job 456789
59+
60+ # View the full log for a specific job
61+ $ gh run view --log --job 456789
5062
5163 # Exit non-zero if a run failed
52- $ gh run view 0451 -e && echo "job pending or passed"
64+ $ gh run view 0451 -e && echo "run pending or passed"
5365 ` ),
66+ // TODO should exit status respect only a selected job if --job is passed?
5467 RunE : func (cmd * cobra.Command , args []string ) error {
5568 // support `-R, --repo` override
5669 opts .BaseRepo = f .BaseRepo
5770
58- if len (args ) > 0 {
71+ if len (args ) == 0 && opts .JobID == "" {
72+ if ! opts .IO .CanPrompt () {
73+ return & cmdutil.FlagError {Err : errors .New ("run or job ID required when not running interactively" )}
74+ } else {
75+ opts .Prompt = true
76+ }
77+ } else if len (args ) > 0 {
5978 opts .RunID = args [0 ]
60- } else if ! opts .IO .CanPrompt () {
61- return & cmdutil.FlagError {Err : errors .New ("run ID required when not running interactively" )}
62- } else {
63- opts .Prompt = true
79+ }
80+
81+ if opts .RunID != "" && opts .JobID != "" {
82+ opts .RunID = ""
83+ if opts .IO .CanPrompt () {
84+ cs := opts .IO .ColorScheme ()
85+ fmt .Fprintf (opts .IO .ErrOut , "%s both run and job IDs specified; ignoring run ID\n " , cs .WarningIcon ())
86+ }
6487 }
6588
6689 if runF != nil {
@@ -72,28 +95,50 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
7295 cmd .Flags ().BoolVarP (& opts .Verbose , "verbose" , "v" , false , "Show job steps" )
7396 // TODO should we try and expose pending via another exit code?
7497 cmd .Flags ().BoolVar (& opts .ExitStatus , "exit-status" , false , "Exit with non-zero status if run failed" )
98+ cmd .Flags ().StringVarP (& opts .JobID , "job" , "j" , "" , "View a specific job ID from a run" )
99+ cmd .Flags ().BoolVar (& opts .Log , "log" , false , "View full log for either a run or specific job" )
75100
76101 return cmd
77102}
78103
79104func runView (opts * ViewOptions ) error {
80- c , err := opts .HttpClient ()
105+ httpClient , err := opts .HttpClient ()
81106 if err != nil {
82107 return fmt .Errorf ("failed to create http client: %w" , err )
83108 }
84- client := api .NewClientFromHTTP (c )
109+ client := api .NewClientFromHTTP (httpClient )
85110
86111 repo , err := opts .BaseRepo ()
87112 if err != nil {
88113 return fmt .Errorf ("failed to determine base repo: %w" , err )
89114 }
90115
116+ jobID := opts .JobID
91117 runID := opts .RunID
118+ var selectedJob * shared.Job
119+ var run * shared.Run
120+ var jobs []shared.Job
121+
122+ defer opts .IO .StopProgressIndicator ()
123+
124+ if jobID != "" {
125+ opts .IO .StartProgressIndicator ()
126+ selectedJob , err = getJob (client , repo , jobID )
127+ opts .IO .StopProgressIndicator ()
128+ if err != nil {
129+ return fmt .Errorf ("failed to get job: %w" , err )
130+ }
131+ // TODO once more stuff is merged, standardize on using ints
132+ runID = fmt .Sprintf ("%d" , selectedJob .RunID )
133+ }
134+
135+ cs := opts .IO .ColorScheme ()
92136
93137 if opts .Prompt {
94- cs := opts .IO .ColorScheme ()
95138 // TODO arbitrary limit
139+ opts .IO .StartProgressIndicator ()
96140 runs , err := shared .GetRuns (client , repo , 10 )
141+ opts .IO .StopProgressIndicator ()
97142 if err != nil {
98143 return fmt .Errorf ("failed to get runs: %w" , err )
99144 }
@@ -104,24 +149,71 @@ func runView(opts *ViewOptions) error {
104149 }
105150
106151 opts .IO .StartProgressIndicator ()
107- defer opts .IO .StopProgressIndicator ()
108-
109- run , err := shared .GetRun (client , repo , runID )
152+ run , err = shared .GetRun (client , repo , runID )
153+ opts .IO .StopProgressIndicator ()
110154 if err != nil {
111155 return fmt .Errorf ("failed to get run: %w" , err )
112156 }
113157
158+ if opts .Prompt {
159+ opts .IO .StartProgressIndicator ()
160+ jobs , err = shared .GetJobs (client , repo , * run )
161+ opts .IO .StopProgressIndicator ()
162+ if err != nil {
163+ return err
164+ }
165+ if len (jobs ) > 1 {
166+ selectedJob , err = promptForJob (cs , jobs )
167+ if err != nil {
168+ return err
169+ }
170+ }
171+ }
172+
173+ opts .IO .StartProgressIndicator ()
174+
175+ if opts .Log && selectedJob != nil {
176+ r , err := jobLog (httpClient , repo , selectedJob .ID )
177+ if err != nil {
178+ return err
179+ }
180+ opts .IO .StopProgressIndicator ()
181+
182+ err = opts .IO .StartPager ()
183+ if err != nil {
184+ return err
185+ }
186+ defer opts .IO .StopPager ()
187+
188+ if _ , err := io .Copy (opts .IO .Out , r ); err != nil {
189+ return fmt .Errorf ("failed to read log: %w" , err )
190+ }
191+
192+ if opts .ExitStatus && shared .IsFailureState (run .Conclusion ) {
193+ return cmdutil .SilentError
194+ }
195+
196+ return nil
197+ }
198+
199+ // TODO support --log without selectedJob
200+
201+ if selectedJob == nil && len (jobs ) == 0 {
202+ jobs , err = shared .GetJobs (client , repo , * run )
203+ opts .IO .StopProgressIndicator ()
204+ if err != nil {
205+ return fmt .Errorf ("failed to get jobs: %w" , err )
206+ }
207+ } else if selectedJob != nil {
208+ jobs = []shared.Job {* selectedJob }
209+ }
210+
114211 prNumber := ""
115212 number , err := shared .PullRequestForRun (client , repo , * run )
116213 if err == nil {
117214 prNumber = fmt .Sprintf (" #%d" , number )
118215 }
119216
120- jobs , err := shared .GetJobs (client , repo , * run )
121- if err != nil {
122- return fmt .Errorf ("failed to get jobs: %w" , err )
123- }
124-
125217 var annotations []shared.Annotation
126218
127219 var annotationErr error
@@ -135,12 +227,12 @@ func runView(opts *ViewOptions) error {
135227 }
136228
137229 opts .IO .StopProgressIndicator ()
230+
138231 if annotationErr != nil {
139232 return fmt .Errorf ("failed to get annotations: %w" , annotationErr )
140233 }
141234
142235 out := opts .IO .Out
143- cs := opts .IO .ColorScheme ()
144236
145237 ago := opts .Now ().Sub (run .CreatedAt )
146238
@@ -162,23 +254,93 @@ func runView(opts *ViewOptions) error {
162254 return nil
163255 }
164256
165- fmt .Fprintln (out , cs .Bold ("JOBS" ))
166-
167- fmt .Fprintln (out , shared .RenderJobs (cs , jobs , opts .Verbose ))
257+ if selectedJob == nil {
258+ fmt .Fprintln (out , cs .Bold ("JOBS" ))
259+ fmt .Fprintln (out , shared .RenderJobs (cs , jobs , opts .Verbose ))
260+ } else {
261+ fmt .Fprintln (out , shared .RenderJobs (cs , jobs , true ))
262+ }
168263
169264 if len (annotations ) > 0 {
170265 fmt .Fprintln (out )
171266 fmt .Fprintln (out , cs .Bold ("ANNOTATIONS" ))
172267 fmt .Fprintln (out , shared .RenderAnnotations (cs , annotations ))
173268 }
174269
175- fmt .Fprintln (out )
176- fmt .Fprintln (out , "For more information about a job, try: gh job view <job-id>" )
177- fmt .Fprintf (out , cs .Gray ("view this run on GitHub: %s\n " ), run .URL )
270+ if selectedJob == nil {
271+ fmt .Fprintln (out )
272+ fmt .Fprintln (out , "For more information about a job, try: gh run view --job=<job-id>" )
273+ // TODO note about run view --log when that exists
274+ fmt .Fprintf (out , cs .Gray ("view this run on GitHub: %s\n " ), run .URL )
275+ } else {
276+ fmt .Fprintln (out )
277+ // TODO this does not exist yet
278+ fmt .Fprintf (out , "To see the full job log, try: gh run view --log --job=%d\n " , selectedJob .ID )
279+ fmt .Fprintf (out , cs .Gray ("view this run on GitHub: %s\n " ), run .URL )
280+ }
178281
179282 if opts .ExitStatus && shared .IsFailureState (run .Conclusion ) {
180283 return cmdutil .SilentError
181284 }
182285
183286 return nil
184287}
288+
289+ func getJob (client * api.Client , repo ghrepo.Interface , jobID string ) (* shared.Job , error ) {
290+ path := fmt .Sprintf ("repos/%s/actions/jobs/%s" , ghrepo .FullName (repo ), jobID )
291+
292+ var result shared.Job
293+ err := client .REST (repo .RepoHost (), "GET" , path , nil , & result )
294+ if err != nil {
295+ return nil , err
296+ }
297+
298+ return & result , nil
299+ }
300+
301+ func jobLog (httpClient * http.Client , repo ghrepo.Interface , jobID int ) (io.ReadCloser , error ) {
302+ url := fmt .Sprintf ("%srepos/%s/actions/jobs/%d/logs" ,
303+ ghinstance .RESTPrefix (repo .RepoHost ()), ghrepo .FullName (repo ), jobID )
304+ req , err := http .NewRequest ("GET" , url , nil )
305+ if err != nil {
306+ return nil , err
307+ }
308+
309+ resp , err := httpClient .Do (req )
310+ if err != nil {
311+ return nil , err
312+ }
313+
314+ if resp .StatusCode == 404 {
315+ return nil , errors .New ("job not found" )
316+ } else if resp .StatusCode != 200 {
317+ return nil , api .HandleHTTPError (resp )
318+ }
319+
320+ return resp .Body , nil
321+ }
322+
323+ func promptForJob (cs * iostreams.ColorScheme , jobs []shared.Job ) (* shared.Job , error ) {
324+ candidates := []string {"View all jobs in this run" }
325+ for _ , job := range jobs {
326+ symbol , _ := shared .Symbol (cs , job .Status , job .Conclusion )
327+ candidates = append (candidates , fmt .Sprintf ("%s %s" , symbol , job .Name ))
328+ }
329+
330+ var selected int
331+ err := prompt .SurveyAskOne (& survey.Select {
332+ Message : "View a specific job in this run?" ,
333+ Options : candidates ,
334+ PageSize : 12 ,
335+ }, & selected )
336+ if err != nil {
337+ return nil , err
338+ }
339+
340+ if selected > 0 {
341+ return & jobs [selected - 1 ], nil
342+ }
343+
344+ // User wants to see all jobs
345+ return nil , nil
346+ }
0 commit comments