Skip to content

Commit 216cfb6

Browse files
author
Nate Smith
authored
Merge pull request cli#3333 from cli/run-rerun
gh run rerun
2 parents 9fe7326 + 238a371 commit 216cfb6

File tree

4 files changed

+365
-2
lines changed

4 files changed

+365
-2
lines changed

pkg/cmd/run/rerun/rerun.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package rerun
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/cli/cli/api"
9+
"github.com/cli/cli/internal/ghrepo"
10+
"github.com/cli/cli/pkg/cmd/run/shared"
11+
"github.com/cli/cli/pkg/cmdutil"
12+
"github.com/cli/cli/pkg/iostreams"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type RerunOptions struct {
17+
HttpClient func() (*http.Client, error)
18+
IO *iostreams.IOStreams
19+
BaseRepo func() (ghrepo.Interface, error)
20+
21+
RunID string
22+
23+
Prompt bool
24+
}
25+
26+
func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Command {
27+
opts := &RerunOptions{
28+
IO: f.IOStreams,
29+
HttpClient: f.HttpClient,
30+
}
31+
32+
cmd := &cobra.Command{
33+
Use: "rerun [<run-id>]",
34+
Short: "Rerun a given run",
35+
Args: cobra.MaximumNArgs(1),
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
// support `-R, --repo` override
38+
opts.BaseRepo = f.BaseRepo
39+
40+
if len(args) > 0 {
41+
opts.RunID = args[0]
42+
} else if !opts.IO.CanPrompt() {
43+
return &cmdutil.FlagError{Err: errors.New("run ID required when not running interactively")}
44+
} else {
45+
opts.Prompt = true
46+
}
47+
48+
if runF != nil {
49+
return runF(opts)
50+
}
51+
return runRerun(opts)
52+
},
53+
}
54+
55+
return cmd
56+
}
57+
58+
func runRerun(opts *RerunOptions) error {
59+
c, err := opts.HttpClient()
60+
if err != nil {
61+
return fmt.Errorf("failed to create http client: %w", err)
62+
}
63+
client := api.NewClientFromHTTP(c)
64+
65+
repo, err := opts.BaseRepo()
66+
if err != nil {
67+
return fmt.Errorf("failed to determine base repo: %w", err)
68+
}
69+
70+
runID := opts.RunID
71+
72+
if opts.Prompt {
73+
cs := opts.IO.ColorScheme()
74+
runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool {
75+
if run.Status != shared.Completed {
76+
return false
77+
}
78+
// TODO StartupFailure indiciates a bad yaml file; such runs can never be
79+
// rerun. But hiding them from the prompt might confuse people?
80+
return run.Conclusion != shared.Success && run.Conclusion != shared.StartupFailure
81+
})
82+
if err != nil {
83+
return fmt.Errorf("failed to get runs: %w", err)
84+
}
85+
if len(runs) == 0 {
86+
return errors.New("no recent runs have failed; please specify a specific run ID")
87+
}
88+
runID, err = shared.PromptForRun(cs, runs)
89+
if err != nil {
90+
return err
91+
}
92+
}
93+
94+
opts.IO.StartProgressIndicator()
95+
run, err := shared.GetRun(client, repo, runID)
96+
opts.IO.StopProgressIndicator()
97+
if err != nil {
98+
return fmt.Errorf("failed to get run: %w", err)
99+
}
100+
101+
path := fmt.Sprintf("repos/%s/actions/runs/%d/rerun", ghrepo.FullName(repo), run.ID)
102+
103+
err = client.REST(repo.RepoHost(), "POST", path, nil, nil)
104+
if err != nil {
105+
var httpError api.HTTPError
106+
if errors.As(err, &httpError) && httpError.StatusCode == 403 {
107+
return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken.", run.ID)
108+
}
109+
return fmt.Errorf("failed to rerun: %w", err)
110+
}
111+
112+
if opts.IO.CanPrompt() {
113+
cs := opts.IO.ColorScheme()
114+
fmt.Fprintf(opts.IO.Out, "%s Requested rerun of run %s\n",
115+
cs.SuccessIcon(),
116+
cs.Cyanf("%d", run.ID))
117+
}
118+
119+
return nil
120+
}

