Skip to content

Commit

Permalink
Merge pull request #3333 from cli/run-rerun
Browse files Browse the repository at this point in the history
gh run rerun
  • Loading branch information
Nate Smith authored Apr 2, 2021
2 parents 9fe7326 + 238a371 commit 216cfb6
Show file tree
Hide file tree
Showing 4 changed files with 365 additions and 2 deletions.
120 changes: 120 additions & 0 deletions pkg/cmd/run/rerun/rerun.go
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
}
214 changes: 214 additions & 0 deletions pkg/cmd/run/rerun/rerun_test.go
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)
})
}
}
2 changes: 2 additions & 0 deletions pkg/cmd/run/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}
31 changes: 29 additions & 2 deletions pkg/cmd/run/shared/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down

0 comments on commit 216cfb6

Please sign in to comment.