X Tutup
Skip to content

Commit 126b498

Browse files
author
Nate Smith
authored
Actions Support Phase 1 (cli#2923)
* Implement first round of support for GitHub Actions This commit adds: gh actions gh run list gh run view gh job view as part of our first round of actions support. These commands are unlisted and considered in beta. * review feedback * tests for exit status on job view * spinner tracks io itself * review feedback * fix PR matching * enable pager for job log viewing * add more colorf functions * add AnnotationSymbol * hide job, run * do not add method to api.Client * remove useless cargo coded copypasta
1 parent e2de02d commit 126b498

File tree

17 files changed

+2112
-4
lines changed

17 files changed

+2112
-4
lines changed

pkg/cmd/actions/actions.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package actions
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/MakeNowJust/heredoc"
7+
"github.com/cli/cli/pkg/cmdutil"
8+
"github.com/cli/cli/pkg/iostreams"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
type ActionsOptions struct {
13+
IO *iostreams.IOStreams
14+
}
15+
16+
func NewCmdActions(f *cmdutil.Factory) *cobra.Command {
17+
opts := ActionsOptions{
18+
IO: f.IOStreams,
19+
}
20+
21+
cmd := &cobra.Command{
22+
Use: "actions",
23+
Short: "Learn about working with GitHub actions",
24+
Args: cobra.ExactArgs(0),
25+
Hidden: true,
26+
Run: func(cmd *cobra.Command, args []string) {
27+
actionsRun(opts)
28+
},
29+
}
30+
31+
return cmd
32+
}
33+
34+
func actionsRun(opts ActionsOptions) {
35+
cs := opts.IO.ColorScheme()
36+
fmt.Fprint(opts.IO.Out, heredoc.Docf(`
37+
Welcome to GitHub Actions on the command line.
38+
39+
This part of gh is in beta and subject to change!
40+
41+
To follow along while we get to GA, please see this
42+
tracking issue: https://github.com/cli/cli/issues/2889
43+
44+
%s
45+
gh run list: List recent workflow runs
46+
gh run view: View details for a given workflow run
47+
48+
%s
49+
gh job view: View details for a given job
50+
`,
51+
cs.Bold("Working with runs"),
52+
cs.Bold("Working with jobs within runs")))
53+
/*
54+
fmt.Fprint(opts.IO.Out, heredoc.Docf(`
55+
Welcome to GitHub Actions on the command line.
56+
57+
%s
58+
gh workflow list: List workflows in the current repository
59+
gh workflow run: Kick off a workflow run
60+
gh workflow init: Create a new workflow
61+
gh workflow check: Check a workflow file for correctness
62+
63+
%s
64+
gh run list: List recent workflow runs
65+
gh run view: View details for a given workflow run
66+
gh run watch: Watch a streaming log for a workflow run
67+
68+
%s
69+
gh job view: View details for a given job
70+
gh job run: Run a given job within a workflow
71+
`,
72+
cs.Bold("Working with workflows"),
73+
cs.Bold("Working with runs"),
74+
cs.Bold("Working with jobs within runs")))
75+
*/
76+
}

pkg/cmd/job/job.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package job
2+
3+
import (
4+
viewCmd "github.com/cli/cli/pkg/cmd/job/view"
5+
"github.com/cli/cli/pkg/cmdutil"
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func NewCmdJob(f *cmdutil.Factory) *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "job <command>",
12+
Short: "Interact with the individual jobs of a workflow run",
13+
Hidden: true,
14+
Long: "List and view the jobs of a workflow run including full logs",
15+
// TODO action annotation
16+
}
17+
cmdutil.EnableRepoOverride(cmd, f)
18+
19+
cmd.AddCommand(viewCmd.NewCmdView(f, nil))
20+
21+
return cmd
22+
}

pkg/cmd/job/view/http.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package view
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
9+
"github.com/cli/cli/api"
10+
"github.com/cli/cli/internal/ghinstance"
11+
"github.com/cli/cli/internal/ghrepo"
12+
"github.com/cli/cli/pkg/cmd/run/shared"
13+
)
14+
15+
func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID string) (io.ReadCloser, error) {
16+
url := fmt.Sprintf("%srepos/%s/actions/jobs/%s/logs",
17+
ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), jobID)
18+
req, err := http.NewRequest("GET", url, nil)
19+
if err != nil {
20+
return nil, err
21+
}
22+
23+
resp, err := httpClient.Do(req)
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
if resp.StatusCode == 404 {
29+
return nil, errors.New("job not found")
30+
} else if resp.StatusCode != 200 {
31+
return nil, api.HandleHTTPError(resp)
32+
}
33+
34+
return resp.Body, nil
35+
}
36+
37+
func getJob(client *api.Client, repo ghrepo.Interface, jobID string) (*shared.Job, error) {
38+
path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID)
39+
40+
var result shared.Job
41+
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
return &result, nil
47+
}