pkg/cmd/run/rerun/rerun_test.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package rerun
2+
3+
import (
4+
"bytes"
5+
"io/ioutil"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/cli/cli/internal/ghrepo"
10+
"github.com/cli/cli/pkg/cmd/run/shared"
11+
"github.com/cli/cli/pkg/cmdutil"
12+
"github.com/cli/cli/pkg/httpmock"
13+
"github.com/cli/cli/pkg/iostreams"
14+
"github.com/cli/cli/pkg/prompt"
15+
"github.com/google/shlex"
16+
"github.com/stretchr/testify/assert"
17+
)
18+
19+
func TestNewCmdRerun(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
cli string
23+
tty bool
24+
wants RerunOptions
25+
wantsErr bool
26+
}{
27+
{
28+
name: "blank nontty",
29+
wantsErr: true,
30+
},
31+
{
32+
name: "blank tty",
33+
tty: true,
34+
wants: RerunOptions{
35+
Prompt: true,
36+
},
37+
},
38+
{
39+
name: "with arg nontty",
40+
cli: "1234",
41+
wants: RerunOptions{
42+
RunID: "1234",
43+
},
44+
},
45+
{
46+
name: "with arg tty",
47+
tty: true,
48+
cli: "1234",
49+
wants: RerunOptions{
50+
RunID: "1234",
51+
},
52+
},
53+
}
54+
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
io, _, _, _ := iostreams.Test()
58+
io.SetStdinTTY(tt.tty)
59+
io.SetStdoutTTY(tt.tty)
60+
61+
f := &cmdutil.Factory{
62+
IOStreams: io,
63+
}
64+
65+
argv, err := shlex.Split(tt.cli)
66+
assert.NoError(t, err)
67+
68+
var gotOpts *RerunOptions
69+
cmd := NewCmdRerun(f, func(opts *RerunOptions) error {
70+
gotOpts = opts
71+
return nil
72+
})
73+
cmd.SetArgs(argv)
74+
cmd.SetIn(&bytes.Buffer{})
75+
cmd.SetOut(ioutil.Discard)
76+
cmd.SetErr(ioutil.Discard)
77+
78+
_, err = cmd.ExecuteC()
79+
if tt.wantsErr {
80+
assert.Error(t, err)
81+
return
82+
}
83+
84+
assert.NoError(t, err)
85+
86+
assert.Equal(t, tt.wants.RunID, gotOpts.RunID)
87+
assert.Equal(t, tt.wants.Prompt, gotOpts.Prompt)
88+
})
89+
}
90+
91+
}
92+
93+
func TestRerun(t *testing.T) {
94+
tests := []struct {
95+
name string
96+
httpStubs func(*httpmock.Registry)
97+
askStubs func(*prompt.AskStubber)
98+
opts *RerunOptions
99+
tty bool
100+
wantErr bool
101+
ErrOut string
102+
wantOut string
103+
}{
104+
{
105+
name: "arg",
106+
tty: true,
107+
opts: &RerunOptions{
108+
RunID: "1234",
109+
},
110+
httpStubs: func(reg *httpmock.Registry) {
111+
reg.Register(
112+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
113+
httpmock.JSONResponse(shared.FailedRun))
114+
reg.Register(
115+
httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun"),
116+
httpmock.StringResponse("{}"))
117+
},
118+
wantOut: "✓ Requested rerun of run 1234\n",
119+
},
120+
{
121+
name: "prompt",
122+
tty: true,
123+
opts: &RerunOptions{
124+
Prompt: true,
125+
},
126+
httpStubs: func(reg *httpmock.Registry) {
127+
reg.Register(
128+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
129+
httpmock.JSONResponse(shared.RunsPayload{
130+
WorkflowRuns: shared.TestRuns,
131+
}))
132+
reg.Register(
133+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
134+
httpmock.JSONResponse(shared.FailedRun))
135+
reg.Register(
136+
httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun"),
137+
httpmock.StringResponse("{}"))
138+
},
139+
askStubs: func(as *prompt.AskStubber) {
140+
as.StubOne(2)
141+
},
142+
wantOut: "✓ Requested rerun of run 1234\n",
143+
},
144+
{
145+
name: "prompt but no failed runs",
146+
tty: true,
147+
opts: &RerunOptions{
148+
Prompt: true,
149+
},
150+
httpStubs: func(reg *httpmock.Registry) {
151+
reg.Register(
152+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
153+
httpmock.JSONResponse(shared.RunsPayload{
154+
WorkflowRuns: []shared.Run{
155+
shared.SuccessfulRun,
156+
shared.TestRun("in progress", 2, shared.InProgress, ""),
157+
}}))
158+
},
159+
wantErr: true,
160+
ErrOut: "no recent runs have failed; please specify a specific run ID",
161+
},
162+
{
163+
name: "unrerunnable",
164+
tty: true,
165+
opts: &RerunOptions{
166+
RunID: "3",
167+
},
168+
httpStubs: func(reg *httpmock.Registry) {
169+
reg.Register(
170+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
171+
httpmock.JSONResponse(shared.SuccessfulRun))
172+
reg.Register(
173+
httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/3/rerun"),
174+
httpmock.StatusStringResponse(403, "no"))
175+
},
176+
wantErr: true,
177+
ErrOut: "run 3 cannot be rerun; its workflow file may be broken.",
178+
},
179+
}
180+
181+
for _, tt := range tests {
182+
reg := &httpmock.Registry{}
183+
tt.httpStubs(reg)
184+
tt.opts.HttpClient = func() (*http.Client, error) {
185+
return &http.Client{Transport: reg}, nil
186+
}
187+
188+
io, _, stdout, _ := iostreams.Test()
189+
io.SetStdinTTY(tt.tty)
190+
io.SetStdoutTTY(tt.tty)
191+
tt.opts.IO = io
192+
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
193+
return ghrepo.FromFullName("OWNER/REPO")
194+
}
195+
196+
as, teardown := prompt.InitAskStubber()
197+
defer teardown()
198+
if tt.askStubs != nil {
199+
tt.askStubs(as)
200+
}
201+
202+
t.Run(tt.name, func(t *testing.T) {
203+
err := runRerun(tt.opts)
204+
if tt.wantErr {
205+
assert.Error(t, err)
206+
assert.Equal(t, tt.ErrOut, err.Error())
207+
return
208+
}
209+
assert.NoError(t, err)
210+
assert.Equal(t, tt.wantOut, stdout.String())
211+
reg.Verify(t)
212+
})
213+
}
214+
}

