diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go new file mode 100644 index 000000000..45bc320e1 --- /dev/null +++ b/pkg/cmd/run/rerun/rerun.go @@ -0,0 +1,120 @@ +package rerun + +import ( + "errors" + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/run/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type RerunOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RunID string + + Prompt bool +} + +func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Command { + opts := &RerunOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "rerun []", + Short: "Rerun a given run", + Args: cobra.MaximumNArgs(1), + 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 runRerun(opts) + }, + } + + return cmd +} + +func runRerun(opts *RerunOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("failed to create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + repo, err := opts.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repo: %w", err) + } + + runID := opts.RunID + + if opts.Prompt { + cs := opts.IO.ColorScheme() + runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool { + if run.Status != shared.Completed { + return false + } + // TODO StartupFailure indiciates a bad yaml file; such runs can never be + // rerun. But hiding them from the prompt might confuse people? + return run.Conclusion != shared.Success && run.Conclusion != shared.StartupFailure + }) + if err != nil { + return fmt.Errorf("failed to get runs: %w", err) + } + if len(runs) == 0 { + return errors.New("no recent runs have failed; please specify a specific run ID") + } + runID, err = shared.PromptForRun(cs, runs) + if err != nil { + return err + } + } + + opts.IO.StartProgressIndicator() + run, err := shared.GetRun(client, repo, runID) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get run: %w", err) + } + + path := fmt.Sprintf("repos/%s/actions/runs/%d/rerun", ghrepo.FullName(repo), run.ID) + + err = client.REST(repo.RepoHost(), "POST", path, nil, nil) + if err != nil { + var httpError api.HTTPError + if errors.As(err, &httpError) && httpError.StatusCode == 403 { + return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken.", run.ID) + } + return fmt.Errorf("failed to rerun: %w", err) + } + + if opts.IO.CanPrompt() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s Requested rerun of run %s\n", + cs.SuccessIcon(), + cs.Cyanf("%d", run.ID)) + } + + return nil +} diff --git a/pkg/cmd/run/rerun/rerun_test.go b/pkg/cmd/run/rerun/rerun_test.go new file mode 100644 index 000000000..585fa93f3 --- /dev/null +++ b/pkg/cmd/run/rerun/rerun_test.go @@ -0,0 +1,214 @@ +package rerun + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/run/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdRerun(t *testing.T) { + tests := []struct { + name string + cli string + tty bool + wants RerunOptions + wantsErr bool + }{ + { + name: "blank nontty", + wantsErr: true, + }, + { + name: "blank tty", + tty: true, + wants: RerunOptions{ + Prompt: true, + }, + }, + { + name: "with arg nontty", + cli: "1234", + wants: RerunOptions{ + RunID: "1234", + }, + }, + { + name: "with arg tty", + tty: true, + cli: "1234", + wants: RerunOptions{ + RunID: "1234", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *RerunOptions + cmd := NewCmdRerun(f, func(opts *RerunOptions) 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) + }) + } + +} + +func TestRerun(t *testing.T) { + tests := []struct { + name string + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + opts *RerunOptions + tty bool + wantErr bool + ErrOut string + wantOut string + }{ + { + name: "arg", + tty: true, + opts: &RerunOptions{ + RunID: "1234", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun"), + httpmock.StringResponse("{}")) + }, + wantOut: "✓ Requested rerun of run 1234\n", + }, + { + name: "prompt", + tty: true, + opts: &RerunOptions{ + Prompt: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun"), + httpmock.StringResponse("{}")) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(2) + }, + wantOut: "✓ Requested rerun of run 1234\n", + }, + { + name: "prompt but no failed runs", + tty: true, + opts: &RerunOptions{ + Prompt: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: []shared.Run{ + shared.SuccessfulRun, + shared.TestRun("in progress", 2, shared.InProgress, ""), + }})) + }, + wantErr: true, + ErrOut: "no recent runs have failed; please specify a specific run ID", + }, + { + name: "unrerunnable", + tty: true, + opts: &RerunOptions{ + RunID: "3", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/3/rerun"), + httpmock.StatusStringResponse(403, "no")) + }, + wantErr: true, + ErrOut: "run 3 cannot be rerun; its workflow file may be broken.", + }, + } + + 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.SetStdinTTY(tt.tty) + 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 := runRerun(tt.opts) + if tt.wantErr { + assert.Error(t, err) + assert.Equal(t, tt.ErrOut, err.Error()) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/run/run.go b/pkg/cmd/run/run.go index a3aacdcdc..59b6549d4 100644 --- a/pkg/cmd/run/run.go +++ b/pkg/cmd/run/run.go @@ -2,6 +2,7 @@ package run 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" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -21,6 +22,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)) return cmd } diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 79489e61d..45f98abc3 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -151,6 +151,25 @@ type RunsPayload struct { WorkflowRuns []Run `json:"workflow_runs"` } +func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, limit int, f func(Run) bool) ([]Run, error) { + path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo)) + runs, err := getRuns(client, repo, path, 50) + if err != nil { + return nil, err + } + filtered := []Run{} + for _, run := range runs { + if f(run) { + filtered = append(filtered, run) + } + if len(filtered) == limit { + break + } + } + + return filtered, nil +} + func GetRunsByWorkflow(client *api.Client, repo ghrepo.Interface, limit, workflowID int) ([]Run, error) { path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), workflowID) return getRuns(client, repo, path, limit) @@ -173,9 +192,17 @@ func getRuns(client *api.Client, repo ghrepo.Interface, path string, limit int) for len(runs) < limit { var result RunsPayload - pagedPath := fmt.Sprintf("%s?per_page=%d&page=%d", path, perPage, page) + parsed, err := url.Parse(path) + if err != nil { + return nil, err + } + query := parsed.Query() + query.Set("per_page", fmt.Sprintf("%d", perPage)) + query.Set("page", fmt.Sprintf("%d", page)) + parsed.RawQuery = query.Encode() + pagedPath := parsed.String() - err := client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result) + err = client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result) if err != nil { return nil, err }