pkg/cmd/job/view/view.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package view
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"time"
9+
10+
"github.com/AlecAivazis/survey/v2"
11+
"github.com/MakeNowJust/heredoc"
12+
"github.com/cli/cli/api"
13+
"github.com/cli/cli/internal/ghrepo"
14+
"github.com/cli/cli/pkg/cmd/run/shared"
15+
"github.com/cli/cli/pkg/cmdutil"
16+
"github.com/cli/cli/pkg/iostreams"
17+
"github.com/cli/cli/pkg/prompt"
18+
"github.com/cli/cli/utils"
19+
"github.com/spf13/cobra"
20+
)
21+
22+
type ViewOptions struct {
23+
HttpClient func() (*http.Client, error)
24+
IO *iostreams.IOStreams
25+
BaseRepo func() (ghrepo.Interface, error)
26+
27+
JobID string
28+
Log bool
29+
ExitStatus bool
30+
31+
Prompt bool
32+
33+
Now func() time.Time
34+
}
35+
36+
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
37+
opts := &ViewOptions{
38+
IO: f.IOStreams,
39+
HttpClient: f.HttpClient,
40+
Now: time.Now,
41+
}
42+
cmd := &cobra.Command{
43+
Use: "view [<job-id>]",
44+
Short: "View the summary or full logs of a workflow run's job",
45+
Args: cobra.MaximumNArgs(1),
46+
Hidden: true,
47+
Example: heredoc.Doc(`
48+
# Interactively select a run then job
49+
$ gh job view
50+
51+
# Just view the logs for a job
52+
$ gh job view 0451 --log
53+
54+
# Exit non-zero if a job failed
55+
$ gh job view 0451 -e && echo "job pending or passed"
56+
`),
57+
RunE: func(cmd *cobra.Command, args []string) error {
58+
// support `-R, --repo` override
59+
opts.BaseRepo = f.BaseRepo
60+
61+
if len(args) > 0 {
62+
opts.JobID = args[0]
63+
} else if !opts.IO.CanPrompt() {
64+
return &cmdutil.FlagError{Err: errors.New("job ID required when not running interactively")}
65+
} else {
66+
opts.Prompt = true
67+
}
68+
69+
if runF != nil {
70+
return runF(opts)
71+
}
72+
return runView(opts)
73+
},
74+
}
75+
cmd.Flags().BoolVarP(&opts.Log, "log", "l", false, "Print full logs for job")
76+
// TODO should we try and expose pending via another exit code?
77+
cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if job failed")
78+
79+
return cmd
80+
}
81+
82+
func runView(opts *ViewOptions) error {
83+
httpClient, err := opts.HttpClient()
84+
if err != nil {
85+
return fmt.Errorf("failed to create http client: %w", err)
86+
}
87+
client := api.NewClientFromHTTP(httpClient)
88+
89+
repo, err := opts.BaseRepo()
90+
if err != nil {
91+
return fmt.Errorf("failed to determine base repo: %w", err)
92+
}
93+
94+
out := opts.IO.Out
95+
cs := opts.IO.ColorScheme()
96+
97+
jobID := opts.JobID
98+
if opts.Prompt {
99+
runID, err := shared.PromptForRun(cs, client, repo)
100+
if err != nil {
101+
return err
102+
}
103+
// TODO I'd love to overwrite the result of the prompt since it adds visual noise but I'm not sure
104+
// the cleanest way to do that.
105+
fmt.Fprintln(out)
106+
107+
opts.IO.StartProgressIndicator()
108+
defer opts.IO.StopProgressIndicator()
109+
110+
run, err := shared.GetRun(client, repo, runID)
111+
if err != nil {
112+
return fmt.Errorf("failed to get run: %w", err)
113+
}
114+
115+
opts.IO.StopProgressIndicator()
116+
jobID, err = promptForJob(*opts, client, repo, *run)
117+
if err != nil {
118+
return err
119+
}
120+
121+
fmt.Fprintln(out)
122+
}
123+
124+
opts.IO.StartProgressIndicator()
125+
job, err := getJob(client, repo, jobID)
126+
if err != nil {
127+
return fmt.Errorf("failed to get job: %w", err)
128+
}
129+
130+
if opts.Log {
131+
r, err := jobLog(httpClient, repo, jobID)
132+
if err != nil {
133+
return err
134+
}
135+
136+
opts.IO.StopProgressIndicator()
137+
138+
err = opts.IO.StartPager()
139+
if err != nil {
140+
return err
141+
}
142+
defer opts.IO.StopPager()
143+
144+
if _, err := io.Copy(opts.IO.Out, r); err != nil {
145+
return fmt.Errorf("failed to read log: %w", err)
146+
}
147+
148+
if opts.ExitStatus && shared.IsFailureState(job.Conclusion) {
149+
return cmdutil.SilentError
150+
}
151+
152+
return nil
153+
}
154+
155+
annotations, err := shared.GetAnnotations(client, repo, *job)
156+
opts.IO.StopProgressIndicator()
157+
if err != nil {
158+
return fmt.Errorf("failed to get annotations: %w", err)
159+
}
160+
161+
elapsed := job.CompletedAt.Sub(job.StartedAt)
162+
elapsedStr := fmt.Sprintf(" in %s", elapsed)
163+
if elapsed < 0 {
164+
elapsedStr = ""
165+
}
166+
167+
symbol, symColor := shared.Symbol(cs, job.Status, job.Conclusion)
168+
169+
fmt.Fprintf(out, "%s (ID %s)\n", cs.Bold(job.Name), cs.Cyanf("%d", job.ID))
170+
fmt.Fprintf(out, "%s %s ago%s\n",
171+
symColor(symbol),
172+
utils.FuzzyAgoAbbr(opts.Now(), job.StartedAt),
173+
elapsedStr)
174+
175+
fmt.Fprintln(out)
176+
177+
for _, step := range job.Steps {
178+
stepSym, stepSymColor := shared.Symbol(cs, step.Status, step.Conclusion)
179+
fmt.Fprintf(out, "%s %s\n",
180+
stepSymColor(stepSym),
181+
step.Name)
182+
}
183+
184+
if len(annotations) > 0 {
185+
fmt.Fprintln(out)
186+
fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
187+
188+
for _, a := range annotations {
189+
fmt.Fprintf(out, "%s %s\n", shared.AnnotationSymbol(cs, a), a.Message)
190+
fmt.Fprintln(out, cs.Grayf("%s#%d\n", a.Path, a.StartLine))
191+
}
192+
}
193+
194+
fmt.Fprintln(out)
195+
fmt.Fprintf(out, "To see the full logs for this job, try: gh job view %s --log\n", jobID)
196+
fmt.Fprintf(out, cs.Gray("View this job on GitHub: %s\n"), job.URL)
197+
198+
if opts.ExitStatus && shared.IsFailureState(job.Conclusion) {
199+
return cmdutil.SilentError
200+
}
201+
202+
return nil
203+
}
204+
205+
func promptForJob(opts ViewOptions, client *api.Client, repo ghrepo.Interface, run shared.Run) (string, error) {
206+
cs := opts.IO.ColorScheme()
207+
jobs, err := shared.GetJobs(client, repo, run)
208+
if err != nil {
209+
return "", err
210+
}
211+
212+
if len(jobs) == 1 {
213+
return fmt.Sprintf("%d", jobs[0].ID), nil
214+
}
215+
216+
var selected int
217+
218+
candidates := []string{}
219+
220+
for _, job := range jobs {
221+
symbol, symColor := shared.Symbol(cs, job.Status, job.Conclusion)
222+
candidates = append(candidates, fmt.Sprintf("%s %s", symColor(symbol), job.Name))
223+
}
224+
225+
// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
226+
// become contiguous
227+
err = prompt.SurveyAskOne(&survey.Select{
228+
Message: "Select a job to view",
229+
Options: candidates,
230+
PageSize: 10,
231+
}, &selected)
232+
if err != nil {
233+
return "", err
234+
}
235+
236+
return fmt.Sprintf("%d", jobs[selected].ID), nil
237+
}

0 commit comments

Comments
 (0)
X Tutup