pkg/cmd/run/run.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package run
22

33
import (
44
cmdList "github.com/cli/cli/pkg/cmd/run/list"
5+
cmdRerun "github.com/cli/cli/pkg/cmd/run/rerun"
56
cmdView "github.com/cli/cli/pkg/cmd/run/view"
67
"github.com/cli/cli/pkg/cmdutil"
78
"github.com/spf13/cobra"
@@ -21,6 +22,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command {
2122

2223
cmd.AddCommand(cmdList.NewCmdList(f, nil))
2324
cmd.AddCommand(cmdView.NewCmdView(f, nil))
25+
cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil))
2426

2527
return cmd
2628
}

pkg/cmd/run/shared/shared.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,25 @@ type RunsPayload struct {
151151
WorkflowRuns []Run `json:"workflow_runs"`
152152
}
153153

154+
func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, limit int, f func(Run) bool) ([]Run, error) {
155+
path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
156+
runs, err := getRuns(client, repo, path, 50)
157+
if err != nil {
158+
return nil, err
159+
}
160+
filtered := []Run{}
161+
for _, run := range runs {
162+
if f(run) {
163+
filtered = append(filtered, run)
164+
}
165+
if len(filtered) == limit {
166+
break
167+
}
168+
}
169+
170+
return filtered, nil
171+
}
172+
154173
func GetRunsByWorkflow(client *api.Client, repo ghrepo.Interface, limit, workflowID int) ([]Run, error) {
155174
path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), workflowID)
156175
return getRuns(client, repo, path, limit)
@@ -173,9 +192,17 @@ func getRuns(client *api.Client, repo ghrepo.Interface, path string, limit int)
173192
for len(runs) < limit {
174193
var result RunsPayload
175194

176-
pagedPath := fmt.Sprintf("%s?per_page=%d&page=%d", path, perPage, page)
195+
parsed, err := url.Parse(path)
196+
if err != nil {
197+
return nil, err
198+
}
199+
query := parsed.Query()
200+
query.Set("per_page", fmt.Sprintf("%d", perPage))
201+
query.Set("page", fmt.Sprintf("%d", page))
202+
parsed.RawQuery = query.Encode()
203+
pagedPath := parsed.String()
177204

178-
err := client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result)
205+
err = client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result)
179206
if err != nil {
180207
return nil, err
181208
}

0 commit comments

Comments
 (0)