Merge pull request #5275 from cdb/cdb/rerun-failed-jobs

Support "all failed jobs" and individual job re-run for a github actions workflow run
This commit is contained in:
Nate Smith 2022-03-15 12:08:36 -05:00 committed by GitHub
commit 1c260191ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 186 additions and 44 deletions

View file

@ -18,7 +18,9 @@ type RerunOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
RunID string
RunID string
OnlyFailed bool
JobID string
Prompt bool
}
@ -37,12 +39,18 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
if len(args) == 0 && opts.JobID == "" {
if !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("`<run-id>` or `--job` required when not running interactively")
} else {
opts.Prompt = true
}
} else if len(args) > 0 {
opts.RunID = args[0]
} else if !opts.IO.CanPrompt() {
return cmdutil.FlagErrorf("run ID required when not running interactively")
} else {
opts.Prompt = true
}
if opts.RunID != "" && opts.JobID != "" {
return cmdutil.FlagErrorf("specify only one of `<run-id>` or `--job`")
}
if runF != nil {
@ -52,6 +60,9 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm
},
}
cmd.Flags().BoolVar(&opts.OnlyFailed, "failed", false, "Rerun only failed jobs, including dependencies")
cmd.Flags().StringVarP(&opts.JobID, "job", "j", "", "Rerun a specific job from a run, including dependencies")
return cmd
}
@ -67,10 +78,23 @@ func runRerun(opts *RerunOptions) error {
return fmt.Errorf("failed to determine base repo: %w", err)
}
cs := opts.IO.ColorScheme()
runID := opts.RunID
jobID := opts.JobID
var selectedJob *shared.Job
if jobID != "" {
opts.IO.StartProgressIndicator()
selectedJob, err = shared.GetJob(client, repo, jobID)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
runID = fmt.Sprintf("%d", selectedJob.RunID)
}
if opts.Prompt {
cs := opts.IO.ColorScheme()
runs, err := shared.GetRunsWithFilter(client, repo, nil, 10, func(run shared.Run) bool {
if run.Status != shared.Completed {
return false
@ -83,7 +107,7 @@ func runRerun(opts *RerunOptions) error {
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")
return errors.New("no recent runs have failed; please specify a specific `<run-id>`")
}
runID, err = shared.PromptForRun(cs, runs)
if err != nil {
@ -91,30 +115,73 @@ func runRerun(opts *RerunOptions) error {
}
}
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)
if opts.JobID != "" {
err = rerunJob(client, repo, selectedJob)
if err != nil {
return err
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "%s Requested rerun of job %s on run %s\n",
cs.SuccessIcon(),
cs.Cyanf("%d", selectedJob.ID),
cs.Cyanf("%d", selectedJob.RunID))
}
} else {
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)
}
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))
err = rerunRun(client, repo, run, opts.OnlyFailed)
if err != nil {
return err
}
if opts.IO.IsStdoutTTY() {
onlyFailedMsg := ""
if opts.OnlyFailed {
onlyFailedMsg = "(failed jobs) "
}
fmt.Fprintf(opts.IO.Out, "%s Requested rerun %sof run %s\n",
cs.SuccessIcon(),
onlyFailedMsg,
cs.Cyanf("%d", run.ID))
}
}
return nil
}
func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFailed bool) error {
runVerb := "rerun"
if onlyFailed {
runVerb = "rerun-failed-jobs"
}
path := fmt.Sprintf("repos/%s/actions/runs/%d/%s", ghrepo.FullName(repo), run.ID, runVerb)
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)
}
return nil
}
func rerunJob(client *api.Client, repo ghrepo.Interface, job *shared.Job) error {
path := fmt.Sprintf("repos/%s/actions/jobs/%d/rerun", ghrepo.FullName(repo), job.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("job %d cannot be rerun", job.ID)
}
return fmt.Errorf("failed to rerun: %w", err)
}
return nil
}

View file

@ -50,6 +50,48 @@ func TestNewCmdRerun(t *testing.T) {
RunID: "1234",
},
},
{
name: "failed arg nontty",
cli: "4321 --failed",
wants: RerunOptions{
RunID: "4321",
OnlyFailed: true,
},
},
{
name: "failed arg",
tty: true,
cli: "--failed",
wants: RerunOptions{
Prompt: true,
OnlyFailed: true,
},
},
{
name: "with arg job",
tty: true,
cli: "--job 1234",
wants: RerunOptions{
JobID: "1234",
},
},
{
name: "with args jobID and runID fails",
tty: true,
cli: "1234 --job 5678",
wantsErr: true,
},
{
name: "with arg job with no ID fails",
tty: true,
cli: "--job",
wantsErr: true,
},
{
name: "with arg job with no ID no tty fails",
cli: "--job",
wantsErr: true,
},
}
for _, tt := range tests {
@ -117,6 +159,39 @@ func TestRerun(t *testing.T) {
},
wantOut: "✓ Requested rerun of run 1234\n",
},
{
name: "arg including onlyFailed",
tty: true,
opts: &RerunOptions{
RunID: "1234",
OnlyFailed: true,
},
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-failed-jobs"),
httpmock.StringResponse("{}"))
},
wantOut: "✓ Requested rerun (failed jobs) of run 1234\n",
},
{
name: "arg including a specific job",
tty: true,
opts: &RerunOptions{
JobID: "20", // 20 is shared.FailedJob.ID
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"),
httpmock.JSONResponse(shared.FailedJob))
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/actions/jobs/20/rerun"),
httpmock.StringResponse("{}"))
},
wantOut: "✓ Requested rerun of job 20 on run 1234\n",
},
{
name: "prompt",
tty: true,
@ -158,7 +233,7 @@ func TestRerun(t *testing.T) {
}}))
},
wantErr: true,
errOut: "no recent runs have failed; please specify a specific run ID",
errOut: "no recent runs have failed; please specify a specific `<run-id>`",
},
{
name: "unrerunnable",
@ -175,7 +250,7 @@ func TestRerun(t *testing.T) {
httpmock.StatusStringResponse(403, "no"))
},
wantErr: true,
errOut: "run 3 cannot be rerun; its workflow file may be broken.",
errOut: "run 3 cannot be rerun; its workflow file may be broken",
},
}

View file

@ -307,6 +307,18 @@ func GetJobs(client *api.Client, repo ghrepo.Interface, run Run) ([]Job, error)
return result.Jobs, nil
}
func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, error) {
path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID)
var result Job
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) {
var selected int
now := time.Now()

View file

@ -183,7 +183,7 @@ func runView(opts *ViewOptions) error {
if jobID != "" {
opts.IO.StartProgressIndicator()
selectedJob, err = getJob(client, repo, jobID)
selectedJob, err = shared.GetJob(client, repo, jobID)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
@ -395,18 +395,6 @@ func runView(opts *ViewOptions) error {
return nil
}
func getJob(client *api.Client, repo ghrepo.Interface, jobID string) (*shared.Job, error) {
path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID)
var result shared.Job
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) {
req, err := http.NewRequest("GET", logURL, nil)
if err != nil {