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") + } +} 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 5ee80f513..7bb45b4ce 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -5,6 +5,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" ) @@ -25,6 +26,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil)) cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) + cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil)) return cmd } 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/run/view/view.go b/pkg/cmd/run/view/view.go index 31494879b..1204d31d6 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -21,16 +21,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 @@ -42,6 +48,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 []", @@ -87,6 +94,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } } + if opts.Web && opts.Log { + return &cmdutil.FlagError{Err: errors.New("specify only one of --web or --log")} + } + if runF != nil { return runF(opts) } @@ -98,6 +109,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 } @@ -171,6 +183,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 b88995e07..657bd0869 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -37,6 +37,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", @@ -128,13 +151,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", @@ -492,6 +516,39 @@ 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 run", + 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", + }, + { + 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 { @@ -519,6 +576,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 { @@ -531,6 +591,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) }) } diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go new file mode 100644 index 000000000..41162e3be --- /dev/null +++ b/pkg/cmd/run/watch/watch.go @@ -0,0 +1,237 @@ +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: "Watch a run until it 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) + } + + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + runID := opts.RunID + var run *shared.Run + + if opts.Prompt { + 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 { + 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 + } + + prNumber := "" + number, err := shared.PullRequestForRun(client, repo, *run) + if err == nil { + prNumber = fmt.Sprintf(" #%d", number) + } + + opts.IO.EnableVirtualTerminalProcessing() + // clear entire screen + fmt.Fprintf(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) + } + + 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 + } + + 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 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 nil, 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 nil, 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 { + 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..4152aad52 --- /dev/null +++ b/pkg/cmd/run/watch/watch_test.go @@ -0,0 +1,323 @@ +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 + }{ + { + 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: "Run failed (1234) has already completed with 'failure'\n", + }, + { + name: "prompt, no in progress runs", + tty: true, + 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, + tty: 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\n✓ Run more runs (2) completed with 'success'\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", + tty: true, + 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\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", + }, + { + 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\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/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 } 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) +}