From 71c2bc5807bd6c311a2a67107b5d62f3509c9802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 30 Mar 2021 20:47:25 +0200 Subject: [PATCH 1/9] Add tests for manual pages generation --- cmd/gen-docs/main.go | 54 +++++++++++++++++++-------------------- cmd/gen-docs/main_test.go | 32 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 cmd/gen-docs/main_test.go diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 4a0bff26b..bfd4a12d0 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "path/filepath" "strings" "github.com/cli/cli/internal/docs" @@ -13,27 +14,30 @@ import ( ) func main() { - var flagError pflag.ErrorHandling - docCmd := pflag.NewFlagSet("", flagError) - manPage := docCmd.BoolP("man-page", "", false, "Generate manual pages") - website := docCmd.BoolP("website", "", false, "Generate website pages") - dir := docCmd.StringP("doc-path", "", "", "Path directory where you want generate doc files") - help := docCmd.BoolP("help", "h", false, "Help about any command") - - if err := docCmd.Parse(os.Args); err != nil { + if err := run(os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) os.Exit(1) } +} + +func run(args []string) error { + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + manPage := flags.BoolP("man-page", "", false, "Generate manual pages") + website := flags.BoolP("website", "", false, "Generate website pages") + dir := flags.StringP("doc-path", "", "", "Path directory where you want generate doc files") + help := flags.BoolP("help", "h", false, "Help about any command") + + if err := flags.Parse(args); err != nil { + return err + } if *help { - _, err := fmt.Fprintf(os.Stderr, "Usage of %s:\n\n%s", os.Args[0], docCmd.FlagUsages()) - if err != nil { - fatal(err) - } - os.Exit(1) + fmt.Fprintf(os.Stderr, "Usage of %s:\n\n%s", filepath.Base(args[0]), flags.FlagUsages()) + return nil } if *dir == "" { - fatal("no dir set") + return fmt.Errorf("error: --doc-path not set") } io, _, _, _ := iostreams.Test() @@ -43,15 +47,13 @@ func main() { }, "", "") rootCmd.InitDefaultHelpCmd() - err := os.MkdirAll(*dir, 0755) - if err != nil { - fatal(err) + if err := os.MkdirAll(*dir, 0755); err != nil { + return err } if *website { - err = docs.GenMarkdownTreeCustom(rootCmd, *dir, filePrepender, linkHandler) - if err != nil { - fatal(err) + if err := docs.GenMarkdownTreeCustom(rootCmd, *dir, filePrepender, linkHandler); err != nil { + return err } } @@ -62,11 +64,12 @@ func main() { Source: "", Manual: "", } - err = docs.GenManTree(rootCmd, header, *dir) - if err != nil { - fatal(err) + if err := docs.GenManTree(rootCmd, header, *dir); err != nil { + return err } } + + return nil } func filePrepender(filename string) string { @@ -82,11 +85,6 @@ func linkHandler(name string) string { return fmt.Sprintf("./%s", strings.TrimSuffix(name, ".md")) } -func fatal(msg interface{}) { - fmt.Fprintln(os.Stderr, msg) - os.Exit(1) -} - type browser struct{} func (b *browser) Browse(url string) error { diff --git a/cmd/gen-docs/main_test.go b/cmd/gen-docs/main_test.go new file mode 100644 index 000000000..5c69ff6b2 --- /dev/null +++ b/cmd/gen-docs/main_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "io/ioutil" + "strings" + "testing" +) + +func Test_run(t *testing.T) { + dir := t.TempDir() + args := []string{"--man-page", "--website", "--doc-path", dir} + err := run(args) + if err != nil { + t.Fatalf("got error: %v", err) + } + + manPage, err := ioutil.ReadFile(dir + "/gh-issue-create.1") + if err != nil { + t.Fatalf("error reading `gh-issue-create.1`: %v", err) + } + if !strings.Contains(string(manPage), `\fBgh issue create`) { + t.Fatal("man page corrupted") + } + + markdownPage, err := ioutil.ReadFile(dir + "/gh_issue_create.md") + if err != nil { + t.Fatalf("error reading `gh_issue_create.md`: %v", err) + } + if !strings.Contains(string(markdownPage), `## gh issue create`) { + t.Fatal("markdown page corrupted") + } +} From 5566289d1c986541188fc714623e430ce70d3d9e Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Mon, 29 Mar 2021 13:41:01 -0700 Subject: [PATCH 2/9] workflow view --- pkg/cmd/run/shared/shared.go | 1 + pkg/cmd/workflow/view/http.go | 58 ++++ pkg/cmd/workflow/view/view.go | 270 ++++++++++++++++++ pkg/cmd/workflow/view/view_test.go | 425 +++++++++++++++++++++++++++++ pkg/cmd/workflow/workflow.go | 2 + 5 files changed, 756 insertions(+) create mode 100644 pkg/cmd/workflow/view/http.go create mode 100644 pkg/cmd/workflow/view/view.go create mode 100644 pkg/cmd/workflow/view/view_test.go diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 45f98abc3..7cfc822f6 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -148,6 +148,7 @@ func IsFailureState(c Conclusion) bool { } type RunsPayload struct { + TotalCount int `json:"total_count"` WorkflowRuns []Run `json:"workflow_runs"` } diff --git a/pkg/cmd/workflow/view/http.go b/pkg/cmd/workflow/view/http.go new file mode 100644 index 000000000..bee32b3b8 --- /dev/null +++ b/pkg/cmd/workflow/view/http.go @@ -0,0 +1,58 @@ +package view + +import ( + "encoding/base64" + "fmt" + "net/url" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + runShared "github.com/cli/cli/pkg/cmd/run/shared" + "github.com/cli/cli/pkg/cmd/workflow/shared" +) + +type workflowRuns struct { + Total int + Runs []runShared.Run +} + +func getWorkflowContent(client *api.Client, repo ghrepo.Interface, ref string, workflow *shared.Workflow) (string, error) { + path := fmt.Sprintf("repos/%s/contents/%s", ghrepo.FullName(repo), workflow.Path) + if ref != "" { + q := fmt.Sprintf("?ref=%s", url.QueryEscape(ref)) + path = path + q + } + + type Result struct { + Content string + } + + var result Result + err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + if err != nil { + return "", err + } + + decoded, err := base64.StdEncoding.DecodeString(result.Content) + if err != nil { + return "", fmt.Errorf("failed to decode workflow file: %w", err) + } + + return string(decoded), nil +} + +func getWorkflowRuns(client *api.Client, repo ghrepo.Interface, workflow *shared.Workflow) (workflowRuns, error) { + var wr workflowRuns + var result runShared.RunsPayload + path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs?per_page=%d&page=%d", ghrepo.FullName(repo), workflow.ID, 5, 1) + + err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + if err != nil { + return wr, err + } + + wr.Total = result.TotalCount + wr.Runs = append(wr.Runs, result.WorkflowRuns...) + + return wr, nil +} diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go new file mode 100644 index 000000000..a39b84f8e --- /dev/null +++ b/pkg/cmd/workflow/view/view.go @@ -0,0 +1,270 @@ +package view + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + runShared "github.com/cli/cli/pkg/cmd/run/shared" + "github.com/cli/cli/pkg/cmd/workflow/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/markdown" + "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) + Browser cmdutil.Browser + + Selector string + Ref string + Web bool + Prompt bool + Raw bool + Yaml bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Browser: f.Browser, + } + + cmd := &cobra.Command{ + Use: "view [ | ]", + Short: "View the summary of a workflow", + Args: cobra.MaximumNArgs(1), + Hidden: true, + Example: heredoc.Doc(` + # Interactively select a workflow to view + $ gh workflow view + + # View a specific workflow + $ gh workflow view 0451 + `), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.Raw = !opts.IO.CanPrompt() + + if len(args) > 0 { + opts.Selector = args[0] + } else if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("workflow argument required when not running interactively")} + } else { + opts.Prompt = true + } + + if runF != nil { + return runF(opts) + } + return runView(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open workflow in the browser") + cmd.Flags().BoolVarP(&opts.Yaml, "yaml", "y", false, "View the workflow yaml file") + cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "The branch or tag name which contains the version of the workflow file you'd like to view") + + return cmd +} + +func runView(opts *ViewOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not build http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + + var workflow *shared.Workflow + states := []shared.WorkflowState{shared.Active} + workflow, err = shared.ResolveWorkflow(opts.IO, client, repo, opts.Prompt, opts.Selector, states) + if err != nil { + return err + } + + if opts.Web { + var url string + hostname := repo.RepoHost() + if opts.Yaml { + ref := opts.Ref + if ref == "" { + opts.IO.StartProgressIndicator() + ref, err = api.RepoDefaultBranch(client, repo) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + } + url = fmt.Sprintf("https://%s/%s/blob/%s/%s", hostname, ghrepo.FullName(repo), ref, workflow.Path) + } else { + baseName := filepath.Base(workflow.Path) + url = fmt.Sprintf("https://%s/%s/actions/workflows/%s", hostname, ghrepo.FullName(repo), baseName) + } + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url)) + } + return opts.Browser.Browse(url) + } + + if opts.Yaml { + err = viewWorkflowContent(opts, client, workflow) + } else { + err = viewWorkflowInfo(opts, client, workflow) + } + if err != nil { + return err + } + + return nil +} + +func viewWorkflowContent(opts *ViewOptions, client *api.Client, workflow *shared.Workflow) error { + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + + opts.IO.StartProgressIndicator() + yaml, err := getWorkflowContent(client, repo, opts.Ref, workflow) + opts.IO.StopProgressIndicator() + if err != nil { + if s, ok := err.(api.HTTPError); ok { + if s.StatusCode == 404 { + base := filepath.Base(workflow.Path) + if opts.Ref != "" { + return fmt.Errorf("could not find workflow file %s on %s, try specifying a different ref", base, opts.Ref) + } + return fmt.Errorf("could not find workflow file %s, try specifying a branch or tag using --ref", base) + } + } + return fmt.Errorf("could not get workflow file content: %w", err) + } + + theme := opts.IO.DetectTerminalTheme() + markdownStyle := markdown.GetStyle(theme) + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err) + } + defer opts.IO.StopPager() + + if !opts.Raw { + cs := opts.IO.ColorScheme() + out := opts.IO.Out + + fileName := filepath.Base(workflow.Path) + fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Gray(fileName)) + fmt.Fprintf(out, "ID: %s", cs.Cyanf("%d", workflow.ID)) + + codeBlock := fmt.Sprintf("```yaml\n%s\n```", yaml) + rendered, err := markdown.RenderWithOpts(codeBlock, markdownStyle, + markdown.RenderOpts{ + markdown.WithoutIndentation(), + markdown.WithoutWrap(), + }) + if err != nil { + return err + } + _, err = fmt.Fprint(opts.IO.Out, rendered) + return err + } + + if _, err := fmt.Fprint(opts.IO.Out, yaml); err != nil { + return err + } + + if !strings.HasSuffix(yaml, "\n") { + _, err := fmt.Fprint(opts.IO.Out, "\n") + return err + } + + return nil +} + +func viewWorkflowInfo(opts *ViewOptions, client *api.Client, workflow *shared.Workflow) error { + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + + opts.IO.StartProgressIndicator() + wr, err := getWorkflowRuns(client, repo, workflow) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + + out := opts.IO.Out + cs := opts.IO.ColorScheme() + tp := utils.NewTablePrinter(opts.IO) + + // Header + filename := filepath.Base(workflow.Path) + fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Cyan(filename)) + fmt.Fprintf(out, "ID: %s\n\n", cs.Cyanf("%d", workflow.ID)) + + // Runs + fmt.Fprintf(out, "Total runs %d\n", wr.Total) + + if wr.Total != 0 { + fmt.Fprintln(out, "Recent runs") + } + + for _, run := range wr.Runs { + if opts.Raw { + tp.AddField(string(run.Status), nil, nil) + tp.AddField(string(run.Conclusion), nil, nil) + } else { + symbol, symbolColor := runShared.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.Raw { + 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 + } + + fmt.Fprintln(out) + + // Footer + if wr.Total != 0 { + fmt.Fprintf(out, "To see more runs for this workflow, try: gh run list --workflow %s\n", filename) + } + fmt.Fprintf(out, "To see the YAML for this workflow, try: gh workflow view %s --yaml\n", filename) + return nil +} diff --git a/pkg/cmd/workflow/view/view_test.go b/pkg/cmd/workflow/view/view_test.go new file mode 100644 index 000000000..2c14b5042 --- /dev/null +++ b/pkg/cmd/workflow/view/view_test.go @@ -0,0 +1,425 @@ +package view + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + runShared "github.com/cli/cli/pkg/cmd/run/shared" + "github.com/cli/cli/pkg/cmd/workflow/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 tty", + tty: true, + wants: ViewOptions{ + Prompt: true, + }, + }, + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "arg tty", + cli: "123", + tty: true, + wants: ViewOptions{ + Selector: "123", + }, + }, + { + name: "arg nontty", + cli: "123", + wants: ViewOptions{ + Selector: "123", + Raw: true, + }, + }, + { + name: "web tty", + cli: "--web", + tty: true, + wants: ViewOptions{ + Prompt: true, + Web: true, + }, + }, + { + name: "web nontty", + cli: "-w 123", + wants: ViewOptions{ + Raw: true, + Web: true, + Selector: "123", + }, + }, + { + name: "yaml tty", + cli: "--yaml", + tty: true, + wants: ViewOptions{ + Prompt: true, + Yaml: true, + }, + }, + { + name: "yaml nontty", + cli: "-y 123", + wants: ViewOptions{ + Raw: true, + Yaml: true, + Selector: "123", + }, + }, + { + name: "ref tty", + cli: "--ref 456", + tty: true, + wants: ViewOptions{ + Prompt: true, + Ref: "456", + }, + }, + { + name: "ref nontty", + cli: "123 -r 456", + wants: ViewOptions{ + Raw: true, + Ref: "456", + Selector: "123", + }, + }, + } + + 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.Selector, gotOpts.Selector) + assert.Equal(t, tt.wants.Ref, gotOpts.Ref) + assert.Equal(t, tt.wants.Web, gotOpts.Web) + assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt) + assert.Equal(t, tt.wants.Raw, gotOpts.Raw) + assert.Equal(t, tt.wants.Yaml, gotOpts.Yaml) + }) + } +} + +func TestViewRun(t *testing.T) { + aWorkflow := shared.Workflow{ + Name: "a workflow", + ID: 123, + Path: ".github/workflows/flow.yml", + State: shared.Active, + } + aWorkflowContent := `{"content":"bmFtZTogYSB3b3JrZmxvdwo="}` + aWorkflowInfo := heredoc.Doc(` + a workflow - flow.yml + ID: 123 + + Total runs 10 + Recent runs + X cool commit timed out trunk push 1 + - cool commit in progress trunk push 2 + ✓ cool commit successful trunk push 3 + ✓ cool commit cancelled trunk push 4 + + To see more runs for this workflow, try: gh run list --workflow flow.yml + To see the YAML for this workflow, try: gh workflow view flow.yml --yaml + `) + + tests := []struct { + name string + opts *ViewOptions + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + tty bool + wantOut string + wantErrOut string + wantErr bool + }{ + { + name: "no enabled workflows", + tty: true, + opts: &ViewOptions{ + Prompt: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{})) + }, + wantErrOut: "could not fetch workflows for OWNER/REPO: no workflows are enabled", + wantErr: true, + }, + { + name: "web", + tty: true, + opts: &ViewOptions{ + Selector: "123", + Web: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + }, + wantOut: "Opening github.com/OWNER/REPO/actions/workflows/flow.yml in your browser.\n", + }, + { + name: "web notty", + tty: false, + opts: &ViewOptions{ + Selector: "123", + Web: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + }, + wantOut: "", + }, + { + name: "web with yaml", + tty: true, + opts: &ViewOptions{ + Selector: "123", + Yaml: true, + Web: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(`{ "data": { "repository": { "defaultBranchRef": { "name": "trunk" } } } }`), + ) + }, + wantOut: "Opening github.com/OWNER/REPO/blob/trunk/.github/workflows/flow.yml in your browser.\n", + }, + { + name: "web with yaml and ref", + tty: true, + opts: &ViewOptions{ + Selector: "123", + Ref: "base", + Yaml: true, + Web: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + }, + wantOut: "Opening github.com/OWNER/REPO/blob/base/.github/workflows/flow.yml in your browser.\n", + }, + { + name: "workflow with yaml", + tty: true, + opts: &ViewOptions{ + Selector: "123", + Yaml: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.StringResponse(aWorkflowContent), + ) + }, + wantOut: "a workflow - flow.yml\nID: 123\n\nname: a workflow\n\n\n", + }, + { + name: "workflow with yaml notty", + tty: false, + opts: &ViewOptions{ + Selector: "123", + Yaml: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.StringResponse(aWorkflowContent), + ) + }, + wantOut: "a workflow - flow.yml\nID: 123\n\nname: a workflow\n\n\n", + }, + { + name: "workflow with yaml not found", + tty: true, + opts: &ViewOptions{ + Selector: "123", + Yaml: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.StatusStringResponse(404, "not Found"), + ) + }, + wantErr: true, + wantErrOut: "could not find workflow file flow.yml, try specifying a branch or tag using --ref", + }, + { + name: "workflow with yaml and ref", + tty: true, + opts: &ViewOptions{ + Selector: "123", + Ref: "456", + Yaml: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/flow.yml"), + httpmock.StringResponse(aWorkflowContent), + ) + }, + wantOut: "a workflow - flow.yml\nID: 123\n\nname: a workflow\n\n\n", + }, + { + name: "workflow info", + tty: true, + opts: &ViewOptions{ + Selector: "123", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123/runs"), + httpmock.JSONResponse(runShared.RunsPayload{ + TotalCount: 10, + WorkflowRuns: runShared.TestRuns[0:4], + }), + ) + }, + wantOut: aWorkflowInfo, + }, + { + name: "workflow info notty", + tty: true, + opts: &ViewOptions{ + Selector: "123", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(aWorkflow), + ) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123/runs"), + httpmock.JSONResponse(runShared.RunsPayload{ + TotalCount: 10, + WorkflowRuns: runShared.TestRuns[0:4], + }), + ) + }, + wantOut: aWorkflowInfo, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + tt.httpStubs(reg) + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(tt.tty) + io.SetStdinTTY(tt.tty) + tt.opts.IO = io + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + browser := &cmdutil.TestBrowser{} + tt.opts.Browser = browser + + 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) + assert.Equal(t, tt.wantErrOut, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/workflow/workflow.go b/pkg/cmd/workflow/workflow.go index a61e28ca4..d739da776 100644 --- a/pkg/cmd/workflow/workflow.go +++ b/pkg/cmd/workflow/workflow.go @@ -4,6 +4,7 @@ import ( cmdDisable "github.com/cli/cli/pkg/cmd/workflow/disable" cmdEnable "github.com/cli/cli/pkg/cmd/workflow/enable" cmdList "github.com/cli/cli/pkg/cmd/workflow/list" + cmdView "github.com/cli/cli/pkg/cmd/workflow/view" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -23,6 +24,7 @@ func NewCmdWorkflow(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdEnable.NewCmdEnable(f, nil)) cmd.AddCommand(cmdDisable.NewCmdDisable(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) return cmd } From d1b153c6dc8ec0bb672eaa7c60a1714d990eb475 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 2 Apr 2021 09:43:33 -0700 Subject: [PATCH 3/9] Yaml to YAML --- pkg/cmd/workflow/view/view.go | 8 ++++---- pkg/cmd/workflow/view/view_test.go | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index a39b84f8e..f32633aaf 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -30,7 +30,7 @@ type ViewOptions struct { Web bool Prompt bool Raw bool - Yaml bool + YAML bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -74,7 +74,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open workflow in the browser") - cmd.Flags().BoolVarP(&opts.Yaml, "yaml", "y", false, "View the workflow yaml file") + cmd.Flags().BoolVarP(&opts.YAML, "yaml", "y", false, "View the workflow yaml file") cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "The branch or tag name which contains the version of the workflow file you'd like to view") return cmd @@ -102,7 +102,7 @@ func runView(opts *ViewOptions) error { if opts.Web { var url string hostname := repo.RepoHost() - if opts.Yaml { + if opts.YAML { ref := opts.Ref if ref == "" { opts.IO.StartProgressIndicator() @@ -123,7 +123,7 @@ func runView(opts *ViewOptions) error { return opts.Browser.Browse(url) } - if opts.Yaml { + if opts.YAML { err = viewWorkflowContent(opts, client, workflow) } else { err = viewWorkflowInfo(opts, client, workflow) diff --git a/pkg/cmd/workflow/view/view_test.go b/pkg/cmd/workflow/view/view_test.go index 2c14b5042..434cf0c83 100644 --- a/pkg/cmd/workflow/view/view_test.go +++ b/pkg/cmd/workflow/view/view_test.go @@ -77,7 +77,7 @@ func TestNewCmdView(t *testing.T) { tty: true, wants: ViewOptions{ Prompt: true, - Yaml: true, + YAML: true, }, }, { @@ -85,7 +85,7 @@ func TestNewCmdView(t *testing.T) { cli: "-y 123", wants: ViewOptions{ Raw: true, - Yaml: true, + YAML: true, Selector: "123", }, }, @@ -144,7 +144,7 @@ func TestNewCmdView(t *testing.T) { assert.Equal(t, tt.wants.Web, gotOpts.Web) assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt) assert.Equal(t, tt.wants.Raw, gotOpts.Raw) - assert.Equal(t, tt.wants.Yaml, gotOpts.Yaml) + assert.Equal(t, tt.wants.YAML, gotOpts.YAML) }) } } @@ -231,7 +231,7 @@ func TestViewRun(t *testing.T) { tty: true, opts: &ViewOptions{ Selector: "123", - Yaml: true, + YAML: true, Web: true, }, httpStubs: func(reg *httpmock.Registry) { @@ -252,7 +252,7 @@ func TestViewRun(t *testing.T) { opts: &ViewOptions{ Selector: "123", Ref: "base", - Yaml: true, + YAML: true, Web: true, }, httpStubs: func(reg *httpmock.Registry) { @@ -268,7 +268,7 @@ func TestViewRun(t *testing.T) { tty: true, opts: &ViewOptions{ Selector: "123", - Yaml: true, + YAML: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -287,7 +287,7 @@ func TestViewRun(t *testing.T) { tty: false, opts: &ViewOptions{ Selector: "123", - Yaml: true, + YAML: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -306,7 +306,7 @@ func TestViewRun(t *testing.T) { tty: true, opts: &ViewOptions{ Selector: "123", - Yaml: true, + YAML: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -327,7 +327,7 @@ func TestViewRun(t *testing.T) { opts: &ViewOptions{ Selector: "123", Ref: "456", - Yaml: true, + YAML: true, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( From e15189f43ed1ddbb75ab164a8730700e78094a36 Mon Sep 17 00:00:00 2001 From: Sam Coe Date: Fri, 2 Apr 2021 10:16:09 -0700 Subject: [PATCH 4/9] Address PR comments --- pkg/cmd/workflow/view/view.go | 30 ++++++++++++++---------------- pkg/cmd/workflow/view/view_test.go | 23 ++++++++++++++++++----- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index f32633aaf..dbcabcb81 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/http" - "path/filepath" "strings" "github.com/MakeNowJust/heredoc" @@ -41,7 +40,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd := &cobra.Command{ - Use: "view [ | ]", + Use: "view [ | | ]", Short: "View the summary of a workflow", Args: cobra.MaximumNArgs(1), Hidden: true, @@ -56,7 +55,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - opts.Raw = !opts.IO.CanPrompt() + opts.Raw = !opts.IO.IsStdoutTTY() if len(args) > 0 { opts.Selector = args[0] @@ -66,6 +65,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts.Prompt = true } + if !opts.YAML && opts.Ref != "" { + return &cmdutil.FlagError{Err: errors.New("`--yaml` required when specifying `--ref`")} + } + if runF != nil { return runF(opts) } @@ -101,7 +104,6 @@ func runView(opts *ViewOptions) error { if opts.Web { var url string - hostname := repo.RepoHost() if opts.YAML { ref := opts.Ref if ref == "" { @@ -112,10 +114,9 @@ func runView(opts *ViewOptions) error { return err } } - url = fmt.Sprintf("https://%s/%s/blob/%s/%s", hostname, ghrepo.FullName(repo), ref, workflow.Path) + url = ghrepo.GenerateRepoURL(repo, "blob/%s/%s", ref, workflow.Path) } else { - baseName := filepath.Base(workflow.Path) - url = fmt.Sprintf("https://%s/%s/actions/workflows/%s", hostname, ghrepo.FullName(repo), baseName) + url = ghrepo.GenerateRepoURL(repo, "actions/workflows/%s", workflow.Base()) } if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url)) @@ -145,14 +146,11 @@ func viewWorkflowContent(opts *ViewOptions, client *api.Client, workflow *shared yaml, err := getWorkflowContent(client, repo, opts.Ref, workflow) opts.IO.StopProgressIndicator() if err != nil { - if s, ok := err.(api.HTTPError); ok { - if s.StatusCode == 404 { - base := filepath.Base(workflow.Path) - if opts.Ref != "" { - return fmt.Errorf("could not find workflow file %s on %s, try specifying a different ref", base, opts.Ref) - } - return fmt.Errorf("could not find workflow file %s, try specifying a branch or tag using --ref", base) + if s, ok := err.(api.HTTPError); ok && s.StatusCode == 404 { + if opts.Ref != "" { + return fmt.Errorf("could not find workflow file %s on %s, try specifying a different ref", workflow.Base(), opts.Ref) } + return fmt.Errorf("could not find workflow file %s, try specifying a branch or tag using `--ref`", workflow.Base()) } return fmt.Errorf("could not get workflow file content: %w", err) } @@ -168,7 +166,7 @@ func viewWorkflowContent(opts *ViewOptions, client *api.Client, workflow *shared cs := opts.IO.ColorScheme() out := opts.IO.Out - fileName := filepath.Base(workflow.Path) + fileName := workflow.Base() fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Gray(fileName)) fmt.Fprintf(out, "ID: %s", cs.Cyanf("%d", workflow.ID)) @@ -215,7 +213,7 @@ func viewWorkflowInfo(opts *ViewOptions, client *api.Client, workflow *shared.Wo tp := utils.NewTablePrinter(opts.IO) // Header - filename := filepath.Base(workflow.Path) + filename := workflow.Base() fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Cyan(filename)) fmt.Fprintf(out, "ID: %s\n\n", cs.Cyanf("%d", workflow.ID)) diff --git a/pkg/cmd/workflow/view/view_test.go b/pkg/cmd/workflow/view/view_test.go index 434cf0c83..005b5c1f2 100644 --- a/pkg/cmd/workflow/view/view_test.go +++ b/pkg/cmd/workflow/view/view_test.go @@ -90,19 +90,32 @@ func TestNewCmdView(t *testing.T) { }, }, { - name: "ref tty", - cli: "--ref 456", + name: "ref tty", + cli: "--ref 456", + tty: true, + wantsErr: true, + }, + { + name: "ref nontty", + cli: "123 -r 456", + wantsErr: true, + }, + { + name: "yaml ref tty", + cli: "--yaml --ref 456", tty: true, wants: ViewOptions{ Prompt: true, + YAML: true, Ref: "456", }, }, { - name: "ref nontty", - cli: "123 -r 456", + name: "yaml ref nontty", + cli: "123 -y -r 456", wants: ViewOptions{ Raw: true, + YAML: true, Ref: "456", Selector: "123", }, @@ -319,7 +332,7 @@ func TestViewRun(t *testing.T) { ) }, wantErr: true, - wantErrOut: "could not find workflow file flow.yml, try specifying a branch or tag using --ref", + wantErrOut: "could not find workflow file flow.yml, try specifying a branch or tag using `--ref`", }, { name: "workflow with yaml and ref", From 7fc9295786f100df848706f48ae66e37d1698659 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Sun, 4 Apr 2021 21:27:20 -0500 Subject: [PATCH 5/9] support --web for run view --- pkg/cmd/run/view/view.go | 20 ++++++++++++ pkg/cmd/run/view/view_test.go | 59 ++++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index d04668ae8..a15c51e74 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -20,16 +20,22 @@ import ( "github.com/spf13/cobra" ) +type browser interface { + Browse(string) error +} + type ViewOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Browser browser RunID string JobID string Verbose bool ExitStatus bool Log bool + Web bool Prompt bool @@ -41,6 +47,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman IO: f.IOStreams, HttpClient: f.HttpClient, Now: time.Now, + Browser: f.Browser, } cmd := &cobra.Command{ Use: "view []", @@ -86,6 +93,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } } + if opts.Web && opts.Log { + return &cmdutil.FlagError{Err: errors.New("only one of --web or --log can be passed at a time")} + } + if runF != nil { return runF(opts) } @@ -97,6 +108,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run failed") cmd.Flags().StringVarP(&opts.JobID, "job", "j", "", "View a specific job ID from a run") cmd.Flags().BoolVar(&opts.Log, "log", false, "View full log for either a run or specific job") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open run in the browser") return cmd } @@ -155,6 +167,14 @@ func runView(opts *ViewOptions) error { return fmt.Errorf("failed to get run: %w", err) } + if opts.Web { + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(run.URL)) + } + + return opts.Browser.Browse(run.URL) + } + if opts.Prompt { opts.IO.StartProgressIndicator() jobs, err = shared.GetJobs(client, repo, *run) diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 3149957c0..5628cd146 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -36,6 +36,29 @@ func TestNewCmdView(t *testing.T) { Prompt: true, }, }, + { + name: "web tty", + tty: true, + cli: "--web", + wants: ViewOptions{ + Prompt: true, + Web: true, + }, + }, + { + name: "web nontty", + cli: "1234 --web", + wants: ViewOptions{ + Web: true, + RunID: "1234", + }, + }, + { + name: "disallow web and log", + tty: true, + cli: "-w --log", + wantsErr: true, + }, { name: "exit status", cli: "--exit-status 1234", @@ -127,13 +150,14 @@ func TestNewCmdView(t *testing.T) { 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 string + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + opts *ViewOptions + tty bool + wantErr bool + wantOut string + browsedURL string }{ { name: "associate with PR", @@ -431,6 +455,21 @@ func TestViewRun(t *testing.T) { }, wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\nTo see the full job log, try: gh run view --log --job=10\nview this run on GitHub: runs/3\n", }, + { + name: "web", + tty: true, + opts: &ViewOptions{ + RunID: "3", + Web: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + }, + browsedURL: "runs/3", + wantOut: "Opening runs/3 in your browser.\n", + }, } for _, tt := range tests { @@ -458,6 +497,9 @@ func TestViewRun(t *testing.T) { tt.askStubs(as) } + browser := &cmdutil.TestBrowser{} + tt.opts.Browser = browser + t.Run(tt.name, func(t *testing.T) { err := runView(tt.opts) if tt.wantErr { @@ -470,6 +512,9 @@ func TestViewRun(t *testing.T) { assert.NoError(t, err) } assert.Equal(t, tt.wantOut, stdout.String()) + if tt.browsedURL != "" { + assert.Equal(t, tt.browsedURL, browser.BrowsedURL()) + } reg.Verify(t) }) } From 1424e4a973d8770fa695a5e5b0e1c10aaf9d9684 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Sun, 4 Apr 2021 21:33:27 -0500 Subject: [PATCH 6/9] support --web when focusing a job --- pkg/cmd/run/view/view.go | 20 ++++++++++++-------- pkg/cmd/run/view/view_test.go | 20 +++++++++++++++++++- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index a15c51e74..b5ec5cf37 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -167,14 +167,6 @@ func runView(opts *ViewOptions) error { return fmt.Errorf("failed to get run: %w", err) } - if opts.Web { - if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(run.URL)) - } - - return opts.Browser.Browse(run.URL) - } - if opts.Prompt { opts.IO.StartProgressIndicator() jobs, err = shared.GetJobs(client, repo, *run) @@ -190,6 +182,18 @@ func runView(opts *ViewOptions) error { } } + if opts.Web { + url := run.URL + if selectedJob != nil { + url = selectedJob.URL + "?check_suite_focus=true" + } + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url)) + } + + return opts.Browser.Browse(url) + } + opts.IO.StartProgressIndicator() if opts.Log && selectedJob != nil { diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 5628cd146..49eff0fa9 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -456,7 +456,7 @@ func TestViewRun(t *testing.T) { wantOut: "\n✓ trunk successful · 3\nTriggered via push about 59 minutes ago\n\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\nTo see the full job log, try: gh run view --log --job=10\nview this run on GitHub: runs/3\n", }, { - name: "web", + name: "web run", tty: true, opts: &ViewOptions{ RunID: "3", @@ -470,6 +470,24 @@ func TestViewRun(t *testing.T) { browsedURL: "runs/3", wantOut: "Opening runs/3 in your browser.\n", }, + { + name: "web job", + tty: true, + opts: &ViewOptions{ + JobID: "10", + Web: 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/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + }, + browsedURL: "jobs/10?check_suite_focus=true", + wantOut: "Opening jobs/10 in your browser.\n", + }, } for _, tt := range tests { From 3f95e4595fbf1cc813519f18b217d73b2cf48870 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Mon, 5 Apr 2021 13:35:47 -0500 Subject: [PATCH 7/9] tweak error text --- pkg/cmd/run/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index b5ec5cf37..7be430b98 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -94,7 +94,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } if opts.Web && opts.Log { - return &cmdutil.FlagError{Err: errors.New("only one of --web or --log can be passed at a time")} + return &cmdutil.FlagError{Err: errors.New("specify only one of --web or --log")} } if runF != nil { From c8e481e165271cc8238e87cc4a75e18ea1578e38 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Fri, 2 Apr 2021 12:37:01 -0500 Subject: [PATCH 8/9] gh run watch --- go.mod | 1 + pkg/cmd/run/run.go | 2 + pkg/cmd/run/watch/watch.go | 228 ++++++++++++++++++++++ pkg/cmd/run/watch/watch_test.go | 321 +++++++++++++++++++++++++++++++ pkg/iostreams/console.go | 5 + pkg/iostreams/console_windows.go | 21 ++ 6 files changed, 578 insertions(+) create mode 100644 pkg/cmd/run/watch/watch.go create mode 100644 pkg/cmd/run/watch/watch_test.go create mode 100644 pkg/iostreams/console.go create mode 100644 pkg/iostreams/console_windows.go diff --git a/go.mod b/go.mod index 0739022ca..d354aad8d 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/stretchr/testify v1.6.1 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 golang.org/x/sync v0.0.0-20190423024810-112230192c58 + golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d golang.org/x/text v0.3.4 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go index 59b6549d4..7e203c893 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -4,6 +4,7 @@ import ( cmdList "github.com/cli/cli/pkg/cmd/run/list" cmdRerun "github.com/cli/cli/pkg/cmd/run/rerun" cmdView "github.com/cli/cli/pkg/cmd/run/view" + cmdWatch "github.com/cli/cli/pkg/cmd/run/watch" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -23,6 +24,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil)) + cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil)) return cmd } diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go new file mode 100644 index 000000000..86e71e88c --- /dev/null +++ b/pkg/cmd/run/watch/watch.go @@ -0,0 +1,228 @@ +package watch + +import ( + "errors" + "fmt" + "net/http" + "runtime" + "time" + + "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 defaultInterval int = 3 + +type WatchOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + + RunID string + Interval int + ExitStatus bool + + Prompt bool + + Now func() time.Time +} + +func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Command { + opts := &WatchOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Now: time.Now, + } + + cmd := &cobra.Command{ + Use: "watch ", + Short: "Runs until a run completes, showing its progress", + Annotations: map[string]string{ + "IsActions": "true", + }, + 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 watchRun(opts) + }, + } + cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run fails") + cmd.Flags().IntVarP(&opts.Interval, "interval", "i", defaultInterval, "Refresh interval in seconds") + + return cmd +} + +func watchRun(opts *WatchOptions) 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 + var run *shared.Run + + if opts.Prompt { + cs := opts.IO.ColorScheme() + runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool { + return run.Status != shared.Completed + }) + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + if len(runs) == 0 { + return fmt.Errorf("found no in progress runs to watch") + } + runID, err = shared.PromptForRun(cs, runs) + if err != nil { + return err + } + // TODO silly stopgap until dust settles and PromptForRun can just return a run + for _, r := range runs { + if fmt.Sprintf("%d", r.ID) == runID { + run = &r + break + } + } + } else { + run, err = shared.GetRun(client, repo, runID) + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + } + + if run.Status == shared.Completed { + return nil + } + + prNumber := "" + number, err := shared.PullRequestForRun(client, repo, *run) + if err == nil { + prNumber = fmt.Sprintf(" #%d", number) + } + + if runtime.GOOS == "windows" { + opts.IO.EnableVirtualTerminalProcessing() + } + // clear entire screen + fmt.Fprintf(opts.IO.Out, "\x1b[2J") + + annotationCache := map[int][]shared.Annotation{} + + duration, err := time.ParseDuration(fmt.Sprintf("%ds", opts.Interval)) + if err != nil { + return fmt.Errorf("could not parse interval: %w", err) + } + + for run.Status != shared.Completed { + run, err = renderRun(*opts, client, repo, run, prNumber, annotationCache) + if err != nil { + return err + } + time.Sleep(duration) + } + + if opts.ExitStatus && run.Conclusion != shared.Success { + return cmdutil.SilentError + } + + return nil +} + +func renderRun(opts WatchOptions, client *api.Client, repo ghrepo.Interface, run *shared.Run, prNumber string, annotationCache map[int][]shared.Annotation) (*shared.Run, error) { + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + var err error + + run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID)) + if err != nil { + return run, fmt.Errorf("failed to get run: %w", err) + } + + ago := opts.Now().Sub(run.CreatedAt) + + jobs, err := shared.GetJobs(client, repo, *run) + if err != nil { + return run, fmt.Errorf("failed to get jobs: %w", err) + } + + var annotations []shared.Annotation + + var annotationErr error + var as []shared.Annotation + for _, job := range jobs { + if as, ok := annotationCache[job.ID]; ok { + annotations = as + continue + } + + as, annotationErr = shared.GetAnnotations(client, repo, job) + if annotationErr != nil { + break + } + annotations = append(annotations, as...) + + if job.Status != shared.InProgress { + annotationCache[job.ID] = annotations + } + } + + if annotationErr != nil { + return run, fmt.Errorf("failed to get annotations: %w", annotationErr) + } + + if runtime.GOOS == "windows" { + // Just clear whole screen; I wasn't able to get the nicer cursor movement thing working + fmt.Fprintf(opts.IO.Out, "\x1b[2J") + } else { + // Move cursor to 0,0 + fmt.Fprint(opts.IO.Out, "\x1b[0;0H") + // Clear from cursor to bottom of screen + fmt.Fprint(opts.IO.Out, "\x1b[J") + } + + fmt.Fprintln(out, cs.Boldf("Refreshing run status every %d seconds. Press Ctrl+C to quit.", opts.Interval)) + fmt.Fprintln(out) + fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, utils.FuzzyAgo(ago), prNumber)) + fmt.Fprintln(out) + + if len(jobs) == 0 && run.Conclusion == shared.Failure { + return run, nil + } + + fmt.Fprintln(out, cs.Bold("JOBS")) + + fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true)) + + if len(annotations) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, cs.Bold("ANNOTATIONS")) + fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations)) + } + + return run, nil +} diff --git a/pkg/cmd/run/watch/watch_test.go b/pkg/cmd/run/watch/watch_test.go new file mode 100644 index 000000000..b399dacf4 --- /dev/null +++ b/pkg/cmd/run/watch/watch_test.go @@ -0,0 +1,321 @@ +package watch + +import ( + "bytes" + "io/ioutil" + "net/http" + "runtime" + "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 TestNewCmdWatch(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants WatchOptions + wantsErr bool + }{ + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "blank tty", + tty: true, + wants: WatchOptions{ + Prompt: true, + Interval: defaultInterval, + }, + }, + { + name: "interval", + tty: true, + cli: "-i10", + wants: WatchOptions{ + Interval: 10, + Prompt: true, + }, + }, + { + name: "exit status", + cli: "1234 --exit-status", + wants: WatchOptions{ + Interval: defaultInterval, + RunID: "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 *WatchOptions + cmd := NewCmdWatch(f, func(opts *WatchOptions) 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.Interval, gotOpts.Interval) + }) + } +} + +func TestWatchRun(t *testing.T) { + failedRunStubs := func(reg *httpmock.Registry) { + inProgressRun := shared.TestRun("more runs", 2, shared.InProgress, "") + completedRun := shared.TestRun("more runs", 2, shared.Completed, shared.Failure) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.TestRun("run", 1, shared.InProgress, ""), + inProgressRun, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(inProgressRun)) + reg.Register( + httpmock.REST("GET", "runs/2/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{}})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(completedRun)) + reg.Register( + httpmock.REST("GET", "runs/2/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)) + } + successfulRunStubs := func(reg *httpmock.Registry) { + inProgressRun := shared.TestRun("more runs", 2, shared.InProgress, "") + completedRun := shared.TestRun("more runs", 2, shared.Completed, shared.Success) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.TestRun("run", 1, shared.InProgress, ""), + inProgressRun, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(inProgressRun)) + reg.Register( + httpmock.REST("GET", "runs/2/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{})) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/2"), + httpmock.JSONResponse(completedRun)) + reg.Register( + httpmock.REST("GET", "runs/2/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJob, + }, + })) + } + + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + opts *WatchOptions + tty bool + wantErr bool + errMsg string + wantOut string + onlyWindows bool + skipWindows bool + }{ + // TODO exit status respected + { + name: "run ID provided run already completed", + opts: &WatchOptions{ + RunID: "1234", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + }, + wantOut: "", + }, + { + name: "prompt, no in progress runs", + opts: &WatchOptions{ + Prompt: true, + }, + wantErr: true, + errMsg: "found no in progress runs to watch", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.FailedRun, + shared.SuccessfulRun, + }, + })) + }, + }, + { + name: "interval respected", + skipWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + }, + httpStubs: successfulRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n✓ trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n", + }, + { + name: "interval respected, windows", + onlyWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + }, + httpStubs: successfulRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n✓ trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n", + }, + { + name: "exit status respected", + skipWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + ExitStatus: true, + }, + httpStubs: failedRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (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", + wantErr: true, + errMsg: "SilentError", + }, + { + name: "exit status respected, windows", + onlyWindows: true, + opts: &WatchOptions{ + Interval: 0, + Prompt: true, + ExitStatus: true, + }, + httpStubs: failedRunStubs, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) + }, + wantOut: "\x1b[2J\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n\n\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (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", + wantErr: true, + errMsg: "SilentError", + }, + } + + for _, tt := range tests { + if runtime.GOOS == "windows" { + if tt.skipWindows { + continue + } + } else if tt.onlyWindows { + continue + } + + 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 := watchRun(tt.opts) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.errMsg, err.Error()) + if !tt.opts.ExitStatus { + return + } + } + if !tt.opts.ExitStatus { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/iostreams/console.go b/pkg/iostreams/console.go new file mode 100644 index 000000000..6fe541508 --- /dev/null +++ b/pkg/iostreams/console.go @@ -0,0 +1,5 @@ +// +build !windows + +package iostreams + +func (s *IOStreams) EnableVirtualTerminalProcessing() {} diff --git a/pkg/iostreams/console_windows.go b/pkg/iostreams/console_windows.go new file mode 100644 index 000000000..fbe8e16d1 --- /dev/null +++ b/pkg/iostreams/console_windows.go @@ -0,0 +1,21 @@ +// +build windows + +package iostreams + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func (s *IOStreams) EnableVirtualTerminalProcessing() { + if !s.IsStdoutTTY() { + return + } + + stdout := windows.Handle(s.originalOut.(*os.File).Fd()) + + var originalMode uint32 + windows.GetConsoleMode(stdout, &originalMode) + windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) +} From 65524f1ea807991b1ad0891c418b42c287d2dece Mon Sep 17 00:00:00 2001 From: vilmibm Date: Wed, 7 Apr 2021 11:45:49 -0500 Subject: [PATCH 9/9] review feedback --- pkg/cmd/run/watch/watch.go | 29 +++++++++++++++++++---------- pkg/cmd/run/watch/watch_test.go | 12 +++++++----- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go index 86e71e88c..41162e3be 100644 --- a/pkg/cmd/run/watch/watch.go +++ b/pkg/cmd/run/watch/watch.go @@ -41,7 +41,7 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "watch ", - Short: "Runs until a run completes, showing its progress", + Short: "Watch a run until it completes, showing its progress", Annotations: map[string]string{ "IsActions": "true", }, @@ -82,11 +82,13 @@ func watchRun(opts *WatchOptions) error { return fmt.Errorf("failed to determine base repo: %w", err) } + out := opts.IO.Out + cs := opts.IO.ColorScheme() + runID := opts.RunID var run *shared.Run if opts.Prompt { - cs := opts.IO.ColorScheme() runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool { return run.Status != shared.Completed }) @@ -115,6 +117,7 @@ func watchRun(opts *WatchOptions) error { } if run.Status == shared.Completed { + fmt.Fprintf(out, "Run %s (%s) has already completed with '%s'\n", cs.Bold(run.Name), cs.Cyanf("%d", run.ID), run.Conclusion) return nil } @@ -124,11 +127,9 @@ func watchRun(opts *WatchOptions) error { prNumber = fmt.Sprintf(" #%d", number) } - if runtime.GOOS == "windows" { - opts.IO.EnableVirtualTerminalProcessing() - } + opts.IO.EnableVirtualTerminalProcessing() // clear entire screen - fmt.Fprintf(opts.IO.Out, "\x1b[2J") + fmt.Fprintf(out, "\x1b[2J") annotationCache := map[int][]shared.Annotation{} @@ -145,6 +146,14 @@ func watchRun(opts *WatchOptions) error { time.Sleep(duration) } + symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion) + id := cs.Cyanf("%d", run.ID) + + if opts.IO.IsStdoutTTY() { + fmt.Fprintln(out) + fmt.Fprintf(out, "%s Run %s (%s) completed with '%s'\n", symbolColor(symbol), cs.Bold(run.Name), id, run.Conclusion) + } + if opts.ExitStatus && run.Conclusion != shared.Success { return cmdutil.SilentError } @@ -160,14 +169,14 @@ func renderRun(opts WatchOptions, client *api.Client, repo ghrepo.Interface, run run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID)) if err != nil { - return run, fmt.Errorf("failed to get run: %w", err) + return nil, fmt.Errorf("failed to get run: %w", err) } ago := opts.Now().Sub(run.CreatedAt) jobs, err := shared.GetJobs(client, repo, *run) if err != nil { - return run, fmt.Errorf("failed to get jobs: %w", err) + return nil, fmt.Errorf("failed to get jobs: %w", err) } var annotations []shared.Annotation @@ -192,7 +201,7 @@ func renderRun(opts WatchOptions, client *api.Client, repo ghrepo.Interface, run } if annotationErr != nil { - return run, fmt.Errorf("failed to get annotations: %w", annotationErr) + return nil, fmt.Errorf("failed to get annotations: %w", annotationErr) } if runtime.GOOS == "windows" { @@ -210,7 +219,7 @@ func renderRun(opts WatchOptions, client *api.Client, repo ghrepo.Interface, run fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, utils.FuzzyAgo(ago), prNumber)) fmt.Fprintln(out) - if len(jobs) == 0 && run.Conclusion == shared.Failure { + if len(jobs) == 0 { return run, nil } diff --git a/pkg/cmd/run/watch/watch_test.go b/pkg/cmd/run/watch/watch_test.go index b399dacf4..4152aad52 100644 --- a/pkg/cmd/run/watch/watch_test.go +++ b/pkg/cmd/run/watch/watch_test.go @@ -178,7 +178,6 @@ func TestWatchRun(t *testing.T) { onlyWindows bool skipWindows bool }{ - // TODO exit status respected { name: "run ID provided run already completed", opts: &WatchOptions{ @@ -189,10 +188,11 @@ func TestWatchRun(t *testing.T) { httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), httpmock.JSONResponse(shared.FailedRun)) }, - wantOut: "", + wantOut: "Run failed (1234) has already completed with 'failure'\n", }, { name: "prompt, no in progress runs", + tty: true, opts: &WatchOptions{ Prompt: true, }, @@ -212,6 +212,7 @@ func TestWatchRun(t *testing.T) { { name: "interval respected", skipWindows: true, + tty: true, opts: &WatchOptions{ Interval: 0, Prompt: true, @@ -220,7 +221,7 @@ func TestWatchRun(t *testing.T) { askStubs: func(as *prompt.AskStubber) { as.StubOne(1) }, - wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n✓ trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n", + wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n✓ trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n✓ cool job in 4m34s (ID 10)\n ✓ fob the barz\n ✓ barz the fob\n\n✓ Run more runs (2) completed with 'success'\n", }, { name: "interval respected, windows", @@ -237,6 +238,7 @@ func TestWatchRun(t *testing.T) { }, { name: "exit status respected", + tty: true, skipWindows: true, opts: &WatchOptions{ Interval: 0, @@ -247,7 +249,7 @@ func TestWatchRun(t *testing.T) { askStubs: func(as *prompt.AskStubber) { as.StubOne(1) }, - wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (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", + wantOut: "\x1b[2J\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\n\x1b[0;0H\x1b[JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (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\nX Run more runs (2) completed with 'failure'\n", wantErr: true, errMsg: "SilentError", }, @@ -263,7 +265,7 @@ func TestWatchRun(t *testing.T) { askStubs: func(as *prompt.AskStubber) { as.StubOne(1) }, - wantOut: "\x1b[2J\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\n\n\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (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", + wantOut: "\x1b[2J\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\n- trunk more runs · 2\nTriggered via push about 59 minutes ago\n\n\x1b[2JRefreshing run status every 0 seconds. Press Ctrl+C to quit.\n\nX trunk more runs · 2\nTriggered via push about 59 minutes ago\n\nJOBS\nX sad job in 4m34s (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", wantErr: true, errMsg: "SilentError", },