Actions Support Phase 1 (#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
This commit is contained in:
parent
e2de02d6b0
commit
126b498e9f
17 changed files with 2112 additions and 4 deletions
76
pkg/cmd/actions/actions.go
Normal file
76
pkg/cmd/actions/actions.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type ActionsOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
}
|
||||
|
||||
func NewCmdActions(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := ActionsOptions{
|
||||
IO: f.IOStreams,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "actions",
|
||||
Short: "Learn about working with GitHub actions",
|
||||
Args: cobra.ExactArgs(0),
|
||||
Hidden: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
actionsRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func actionsRun(opts ActionsOptions) {
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprint(opts.IO.Out, heredoc.Docf(`
|
||||
Welcome to GitHub Actions on the command line.
|
||||
|
||||
This part of gh is in beta and subject to change!
|
||||
|
||||
To follow along while we get to GA, please see this
|
||||
tracking issue: https://github.com/cli/cli/issues/2889
|
||||
|
||||
%s
|
||||
gh run list: List recent workflow runs
|
||||
gh run view: View details for a given workflow run
|
||||
|
||||
%s
|
||||
gh job view: View details for a given job
|
||||
`,
|
||||
cs.Bold("Working with runs"),
|
||||
cs.Bold("Working with jobs within runs")))
|
||||
/*
|
||||
fmt.Fprint(opts.IO.Out, heredoc.Docf(`
|
||||
Welcome to GitHub Actions on the command line.
|
||||
|
||||
%s
|
||||
gh workflow list: List workflows in the current repository
|
||||
gh workflow run: Kick off a workflow run
|
||||
gh workflow init: Create a new workflow
|
||||
gh workflow check: Check a workflow file for correctness
|
||||
|
||||
%s
|
||||
gh run list: List recent workflow runs
|
||||
gh run view: View details for a given workflow run
|
||||
gh run watch: Watch a streaming log for a workflow run
|
||||
|
||||
%s
|
||||
gh job view: View details for a given job
|
||||
gh job run: Run a given job within a workflow
|
||||
`,
|
||||
cs.Bold("Working with workflows"),
|
||||
cs.Bold("Working with runs"),
|
||||
cs.Bold("Working with jobs within runs")))
|
||||
*/
|
||||
}
|
||||
22
pkg/cmd/job/job.go
Normal file
22
pkg/cmd/job/job.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package job
|
||||
|
||||
import (
|
||||
viewCmd "github.com/cli/cli/pkg/cmd/job/view"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCmdJob(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "job <command>",
|
||||
Short: "Interact with the individual jobs of a workflow run",
|
||||
Hidden: true,
|
||||
Long: "List and view the jobs of a workflow run including full logs",
|
||||
// TODO action annotation
|
||||
}
|
||||
cmdutil.EnableRepoOverride(cmd, f)
|
||||
|
||||
cmd.AddCommand(viewCmd.NewCmdView(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
47
pkg/cmd/job/view/http.go
Normal file
47
pkg/cmd/job/view/http.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/run/shared"
|
||||
)
|
||||
|
||||
func jobLog(httpClient *http.Client, repo ghrepo.Interface, jobID string) (io.ReadCloser, error) {
|
||||
url := fmt.Sprintf("%srepos/%s/actions/jobs/%s/logs",
|
||||
ghinstance.RESTPrefix(repo.RepoHost()), ghrepo.FullName(repo), jobID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, errors.New("job not found")
|
||||
} else if resp.StatusCode != 200 {
|
||||
return nil, api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func getJob(client *api.Client, repo ghrepo.Interface, jobID string) (*shared.Job, error) {
|
||||
path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID)
|
||||
|
||||
var result shared.Job
|
||||
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
237
pkg/cmd/job/view/view.go
Normal file
237
pkg/cmd/job/view/view.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type ViewOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
|
||||
JobID string
|
||||
Log bool
|
||||
ExitStatus bool
|
||||
|
||||
Prompt bool
|
||||
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
|
||||
opts := &ViewOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Now: time.Now,
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view [<job-id>]",
|
||||
Short: "View the summary or full logs of a workflow run's job",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Hidden: true,
|
||||
Example: heredoc.Doc(`
|
||||
# Interactively select a run then job
|
||||
$ gh job view
|
||||
|
||||
# Just view the logs for a job
|
||||
$ gh job view 0451 --log
|
||||
|
||||
# Exit non-zero if a job failed
|
||||
$ gh job view 0451 -e && echo "job pending or passed"
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.JobID = args[0]
|
||||
} else if !opts.IO.CanPrompt() {
|
||||
return &cmdutil.FlagError{Err: errors.New("job ID required when not running interactively")}
|
||||
} else {
|
||||
opts.Prompt = true
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runView(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&opts.Log, "log", "l", false, "Print full logs for job")
|
||||
// TODO should we try and expose pending via another exit code?
|
||||
cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if job failed")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(opts *ViewOptions) error {
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
repo, err := opts.BaseRepo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine base repo: %w", err)
|
||||
}
|
||||
|
||||
out := opts.IO.Out
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
jobID := opts.JobID
|
||||
if opts.Prompt {
|
||||
runID, err := shared.PromptForRun(cs, client, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO I'd love to overwrite the result of the prompt since it adds visual noise but I'm not sure
|
||||
// the cleanest way to do that.
|
||||
fmt.Fprintln(out)
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
run, err := shared.GetRun(client, repo, runID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get run: %w", err)
|
||||
}
|
||||
|
||||
opts.IO.StopProgressIndicator()
|
||||
jobID, err = promptForJob(*opts, client, repo, *run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(out)
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
job, err := getJob(client, repo, jobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get job: %w", err)
|
||||
}
|
||||
|
||||
if opts.Log {
|
||||
r, err := jobLog(httpClient, repo, jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
||||
err = opts.IO.StartPager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer opts.IO.StopPager()
|
||||
|
||||
if _, err := io.Copy(opts.IO.Out, r); err != nil {
|
||||
return fmt.Errorf("failed to read log: %w", err)
|
||||
}
|
||||
|
||||
if opts.ExitStatus && shared.IsFailureState(job.Conclusion) {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
annotations, err := shared.GetAnnotations(client, repo, *job)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get annotations: %w", err)
|
||||
}
|
||||
|
||||
elapsed := job.CompletedAt.Sub(job.StartedAt)
|
||||
elapsedStr := fmt.Sprintf(" in %s", elapsed)
|
||||
if elapsed < 0 {
|
||||
elapsedStr = ""
|
||||
}
|
||||
|
||||
symbol, symColor := shared.Symbol(cs, job.Status, job.Conclusion)
|
||||
|
||||
fmt.Fprintf(out, "%s (ID %s)\n", cs.Bold(job.Name), cs.Cyanf("%d", job.ID))
|
||||
fmt.Fprintf(out, "%s %s ago%s\n",
|
||||
symColor(symbol),
|
||||
utils.FuzzyAgoAbbr(opts.Now(), job.StartedAt),
|
||||
elapsedStr)
|
||||
|
||||
fmt.Fprintln(out)
|
||||
|
||||
for _, step := range job.Steps {
|
||||
stepSym, stepSymColor := shared.Symbol(cs, step.Status, step.Conclusion)
|
||||
fmt.Fprintf(out, "%s %s\n",
|
||||
stepSymColor(stepSym),
|
||||
step.Name)
|
||||
}
|
||||
|
||||
if len(annotations) > 0 {
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
|
||||
|
||||
for _, a := range annotations {
|
||||
fmt.Fprintf(out, "%s %s\n", shared.AnnotationSymbol(cs, a), a.Message)
|
||||
fmt.Fprintln(out, cs.Grayf("%s#%d\n", a.Path, a.StartLine))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintf(out, "To see the full logs for this job, try: gh job view %s --log\n", jobID)
|
||||
fmt.Fprintf(out, cs.Gray("View this job on GitHub: %s\n"), job.URL)
|
||||
|
||||
if opts.ExitStatus && shared.IsFailureState(job.Conclusion) {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptForJob(opts ViewOptions, client *api.Client, repo ghrepo.Interface, run shared.Run) (string, error) {
|
||||
cs := opts.IO.ColorScheme()
|
||||
jobs, err := shared.GetJobs(client, repo, run)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(jobs) == 1 {
|
||||
return fmt.Sprintf("%d", jobs[0].ID), nil
|
||||
}
|
||||
|
||||
var selected int
|
||||
|
||||
candidates := []string{}
|
||||
|
||||
for _, job := range jobs {
|
||||
symbol, symColor := shared.Symbol(cs, job.Status, job.Conclusion)
|
||||
candidates = append(candidates, fmt.Sprintf("%s %s", symColor(symbol), job.Name))
|
||||
}
|
||||
|
||||
// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
|
||||
// become contiguous
|
||||
err = prompt.SurveyAskOne(&survey.Select{
|
||||
Message: "Select a job to view",
|
||||
Options: candidates,
|
||||
PageSize: 10,
|
||||
}, &selected)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d", jobs[selected].ID), nil
|
||||
}
|
||||
351
pkg/cmd/job/view/view_test.go
Normal file
351
pkg/cmd/job/view/view_test.go
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdView(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wants ViewOptions
|
||||
wantsErr bool
|
||||
tty bool
|
||||
}{
|
||||
{
|
||||
name: "blank tty",
|
||||
tty: true,
|
||||
wants: ViewOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blank nontty",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "nontty jobID",
|
||||
cli: "1234",
|
||||
wants: ViewOptions{
|
||||
JobID: "1234",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "log tty",
|
||||
tty: true,
|
||||
cli: "--log",
|
||||
wants: ViewOptions{
|
||||
Prompt: true,
|
||||
Log: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "log nontty",
|
||||
cli: "--log 1234",
|
||||
wants: ViewOptions{
|
||||
JobID: "1234",
|
||||
Log: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exit status",
|
||||
cli: "--exit-status 1234",
|
||||
wants: ViewOptions{
|
||||
JobID: "1234",
|
||||
ExitStatus: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, _, _ := iostreams.Test()
|
||||
io.SetStdinTTY(tt.tty)
|
||||
io.SetStdoutTTY(tt.tty)
|
||||
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *ViewOptions
|
||||
cmd := NewCmdView(f, func(opts *ViewOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(ioutil.Discard)
|
||||
cmd.SetErr(ioutil.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wants.JobID, gotOpts.JobID)
|
||||
assert.Equal(t, tt.wants.Log, gotOpts.Log)
|
||||
assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt)
|
||||
assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunView(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *ViewOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
askStubs func(*prompt.AskStubber)
|
||||
tty bool
|
||||
wantErr bool
|
||||
wantOut string
|
||||
}{
|
||||
{
|
||||
name: "exit status respected with --log",
|
||||
opts: &ViewOptions{
|
||||
JobID: "20",
|
||||
ExitStatus: true,
|
||||
Log: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"),
|
||||
httpmock.JSONResponse(shared.FailedJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20/logs"),
|
||||
httpmock.StringResponse("it's a log\nfor this job\nbeautiful log\n"))
|
||||
},
|
||||
wantErr: true,
|
||||
wantOut: "it's a log\nfor this job\nbeautiful log\n",
|
||||
},
|
||||
{
|
||||
name: "exit status respected",
|
||||
opts: &ViewOptions{
|
||||
JobID: "20",
|
||||
ExitStatus: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"),
|
||||
httpmock.JSONResponse(shared.FailedJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
|
||||
httpmock.JSONResponse(shared.FailedJobAnnotations))
|
||||
},
|
||||
wantErr: true,
|
||||
wantOut: "sad job (ID 20)\nX 59m ago in 4m34s\n\n✓ barf the quux\nX quux the barf\n\nANNOTATIONS\nX the job is sad\nblaze.py#420\n\n\nTo see the full logs for this job, try: gh job view 20 --log\nView this job on GitHub: jobs/20\n",
|
||||
},
|
||||
{
|
||||
name: "interactive flow, multi-job",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
shared.FailedJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
|
||||
httpmock.JSONResponse(shared.SuccessfulJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
},
|
||||
askStubs: func(as *prompt.AskStubber) {
|
||||
as.StubOne(2)
|
||||
as.StubOne(0)
|
||||
},
|
||||
wantOut: "\n\ncool job (ID 10)\n✓ 59m ago in 4m34s\n\n✓ fob the barz\n✓ barz the fob\n\nTo see the full logs for this job, try: gh job view 10 --log\nView this job on GitHub: jobs/10\n",
|
||||
},
|
||||
{
|
||||
name: "interactive, run has only one job",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
|
||||
httpmock.JSONResponse(shared.SuccessfulJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
},
|
||||
askStubs: func(as *prompt.AskStubber) {
|
||||
as.StubOne(2)
|
||||
},
|
||||
wantOut: "\n\ncool job (ID 10)\n✓ 59m ago in 4m34s\n\n✓ fob the barz\n✓ barz the fob\n\nTo see the full logs for this job, try: gh job view 10 --log\nView this job on GitHub: jobs/10\n",
|
||||
},
|
||||
{
|
||||
name: "interactive with log",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
Prompt: true,
|
||||
Log: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
|
||||
httpmock.JSONResponse(shared.SuccessfulJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"),
|
||||
httpmock.StringResponse("it's a log\nfor this job\nbeautiful log\n"))
|
||||
},
|
||||
askStubs: func(as *prompt.AskStubber) {
|
||||
as.StubOne(2)
|
||||
},
|
||||
wantOut: "\n\nit's a log\nfor this job\nbeautiful log\n",
|
||||
},
|
||||
{
|
||||
name: "noninteractive with log",
|
||||
opts: &ViewOptions{
|
||||
JobID: "10",
|
||||
Prompt: false,
|
||||
Log: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
|
||||
httpmock.JSONResponse(shared.SuccessfulJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10/logs"),
|
||||
httpmock.StringResponse("it's a log\nfor this job\nbeautiful log\n"))
|
||||
},
|
||||
wantOut: "it's a log\nfor this job\nbeautiful log\n",
|
||||
},
|
||||
{
|
||||
name: "noninteractive",
|
||||
opts: &ViewOptions{
|
||||
JobID: "10",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/10"),
|
||||
httpmock.JSONResponse(shared.SuccessfulJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
},
|
||||
wantOut: "cool job (ID 10)\n✓ 59m ago in 4m34s\n\n✓ fob the barz\n✓ barz the fob\n\nTo see the full logs for this job, try: gh job view 10 --log\nView this job on GitHub: jobs/10\n",
|
||||
},
|
||||
{
|
||||
name: "shows annotations for failed job",
|
||||
opts: &ViewOptions{
|
||||
JobID: "20",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"),
|
||||
httpmock.JSONResponse(shared.FailedJob))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
|
||||
httpmock.JSONResponse(shared.FailedJobAnnotations))
|
||||
},
|
||||
wantOut: "sad job (ID 20)\nX 59m ago in 4m34s\n\n✓ barf the quux\nX quux the barf\n\nANNOTATIONS\nX the job is sad\nblaze.py#420\n\n\nTo see the full logs for this job, try: gh job view 20 --log\nView this job on GitHub: jobs/20\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
reg := &httpmock.Registry{}
|
||||
tt.httpStubs(reg)
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
|
||||
tt.opts.Now = func() time.Time {
|
||||
notnow, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 05:50:00")
|
||||
return notnow
|
||||
}
|
||||
|
||||
io, _, stdout, _ := iostreams.Test()
|
||||
io.SetStdoutTTY(tt.tty)
|
||||
tt.opts.IO = io
|
||||
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("OWNER/REPO")
|
||||
}
|
||||
|
||||
as, teardown := prompt.InitAskStubber()
|
||||
defer teardown()
|
||||
if tt.askStubs != nil {
|
||||
tt.askStubs(as)
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runView(tt.opts)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if !tt.opts.ExitStatus {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !tt.opts.ExitStatus {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -99,9 +99,9 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin
|
|||
fmt.Fprint(&b, formatCommentStatus(cs, comment.Status()))
|
||||
}
|
||||
if comment.Association() != "NONE" {
|
||||
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.Title(strings.ToLower(comment.Association())))))
|
||||
fmt.Fprint(&b, cs.Boldf(" (%s)", strings.Title(strings.ToLower(comment.Association()))))
|
||||
}
|
||||
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created()))))
|
||||
fmt.Fprint(&b, cs.Boldf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created())))
|
||||
if comment.IsEdited() {
|
||||
fmt.Fprint(&b, cs.Bold(" • Edited"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
|
|||
if checks.Failing == checks.Total {
|
||||
summary = cs.Red("× All checks failing")
|
||||
} else {
|
||||
summary = cs.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total))
|
||||
summary = cs.Redf("× %d/%d checks failing", checks.Failing, checks.Total)
|
||||
}
|
||||
} else if checks.Pending > 0 {
|
||||
summary = cs.Yellow("- Checks pending")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/context"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
actionsCmd "github.com/cli/cli/pkg/cmd/actions"
|
||||
aliasCmd "github.com/cli/cli/pkg/cmd/alias"
|
||||
apiCmd "github.com/cli/cli/pkg/cmd/api"
|
||||
authCmd "github.com/cli/cli/pkg/cmd/auth"
|
||||
|
|
@ -15,10 +16,12 @@ import (
|
|||
"github.com/cli/cli/pkg/cmd/factory"
|
||||
gistCmd "github.com/cli/cli/pkg/cmd/gist"
|
||||
issueCmd "github.com/cli/cli/pkg/cmd/issue"
|
||||
jobCmd "github.com/cli/cli/pkg/cmd/job"
|
||||
prCmd "github.com/cli/cli/pkg/cmd/pr"
|
||||
releaseCmd "github.com/cli/cli/pkg/cmd/release"
|
||||
repoCmd "github.com/cli/cli/pkg/cmd/repo"
|
||||
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
|
||||
runCmd "github.com/cli/cli/pkg/cmd/run"
|
||||
secretCmd "github.com/cli/cli/pkg/cmd/secret"
|
||||
sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key"
|
||||
versionCmd "github.com/cli/cli/pkg/cmd/version"
|
||||
|
|
@ -79,6 +82,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
|
|||
cmd.AddCommand(secretCmd.NewCmdSecret(f))
|
||||
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
|
||||
|
||||
cmd.AddCommand(actionsCmd.NewCmdActions(f))
|
||||
cmd.AddCommand(runCmd.NewCmdRun(f))
|
||||
cmd.AddCommand(jobCmd.NewCmdJob(f))
|
||||
|
||||
// the `api` command should not inherit any extra HTTP headers
|
||||
bareHTTPCmdFactory := *f
|
||||
bareHTTPCmdFactory.HttpClient = bareHTTPClient(f, version)
|
||||
|
|
|
|||
136
pkg/cmd/run/list/list.go
Normal file
136
pkg/cmd/run/list/list.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLimit = 10
|
||||
)
|
||||
|
||||
type ListOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
HttpClient func() (*http.Client, error)
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
|
||||
PlainOutput bool
|
||||
|
||||
Limit int
|
||||
}
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
opts := &ListOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List recent workflow runs",
|
||||
Args: cobra.NoArgs,
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
terminal := opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY()
|
||||
opts.PlainOutput = !terminal
|
||||
|
||||
if opts.Limit < 1 {
|
||||
return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.Limit)}
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return listRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of runs to fetch")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listRun(opts *ListOptions) error {
|
||||
baseRepo, err := opts.BaseRepo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine base repo: %w", err)
|
||||
}
|
||||
|
||||
c, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
runs, err := shared.GetRuns(client, baseRepo, opts.Limit)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get runs: %w", err)
|
||||
}
|
||||
|
||||
tp := utils.NewTablePrinter(opts.IO)
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
if len(runs) == 0 {
|
||||
if !opts.PlainOutput {
|
||||
fmt.Fprintln(opts.IO.ErrOut, "No runs found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
out := opts.IO.Out
|
||||
|
||||
for _, run := range runs {
|
||||
if opts.PlainOutput {
|
||||
tp.AddField(string(run.Status), nil, nil)
|
||||
tp.AddField(string(run.Conclusion), nil, nil)
|
||||
} else {
|
||||
symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion)
|
||||
tp.AddField(symbol, nil, symbolColor)
|
||||
}
|
||||
|
||||
tp.AddField(run.CommitMsg(), nil, cs.Bold)
|
||||
|
||||
tp.AddField(run.Name, nil, nil)
|
||||
tp.AddField(run.HeadBranch, nil, cs.Bold)
|
||||
tp.AddField(string(run.Event), nil, nil)
|
||||
|
||||
if opts.PlainOutput {
|
||||
elapsed := run.UpdatedAt.Sub(run.CreatedAt)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
tp.AddField(elapsed.String(), nil, nil)
|
||||
}
|
||||
|
||||
tp.AddField(fmt.Sprintf("%d", run.ID), nil, cs.Cyan)
|
||||
|
||||
tp.EndRow()
|
||||
}
|
||||
|
||||
err = tp.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.PlainOutput {
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "For details on a run, try: gh run view <run-id>")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
202
pkg/cmd/run/list/list_test.go
Normal file
202
pkg/cmd/run/list/list_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
tty bool
|
||||
wants ListOptions
|
||||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "blank",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "limit",
|
||||
cli: "--limit 100",
|
||||
wants: ListOptions{
|
||||
Limit: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad limit",
|
||||
cli: "--limit hi",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, _, _ := iostreams.Test()
|
||||
io.SetStdinTTY(tt.tty)
|
||||
io.SetStdoutTTY(tt.tty)
|
||||
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(ioutil.Discard)
|
||||
cmd.SetErr(ioutil.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wants.Limit, gotOpts.Limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *ListOptions
|
||||
wantOut string
|
||||
wantErrOut string
|
||||
stubs func(*httpmock.Registry)
|
||||
nontty bool
|
||||
}{
|
||||
{
|
||||
name: "blank tty",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
},
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
},
|
||||
wantOut: "X cool commit timed out trunk push 1\n- cool commit in progress trunk push 2\n✓ cool commit successful trunk push 3\n✓ cool commit cancelled trunk push 4\nX cool commit failed trunk push 1234\n✓ cool commit neutral trunk push 6\n✓ cool commit skipped trunk push 7\n- cool commit requested trunk push 8\n- cool commit queued trunk push 9\nX cool commit stale trunk push 10\n\nFor details on a run, try: gh run view <run-id>\n",
|
||||
},
|
||||
{
|
||||
name: "blank nontty",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
PlainOutput: true,
|
||||
},
|
||||
nontty: true,
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
},
|
||||
wantOut: "completed\ttimed_out\tcool commit\ttimed out\ttrunk\tpush\t4m34s\t1\nin_progress\t\tcool commit\tin progress\ttrunk\tpush\t4m34s\t2\ncompleted\tsuccess\tcool commit\tsuccessful\ttrunk\tpush\t4m34s\t3\ncompleted\tcancelled\tcool commit\tcancelled\ttrunk\tpush\t4m34s\t4\ncompleted\tfailure\tcool commit\tfailed\ttrunk\tpush\t4m34s\t1234\ncompleted\tneutral\tcool commit\tneutral\ttrunk\tpush\t4m34s\t6\ncompleted\tskipped\tcool commit\tskipped\ttrunk\tpush\t4m34s\t7\nrequested\t\tcool commit\trequested\ttrunk\tpush\t4m34s\t8\nqueued\t\tcool commit\tqueued\ttrunk\tpush\t4m34s\t9\ncompleted\tstale\tcool commit\tstale\ttrunk\tpush\t4m34s\t10\n",
|
||||
},
|
||||
{
|
||||
name: "pagination",
|
||||
opts: &ListOptions{
|
||||
Limit: 101,
|
||||
},
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
runID := 0
|
||||
runs := []shared.Run{}
|
||||
for runID < 103 {
|
||||
runs = append(runs, shared.TestRun(fmt.Sprintf("%d", runID), runID, shared.InProgress, ""))
|
||||
runID++
|
||||
}
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: runs[0:100],
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: runs[100:],
|
||||
}))
|
||||
},
|
||||
wantOut: longRunOutput,
|
||||
},
|
||||
{
|
||||
name: "no results nontty",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
PlainOutput: true,
|
||||
},
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
nontty: true,
|
||||
wantOut: "",
|
||||
},
|
||||
{
|
||||
name: "no results tty",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
},
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantOut: "",
|
||||
wantErrOut: "No runs found\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
tt.stubs(reg)
|
||||
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
io.SetStdoutTTY(!tt.nontty)
|
||||
tt.opts.IO = io
|
||||
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("OWNER/REPO")
|
||||
}
|
||||
|
||||
err := listRun(tt.opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
assert.Equal(t, tt.wantErrOut, stderr.String())
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const longRunOutput = "- cool commit 0 trunk push 0\n- cool commit 1 trunk push 1\n- cool commit 2 trunk push 2\n- cool commit 3 trunk push 3\n- cool commit 4 trunk push 4\n- cool commit 5 trunk push 5\n- cool commit 6 trunk push 6\n- cool commit 7 trunk push 7\n- cool commit 8 trunk push 8\n- cool commit 9 trunk push 9\n- cool commit 10 trunk push 10\n- cool commit 11 trunk push 11\n- cool commit 12 trunk push 12\n- cool commit 13 trunk push 13\n- cool commit 14 trunk push 14\n- cool commit 15 trunk push 15\n- cool commit 16 trunk push 16\n- cool commit 17 trunk push 17\n- cool commit 18 trunk push 18\n- cool commit 19 trunk push 19\n- cool commit 20 trunk push 20\n- cool commit 21 trunk push 21\n- cool commit 22 trunk push 22\n- cool commit 23 trunk push 23\n- cool commit 24 trunk push 24\n- cool commit 25 trunk push 25\n- cool commit 26 trunk push 26\n- cool commit 27 trunk push 27\n- cool commit 28 trunk push 28\n- cool commit 29 trunk push 29\n- cool commit 30 trunk push 30\n- cool commit 31 trunk push 31\n- cool commit 32 trunk push 32\n- cool commit 33 trunk push 33\n- cool commit 34 trunk push 34\n- cool commit 35 trunk push 35\n- cool commit 36 trunk push 36\n- cool commit 37 trunk push 37\n- cool commit 38 trunk push 38\n- cool commit 39 trunk push 39\n- cool commit 40 trunk push 40\n- cool commit 41 trunk push 41\n- cool commit 42 trunk push 42\n- cool commit 43 trunk push 43\n- cool commit 44 trunk push 44\n- cool commit 45 trunk push 45\n- cool commit 46 trunk push 46\n- cool commit 47 trunk push 47\n- cool commit 48 trunk push 48\n- cool commit 49 trunk push 49\n- cool commit 50 trunk push 50\n- cool commit 51 trunk push 51\n- cool commit 52 trunk push 52\n- cool commit 53 trunk push 53\n- cool commit 54 trunk push 54\n- cool commit 55 trunk push 55\n- cool commit 56 trunk push 56\n- cool commit 57 trunk push 57\n- cool commit 58 trunk push 58\n- cool commit 59 trunk push 59\n- cool commit 60 trunk push 60\n- cool commit 61 trunk push 61\n- cool commit 62 trunk push 62\n- cool commit 63 trunk push 63\n- cool commit 64 trunk push 64\n- cool commit 65 trunk push 65\n- cool commit 66 trunk push 66\n- cool commit 67 trunk push 67\n- cool commit 68 trunk push 68\n- cool commit 69 trunk push 69\n- cool commit 70 trunk push 70\n- cool commit 71 trunk push 71\n- cool commit 72 trunk push 72\n- cool commit 73 trunk push 73\n- cool commit 74 trunk push 74\n- cool commit 75 trunk push 75\n- cool commit 76 trunk push 76\n- cool commit 77 trunk push 77\n- cool commit 78 trunk push 78\n- cool commit 79 trunk push 79\n- cool commit 80 trunk push 80\n- cool commit 81 trunk push 81\n- cool commit 82 trunk push 82\n- cool commit 83 trunk push 83\n- cool commit 84 trunk push 84\n- cool commit 85 trunk push 85\n- cool commit 86 trunk push 86\n- cool commit 87 trunk push 87\n- cool commit 88 trunk push 88\n- cool commit 89 trunk push 89\n- cool commit 90 trunk push 90\n- cool commit 91 trunk push 91\n- cool commit 92 trunk push 92\n- cool commit 93 trunk push 93\n- cool commit 94 trunk push 94\n- cool commit 95 trunk push 95\n- cool commit 96 trunk push 96\n- cool commit 97 trunk push 97\n- cool commit 98 trunk push 98\n- cool commit 99 trunk push 99\n- cool commit 100 trunk push 100\n\nFor details on a run, try: gh run view <run-id>\n"
|
||||
25
pkg/cmd/run/run.go
Normal file
25
pkg/cmd/run/run.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package run
|
||||
|
||||
import (
|
||||
cmdList "github.com/cli/cli/pkg/cmd/run/list"
|
||||
cmdView "github.com/cli/cli/pkg/cmd/run/view"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCmdRun(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "run <command>",
|
||||
Short: "View details about workflow runs",
|
||||
Hidden: true,
|
||||
Long: "List, view, and watch recent workflow runs from GitHub Actions.",
|
||||
// TODO i'd like to have all the actions commands sorted into their own zone which i think will
|
||||
// require a new annotation
|
||||
}
|
||||
cmdutil.EnableRepoOverride(cmd, f)
|
||||
|
||||
cmd.AddCommand(cmdList.NewCmdList(f, nil))
|
||||
cmd.AddCommand(cmdView.NewCmdView(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
272
pkg/cmd/run/shared/shared.go
Normal file
272
pkg/cmd/run/shared/shared.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
)
|
||||
|
||||
const (
|
||||
// Run statuses
|
||||
Queued Status = "queued"
|
||||
Completed Status = "completed"
|
||||
InProgress Status = "in_progress"
|
||||
Requested Status = "requested"
|
||||
Waiting Status = "waiting"
|
||||
|
||||
// Run conclusions
|
||||
ActionRequired Conclusion = "action_required"
|
||||
Cancelled Conclusion = "cancelled"
|
||||
Failure Conclusion = "failure"
|
||||
Neutral Conclusion = "neutral"
|
||||
Skipped Conclusion = "skipped"
|
||||
Stale Conclusion = "stale"
|
||||
StartupFailure Conclusion = "startup_failure"
|
||||
Success Conclusion = "success"
|
||||
TimedOut Conclusion = "timed_out"
|
||||
|
||||
AnnotationFailure Level = "failure"
|
||||
AnnotationWarning Level = "warning"
|
||||
)
|
||||
|
||||
type Status string
|
||||
type Conclusion string
|
||||
type Level string
|
||||
|
||||
type Run struct {
|
||||
Name string
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Status Status
|
||||
Conclusion Conclusion
|
||||
Event string
|
||||
ID int
|
||||
HeadBranch string `json:"head_branch"`
|
||||
JobsURL string `json:"jobs_url"`
|
||||
HeadCommit Commit `json:"head_commit"`
|
||||
HeadSha string `json:"head_sha"`
|
||||
URL string `json:"html_url"`
|
||||
HeadRepository Repo `json:"head_repository"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
Owner struct {
|
||||
Login string
|
||||
}
|
||||
Name string
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (r Run) CommitMsg() string {
|
||||
commitLines := strings.Split(r.HeadCommit.Message, "\n")
|
||||
if len(commitLines) > 0 {
|
||||
return commitLines[0]
|
||||
} else {
|
||||
return r.HeadSha[0:8]
|
||||
}
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID int
|
||||
Status Status
|
||||
Conclusion Conclusion
|
||||
Name string
|
||||
Steps []Step
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
URL string `json:"html_url"`
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
Name string
|
||||
Status Status
|
||||
Conclusion Conclusion
|
||||
Number int
|
||||
}
|
||||
|
||||
type Annotation struct {
|
||||
JobName string
|
||||
Message string
|
||||
Path string
|
||||
Level Level `json:"annotation_level"`
|
||||
StartLine int `json:"start_line"`
|
||||
}
|
||||
|
||||
func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string {
|
||||
switch a.Level {
|
||||
case AnnotationFailure:
|
||||
return cs.FailureIcon()
|
||||
case AnnotationWarning:
|
||||
return cs.WarningIcon()
|
||||
default:
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
type CheckRun struct {
|
||||
ID int
|
||||
}
|
||||
|
||||
func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) {
|
||||
var result []*Annotation
|
||||
|
||||
path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID)
|
||||
|
||||
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := []Annotation{}
|
||||
|
||||
for _, annotation := range result {
|
||||
annotation.JobName = job.Name
|
||||
out = append(out, *annotation)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func IsFailureState(c Conclusion) bool {
|
||||
switch c {
|
||||
case ActionRequired, Failure, StartupFailure, TimedOut:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type RunsPayload struct {
|
||||
WorkflowRuns []Run `json:"workflow_runs"`
|
||||
}
|
||||
|
||||
func GetRuns(client *api.Client, repo ghrepo.Interface, limit int) ([]Run, error) {
|
||||
perPage := limit
|
||||
page := 1
|
||||
if limit > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
runs := []Run{}
|
||||
|
||||
for len(runs) < limit {
|
||||
var result RunsPayload
|
||||
|
||||
path := fmt.Sprintf("repos/%s/actions/runs?per_page=%d&page=%d", ghrepo.FullName(repo), perPage, page)
|
||||
|
||||
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.WorkflowRuns) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, run := range result.WorkflowRuns {
|
||||
runs = append(runs, run)
|
||||
if len(runs) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(result.WorkflowRuns) < perPage {
|
||||
break
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
type JobsPayload struct {
|
||||
Jobs []Job
|
||||
}
|
||||
|
||||
func GetJobs(client *api.Client, repo ghrepo.Interface, run Run) ([]Job, error) {
|
||||
var result JobsPayload
|
||||
parsed, err := url.Parse(run.JobsURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = client.REST(repo.RepoHost(), "GET", parsed.Path[1:], nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Jobs, nil
|
||||
}
|
||||
|
||||
func PromptForRun(cs *iostreams.ColorScheme, client *api.Client, repo ghrepo.Interface) (string, error) {
|
||||
// TODO arbitrary limit
|
||||
runs, err := GetRuns(client, repo, 10)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var selected int
|
||||
|
||||
candidates := []string{}
|
||||
|
||||
for _, run := range runs {
|
||||
symbol, _ := Symbol(cs, run.Status, run.Conclusion)
|
||||
candidates = append(candidates,
|
||||
fmt.Sprintf("%s %s, %s (%s)", symbol, run.CommitMsg(), run.Name, run.HeadBranch))
|
||||
}
|
||||
|
||||
// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
|
||||
// become contiguous
|
||||
err = prompt.SurveyAskOne(&survey.Select{
|
||||
Message: "Select a workflow run",
|
||||
Options: candidates,
|
||||
PageSize: 10,
|
||||
}, &selected)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d", runs[selected].ID), nil
|
||||
}
|
||||
|
||||
func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) {
|
||||
var result Run
|
||||
|
||||
path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID)
|
||||
|
||||
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
type colorFunc func(string) string
|
||||
|
||||
func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) {
|
||||
noColor := func(s string) string { return s }
|
||||
if status == Completed {
|
||||
switch conclusion {
|
||||
case Success:
|
||||
return cs.SuccessIconWithColor(noColor), cs.Green
|
||||
case Skipped, Cancelled, Neutral:
|
||||
return cs.SuccessIconWithColor(noColor), cs.Gray
|
||||
default:
|
||||
return cs.FailureIconWithColor(noColor), cs.Red
|
||||
}
|
||||
}
|
||||
|
||||
return "-", cs.Yellow
|
||||
}
|
||||
114
pkg/cmd/run/shared/test.go
Normal file
114
pkg/cmd/run/shared/test.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test data for use in the various run and job tests
|
||||
func created() time.Time {
|
||||
created, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 04:51:00")
|
||||
return created
|
||||
}
|
||||
|
||||
func updated() time.Time {
|
||||
updated, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 04:55:34")
|
||||
return updated
|
||||
}
|
||||
|
||||
func TestRun(name string, id int, s Status, c Conclusion) Run {
|
||||
return Run{
|
||||
Name: name,
|
||||
ID: id,
|
||||
CreatedAt: created(),
|
||||
UpdatedAt: updated(),
|
||||
Status: s,
|
||||
Conclusion: c,
|
||||
Event: "push",
|
||||
HeadBranch: "trunk",
|
||||
JobsURL: fmt.Sprintf("/runs/%d/jobs", id),
|
||||
HeadCommit: Commit{
|
||||
Message: "cool commit",
|
||||
},
|
||||
HeadSha: "1234567890",
|
||||
URL: fmt.Sprintf("runs/%d", id),
|
||||
HeadRepository: Repo{
|
||||
Owner: struct{ Login string }{Login: "OWNER"},
|
||||
Name: "REPO",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var SuccessfulRun Run = TestRun("successful", 3, Completed, Success)
|
||||
var FailedRun Run = TestRun("failed", 1234, Completed, Failure)
|
||||
|
||||
var TestRuns []Run = []Run{
|
||||
TestRun("timed out", 1, Completed, TimedOut),
|
||||
TestRun("in progress", 2, InProgress, ""),
|
||||
SuccessfulRun,
|
||||
TestRun("cancelled", 4, Completed, Cancelled),
|
||||
FailedRun,
|
||||
TestRun("neutral", 6, Completed, Neutral),
|
||||
TestRun("skipped", 7, Completed, Skipped),
|
||||
TestRun("requested", 8, Requested, ""),
|
||||
TestRun("queued", 9, Queued, ""),
|
||||
TestRun("stale", 10, Completed, Stale),
|
||||
}
|
||||
|
||||
var SuccessfulJob Job = Job{
|
||||
ID: 10,
|
||||
Status: Completed,
|
||||
Conclusion: Success,
|
||||
Name: "cool job",
|
||||
StartedAt: created(),
|
||||
CompletedAt: updated(),
|
||||
URL: "jobs/10",
|
||||
Steps: []Step{
|
||||
{
|
||||
Name: "fob the barz",
|
||||
Status: Completed,
|
||||
Conclusion: Success,
|
||||
Number: 1,
|
||||
},
|
||||
{
|
||||
Name: "barz the fob",
|
||||
Status: Completed,
|
||||
Conclusion: Success,
|
||||
Number: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var FailedJob Job = Job{
|
||||
ID: 20,
|
||||
Status: Completed,
|
||||
Conclusion: Failure,
|
||||
Name: "sad job",
|
||||
StartedAt: created(),
|
||||
CompletedAt: updated(),
|
||||
URL: "jobs/20",
|
||||
Steps: []Step{
|
||||
{
|
||||
Name: "barf the quux",
|
||||
Status: Completed,
|
||||
Conclusion: Success,
|
||||
Number: 1,
|
||||
},
|
||||
{
|
||||
Name: "quux the barf",
|
||||
Status: Completed,
|
||||
Conclusion: Failure,
|
||||
Number: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var FailedJobAnnotations []Annotation = []Annotation{
|
||||
{
|
||||
JobName: "sad job",
|
||||
Message: "the job is sad",
|
||||
Path: "blaze.py",
|
||||
Level: "failure",
|
||||
StartLine: 420,
|
||||
},
|
||||
}
|
||||
269
pkg/cmd/run/view/view.go
Normal file
269
pkg/cmd/run/view/view.go
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type ViewOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
|
||||
RunID string
|
||||
Verbose bool
|
||||
ExitStatus bool
|
||||
|
||||
Prompt bool
|
||||
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
|
||||
opts := &ViewOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Now: time.Now,
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view [<run-id>]",
|
||||
Short: "View a summary of a workflow run",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Hidden: true,
|
||||
Example: heredoc.Doc(`
|
||||
# Interactively select a run to view
|
||||
$ gh run view
|
||||
|
||||
# View a specific run
|
||||
$ gh run view 0451
|
||||
|
||||
# Exit non-zero if a run failed
|
||||
$ gh run view 0451 -e && echo "job pending or passed"
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.RunID = args[0]
|
||||
} else if !opts.IO.CanPrompt() {
|
||||
return &cmdutil.FlagError{Err: errors.New("run ID required when not running interactively")}
|
||||
} else {
|
||||
opts.Prompt = true
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return runView(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show job steps")
|
||||
// TODO should we try and expose pending via another exit code?
|
||||
cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run failed")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(opts *ViewOptions) error {
|
||||
c, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
repo, err := opts.BaseRepo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine base repo: %w", err)
|
||||
}
|
||||
|
||||
runID := opts.RunID
|
||||
|
||||
if opts.Prompt {
|
||||
cs := opts.IO.ColorScheme()
|
||||
runID, err = shared.PromptForRun(cs, client, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
defer opts.IO.StopProgressIndicator()
|
||||
|
||||
run, err := shared.GetRun(client, repo, runID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get run: %w", err)
|
||||
}
|
||||
|
||||
prNumber := ""
|
||||
number, err := prForRun(client, repo, *run)
|
||||
if err == nil {
|
||||
prNumber = fmt.Sprintf(" #%d", number)
|
||||
}
|
||||
|
||||
jobs, err := shared.GetJobs(client, repo, *run)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get jobs: %w", err)
|
||||
}
|
||||
|
||||
var annotations []shared.Annotation
|
||||
|
||||
var annotationErr error
|
||||
var as []shared.Annotation
|
||||
for _, job := range jobs {
|
||||
as, annotationErr = shared.GetAnnotations(client, repo, job)
|
||||
if annotationErr != nil {
|
||||
break
|
||||
}
|
||||
annotations = append(annotations, as...)
|
||||
}
|
||||
|
||||
opts.IO.StopProgressIndicator()
|
||||
if annotationErr != nil {
|
||||
return fmt.Errorf("failed to get annotations: %w", annotationErr)
|
||||
}
|
||||
|
||||
out := opts.IO.Out
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
title := fmt.Sprintf("%s %s%s",
|
||||
cs.Bold(run.HeadBranch), run.Name, prNumber)
|
||||
symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion)
|
||||
id := cs.Cyanf("%d", run.ID)
|
||||
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintf(out, "%s %s · %s\n", symbolColor(symbol), title, id)
|
||||
|
||||
ago := opts.Now().Sub(run.CreatedAt)
|
||||
|
||||
fmt.Fprintf(out, "Triggered via %s %s\n", run.Event, utils.FuzzyAgo(ago))
|
||||
fmt.Fprintln(out)
|
||||
|
||||
if len(jobs) == 0 && run.Conclusion == shared.Failure {
|
||||
fmt.Fprintf(out, "%s %s\n",
|
||||
cs.FailureIcon(),
|
||||
cs.Bold("This run likely failed because of a workflow file issue."))
|
||||
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintf(out, "For more information, see: %s\n", cs.Bold(run.URL))
|
||||
|
||||
if opts.ExitStatus {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintln(out, cs.Bold("JOBS"))
|
||||
|
||||
for _, job := range jobs {
|
||||
symbol, symbolColor := shared.Symbol(cs, job.Status, job.Conclusion)
|
||||
id := cs.Cyanf("%d", job.ID)
|
||||
fmt.Fprintf(out, "%s %s (ID %s)\n", symbolColor(symbol), job.Name, id)
|
||||
if opts.Verbose || shared.IsFailureState(job.Conclusion) {
|
||||
for _, step := range job.Steps {
|
||||
stepSymbol, stepSymColor := shared.Symbol(cs, step.Status, step.Conclusion)
|
||||
fmt.Fprintf(out, " %s %s\n", stepSymColor(stepSymbol), step.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(annotations) > 0 {
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, cs.Bold("ANNOTATIONS"))
|
||||
|
||||
for _, a := range annotations {
|
||||
fmt.Fprintf(out, "%s %s\n", shared.AnnotationSymbol(cs, a), a.Message)
|
||||
fmt.Fprintln(out, cs.Grayf("%s: %s#%d\n",
|
||||
a.JobName, a.Path, a.StartLine))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "For more information about a job, try: gh job view <job-id>")
|
||||
fmt.Fprintf(out, cs.Gray("view this run on GitHub: %s\n"), run.URL)
|
||||
|
||||
if opts.ExitStatus && shared.IsFailureState(run.Conclusion) {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func prForRun(client *api.Client, repo ghrepo.Interface, run shared.Run) (int, error) {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequests struct {
|
||||
Nodes []struct {
|
||||
Number int
|
||||
HeadRepository struct {
|
||||
Owner struct {
|
||||
Login string
|
||||
}
|
||||
Name string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Number int
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": repo.RepoOwner(),
|
||||
"repo": repo.RepoName(),
|
||||
"headRefName": run.HeadBranch,
|
||||
}
|
||||
|
||||
query := `
|
||||
query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
nodes {
|
||||
number
|
||||
headRepository {
|
||||
owner {
|
||||
login
|
||||
}
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
var resp response
|
||||
|
||||
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
prs := resp.Repository.PullRequests.Nodes
|
||||
if len(prs) == 0 {
|
||||
return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
|
||||
}
|
||||
|
||||
number := -1
|
||||
|
||||
for _, pr := range prs {
|
||||
if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name {
|
||||
number = pr.Number
|
||||
}
|
||||
}
|
||||
|
||||
if number == -1 {
|
||||
return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
|
||||
}
|
||||
|
||||
return number, nil
|
||||
}
|
||||
313
pkg/cmd/run/view/view_test.go
Normal file
313
pkg/cmd/run/view/view_test.go
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdView(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
tty bool
|
||||
wants ViewOptions
|
||||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "blank nontty",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "blank tty",
|
||||
tty: true,
|
||||
wants: ViewOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "exit status",
|
||||
cli: "--exit-status 1234",
|
||||
wants: ViewOptions{
|
||||
RunID: "1234",
|
||||
ExitStatus: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "verbosity",
|
||||
cli: "-v",
|
||||
tty: true,
|
||||
wants: ViewOptions{
|
||||
Verbose: true,
|
||||
Prompt: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with arg nontty",
|
||||
cli: "1234",
|
||||
wants: ViewOptions{
|
||||
RunID: "1234",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, _, _ := iostreams.Test()
|
||||
io.SetStdinTTY(tt.tty)
|
||||
io.SetStdoutTTY(tt.tty)
|
||||
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *ViewOptions
|
||||
cmd := NewCmdView(f, func(opts *ViewOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(ioutil.Discard)
|
||||
cmd.SetErr(ioutil.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wants.RunID, gotOpts.RunID)
|
||||
assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt)
|
||||
assert.Equal(t, tt.wants.ExitStatus, gotOpts.ExitStatus)
|
||||
assert.Equal(t, tt.wants.Verbose, gotOpts.Verbose)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewRun(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
askStubs func(*prompt.AskStubber)
|
||||
opts *ViewOptions
|
||||
tty bool
|
||||
wantErr bool
|
||||
wantOut string
|
||||
}{
|
||||
{
|
||||
name: "associate with PR",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
RunID: "3",
|
||||
Prompt: false,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(`{"data": {
|
||||
"repository": {
|
||||
"pullRequests": {
|
||||
"nodes": [
|
||||
{"number": 2898,
|
||||
"headRepository": {
|
||||
"owner": {
|
||||
"login": "OWNER"
|
||||
},
|
||||
"name": "REPO"}}
|
||||
]}}}}`))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
},
|
||||
wantOut: "\n✓ trunk successful #2898 · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/3\n",
|
||||
},
|
||||
{
|
||||
name: "exit status, failed run",
|
||||
opts: &ViewOptions{
|
||||
RunID: "1234",
|
||||
ExitStatus: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
|
||||
httpmock.JSONResponse(shared.FailedRun))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(``))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/1234/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.FailedJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
|
||||
httpmock.JSONResponse(shared.FailedJobAnnotations))
|
||||
},
|
||||
wantOut: "\nX trunk failed · 1234\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job (ID 20)\n ✓ barf the quux\n X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/1234\n",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "exit status, successful run",
|
||||
opts: &ViewOptions{
|
||||
RunID: "3",
|
||||
ExitStatus: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(``))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
},
|
||||
wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/3\n",
|
||||
},
|
||||
{
|
||||
name: "verbose",
|
||||
tty: true,
|
||||
opts: &ViewOptions{
|
||||
RunID: "1234",
|
||||
Prompt: false,
|
||||
Verbose: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
|
||||
httpmock.JSONResponse(shared.FailedRun))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(``))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/1234/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
shared.FailedJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/20/annotations"),
|
||||
httpmock.JSONResponse(shared.FailedJobAnnotations))
|
||||
},
|
||||
wantOut: "\nX trunk failed · 1234\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n ✓ fob the barz\n ✓ barz the fob\nX sad job (ID 20)\n ✓ barf the quux\n X quux the barf\n\nANNOTATIONS\nX the job is sad\nsad job: blaze.py#420\n\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/1234\n",
|
||||
},
|
||||
{
|
||||
name: "prompts for choice",
|
||||
tty: true,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
WorkflowRuns: shared.TestRuns,
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestForRun`),
|
||||
httpmock.StringResponse(``))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "runs/3/jobs"),
|
||||
httpmock.JSONResponse(shared.JobsPayload{
|
||||
Jobs: []shared.Job{
|
||||
shared.SuccessfulJob,
|
||||
},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/check-runs/10/annotations"),
|
||||
httpmock.JSONResponse([]shared.Annotation{}))
|
||||
},
|
||||
askStubs: func(as *prompt.AskStubber) {
|
||||
as.StubOne(2)
|
||||
},
|
||||
opts: &ViewOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job (ID 10)\n\nFor more information about a job, try: gh job view <job-id>\nview this run on GitHub: runs/3\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
reg := &httpmock.Registry{}
|
||||
tt.httpStubs(reg)
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
|
||||
tt.opts.Now = func() time.Time {
|
||||
notnow, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 05:50:00")
|
||||
return notnow
|
||||
}
|
||||
|
||||
io, _, stdout, _ := iostreams.Test()
|
||||
io.SetStdoutTTY(tt.tty)
|
||||
tt.opts.IO = io
|
||||
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("OWNER/REPO")
|
||||
}
|
||||
|
||||
as, teardown := prompt.InitAskStubber()
|
||||
defer teardown()
|
||||
if tt.askStubs != nil {
|
||||
tt.askStubs(as)
|
||||
}
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runView(tt.opts)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if !tt.opts.ExitStatus {
|
||||
return
|
||||
}
|
||||
}
|
||||
if !tt.opts.ExitStatus {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,10 @@ func (c *ColorScheme) Bold(t string) string {
|
|||
return bold(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Boldf(t string, args ...interface{}) string {
|
||||
return c.Bold(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Red(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
|
|
@ -70,6 +74,10 @@ func (c *ColorScheme) Red(t string) string {
|
|||
return red(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Redf(t string, args ...interface{}) string {
|
||||
return c.Red(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Yellow(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
|
|
@ -77,6 +85,10 @@ func (c *ColorScheme) Yellow(t string) string {
|
|||
return yellow(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Yellowf(t string, args ...interface{}) string {
|
||||
return c.Yellow(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Green(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
|
|
@ -84,6 +96,10 @@ func (c *ColorScheme) Green(t string) string {
|
|||
return green(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Greenf(t string, args ...interface{}) string {
|
||||
return c.Green(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Gray(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
|
|
@ -94,6 +110,10 @@ func (c *ColorScheme) Gray(t string) string {
|
|||
return gray(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Grayf(t string, args ...interface{}) string {
|
||||
return c.Gray(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Magenta(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
|
|
@ -101,6 +121,10 @@ func (c *ColorScheme) Magenta(t string) string {
|
|||
return magenta(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Magentaf(t string, args ...interface{}) string {
|
||||
return c.Magenta(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Cyan(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
|
|
@ -108,6 +132,10 @@ func (c *ColorScheme) Cyan(t string) string {
|
|||
return cyan(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Cyanf(t string, args ...interface{}) string {
|
||||
return c.Cyan(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) CyanBold(t string) string {
|
||||
if !c.enabled {
|
||||
return t
|
||||
|
|
@ -122,6 +150,10 @@ func (c *ColorScheme) Blue(t string) string {
|
|||
return blue(t)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) Bluef(t string, args ...interface{}) string {
|
||||
return c.Blue(fmt.Sprintf(t, args...))
|
||||
}
|
||||
|
||||
func (c *ColorScheme) SuccessIcon() string {
|
||||
return c.SuccessIconWithColor(c.Green)
|
||||
}
|
||||
|
|
@ -135,7 +167,11 @@ func (c *ColorScheme) WarningIcon() string {
|
|||
}
|
||||
|
||||
func (c *ColorScheme) FailureIcon() string {
|
||||
return c.Red("X")
|
||||
return c.FailureIconWithColor(c.Red)
|
||||
}
|
||||
|
||||
func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string {
|
||||
return colo("X")
|
||||
}
|
||||
|
||||
func (c *ColorScheme) ColorFromString(s string) func(string) string {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ func (t ttyTablePrinter) IsTTY() bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Never pass pre-colorized text to AddField; always specify colorFunc. Otherwise, the table printer can't correctly compute the width of its columns.
|
||||
func (t *ttyTablePrinter) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) {
|
||||
if truncateFunc == nil {
|
||||
truncateFunc = text.Truncate
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue