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..dbcabcb81 --- /dev/null +++ b/pkg/cmd/workflow/view/view.go @@ -0,0 +1,268 @@ +package view + +import ( + "errors" + "fmt" + "net/http" + "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.IsStdoutTTY() + + 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 !opts.YAML && opts.Ref != "" { + return &cmdutil.FlagError{Err: errors.New("`--yaml` required when specifying `--ref`")} + } + + 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 + 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 = ghrepo.GenerateRepoURL(repo, "blob/%s/%s", ref, workflow.Path) + } else { + 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)) + } + 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 && 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) + } + + 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 := 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)) + + 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 := 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)) + + // 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..005b5c1f2 --- /dev/null +++ b/pkg/cmd/workflow/view/view_test.go @@ -0,0 +1,438 @@ +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, + 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: "yaml ref nontty", + cli: "123 -y -r 456", + wants: ViewOptions{ + Raw: true, + YAML: 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 }