-
Notifications
You must be signed in to change notification settings - Fork 6.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3333 from cli/run-rerun
gh run rerun
- Loading branch information
Showing
4 changed files
with
365 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 [<run-id>]", | ||
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters