Skip to content

Commit b34efd3

Browse files
author
vilmibm
committed
Implement support for GitHub Actions
- add Cyanf and Grayf helpers - add gh actions - add gh run list - support base repo - grab runs - paginate - begin formatting - basic arg tests - nontty output - show elapsed - split new types into run/shared - add gh run view - basic arg tests - progress indicator - interactive selector - rendering a run - job summary - switch from templates to linewise printing - use table printer for jobs - fuzzy ago - annotations - add gh job view - interactive selector - step rendering - annotation rendering - share annotation code - log rendering (--log) - add next step hints - tweak formatting - support run view -v
1 parent 092cc4c commit b34efd3

File tree

12 files changed

+1087
-0
lines changed

12 files changed

+1087
-0
lines changed

api/queries_actions.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package api
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
9+
"github.com/cli/cli/internal/ghinstance"
10+
"github.com/cli/cli/internal/ghrepo"
11+
)
12+
13+
func (c Client) JobLog(repo ghrepo.Interface, jobID string) (io.ReadCloser, error) {
14+
url := fmt.Sprintf("%srepos/%s/actions/jobs/%s/logs",
15+
ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), jobID)
16+
req, err := http.NewRequest("GET", url, nil)
17+
if err != nil {
18+
return nil, err
19+
}
20+
21+
resp, err := c.http.Do(req)
22+
if err != nil {
23+
return nil, err
24+
}
25+
26+
if resp.StatusCode == 404 {
27+
return nil, &NotFoundError{errors.New("job not found")}
28+
} else if resp.StatusCode != 200 {
29+
return nil, HandleHTTPError(resp)
30+
}
31+
32+
return resp.Body, nil
33+
}

pkg/cmd/actions/actions.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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 acitons",
24+
Args: cobra.ExactArgs(0),
25+
Run: func(cmd *cobra.Command, args []string) {
26+
actionsRun(opts)
27+
},
28+
}
29+
30+
return cmd
31+
}
32+
33+
func actionsRun(opts ActionsOptions) {
34+
cs := opts.IO.ColorScheme()
35+
fmt.Fprint(opts.IO.Out, fmt.Sprintf(heredoc.Doc(`
36+
Welcome to GitHub Actions on the command line.
37+
38+
%s
39+
gh workflow list: List workflows in the current repository
40+
gh workflow run: Kick off a workflow run
41+
gh workflow init: Create a new workflow
42+
gh workflow check: Check a workflow file for correctness
43+
44+
%s
45+
gh run list: List recent workflow runs
46+
gh run view: View details for a given workflow run
47+
gh run watch: Watch a streaming log for a workflow run
48+
49+
%s
50+
gh job view: View details for a given job
51+
gh job run: Run a given job within a workflow
52+
`),
53+
cs.Bold("Working with workflows"),
54+
cs.Bold("Working with runs"),
55+
cs.Bold("Working with jobs within runs")))
56+
}

pkg/cmd/job/job.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
Long: "List and view the jobs of a workflow run including full logs",
14+
// TODO action annotation
15+
}
16+
cmdutil.EnableRepoOverride(cmd, f)
17+
18+
cmd.AddCommand(viewCmd.NewCmdView(f, nil))
19+
20+
return cmd
21+
}

pkg/cmd/job/view/view.go

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

pkg/cmd/root/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/cli/cli/api"
88
"github.com/cli/cli/context"
99
"github.com/cli/cli/internal/ghrepo"
10+
actionsCmd "github.com/cli/cli/pkg/cmd/actions"
1011
aliasCmd "github.com/cli/cli/pkg/cmd/alias"
1112
apiCmd "github.com/cli/cli/pkg/cmd/api"
1213
authCmd "github.com/cli/cli/pkg/cmd/auth"
@@ -15,10 +16,12 @@ import (
1516
"github.com/cli/cli/pkg/cmd/factory"
1617
gistCmd "github.com/cli/cli/pkg/cmd/gist"
1718
issueCmd "github.com/cli/cli/pkg/cmd/issue"
19+
jobCmd "github.com/cli/cli/pkg/cmd/job"
1820
prCmd "github.com/cli/cli/pkg/cmd/pr"
1921
releaseCmd "github.com/cli/cli/pkg/cmd/release"
2022
repoCmd "github.com/cli/cli/pkg/cmd/repo"
2123
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
24+
runCmd "github.com/cli/cli/pkg/cmd/run"
2225
secretCmd "github.com/cli/cli/pkg/cmd/secret"
2326
sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key"
2427
versionCmd "github.com/cli/cli/pkg/cmd/version"
@@ -79,6 +82,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
7982
cmd.AddCommand(secretCmd.NewCmdSecret(f))
8083
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
8184

85+
cmd.AddCommand(actionsCmd.NewCmdActions(f))
86+
cmd.AddCommand(runCmd.NewCmdRun(f))
87+
cmd.AddCommand(jobCmd.NewCmdJob(f))
88+
8289
// the `api` command should not inherit any extra HTTP headers
8390
bareHTTPCmdFactory := *f
8491
bareHTTPCmdFactory.HttpClient = bareHTTPClient(f, version)

0 commit comments

Comments
 (0)