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:
commit
1c260191ee
4 changed files with 186 additions and 44 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue