github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/run/rerun/rerun.go (about)

     1  package rerun
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  
    11  	"github.com/ungtb10d/cli/v2/api"
    12  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    13  	"github.com/ungtb10d/cli/v2/pkg/cmd/run/shared"
    14  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    15  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    16  	"github.com/spf13/cobra"
    17  )
    18  
    19  type RerunOptions struct {
    20  	HttpClient func() (*http.Client, error)
    21  	IO         *iostreams.IOStreams
    22  	BaseRepo   func() (ghrepo.Interface, error)
    23  
    24  	RunID      string
    25  	OnlyFailed bool
    26  	JobID      string
    27  	Debug      bool
    28  
    29  	Prompt bool
    30  }
    31  
    32  func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Command {
    33  	opts := &RerunOptions{
    34  		IO:         f.IOStreams,
    35  		HttpClient: f.HttpClient,
    36  	}
    37  
    38  	cmd := &cobra.Command{
    39  		Use:   "rerun [<run-id>]",
    40  		Short: "Rerun a failed run",
    41  		Args:  cobra.MaximumNArgs(1),
    42  		RunE: func(cmd *cobra.Command, args []string) error {
    43  			// support `-R, --repo` override
    44  			opts.BaseRepo = f.BaseRepo
    45  
    46  			if len(args) == 0 && opts.JobID == "" {
    47  				if !opts.IO.CanPrompt() {
    48  					return cmdutil.FlagErrorf("`<run-id>` or `--job` required when not running interactively")
    49  				} else {
    50  					opts.Prompt = true
    51  				}
    52  			} else if len(args) > 0 {
    53  				opts.RunID = args[0]
    54  			}
    55  
    56  			if opts.RunID != "" && opts.JobID != "" {
    57  				return cmdutil.FlagErrorf("specify only one of `<run-id>` or `--job`")
    58  			}
    59  
    60  			if runF != nil {
    61  				return runF(opts)
    62  			}
    63  			return runRerun(opts)
    64  		},
    65  	}
    66  
    67  	cmd.Flags().BoolVar(&opts.OnlyFailed, "failed", false, "Rerun only failed jobs, including dependencies")
    68  	cmd.Flags().StringVarP(&opts.JobID, "job", "j", "", "Rerun a specific job from a run, including dependencies")
    69  	cmd.Flags().BoolVarP(&opts.Debug, "debug", "d", false, "Rerun with debug logging")
    70  
    71  	return cmd
    72  }
    73  
    74  func runRerun(opts *RerunOptions) error {
    75  	c, err := opts.HttpClient()
    76  	if err != nil {
    77  		return fmt.Errorf("failed to create http client: %w", err)
    78  	}
    79  	client := api.NewClientFromHTTP(c)
    80  
    81  	repo, err := opts.BaseRepo()
    82  	if err != nil {
    83  		return fmt.Errorf("failed to determine base repo: %w", err)
    84  	}
    85  
    86  	cs := opts.IO.ColorScheme()
    87  
    88  	runID := opts.RunID
    89  	jobID := opts.JobID
    90  	var selectedJob *shared.Job
    91  
    92  	if jobID != "" {
    93  		opts.IO.StartProgressIndicator()
    94  		selectedJob, err = shared.GetJob(client, repo, jobID)
    95  		opts.IO.StopProgressIndicator()
    96  		if err != nil {
    97  			return fmt.Errorf("failed to get job: %w", err)
    98  		}
    99  		runID = fmt.Sprintf("%d", selectedJob.RunID)
   100  	}
   101  
   102  	if opts.Prompt {
   103  		runs, err := shared.GetRunsWithFilter(client, repo, nil, 10, func(run shared.Run) bool {
   104  			if run.Status != shared.Completed {
   105  				return false
   106  			}
   107  			// TODO StartupFailure indicates a bad yaml file; such runs can never be
   108  			// rerun. But hiding them from the prompt might confuse people?
   109  			return run.Conclusion != shared.Success && run.Conclusion != shared.StartupFailure
   110  		})
   111  		if err != nil {
   112  			return fmt.Errorf("failed to get runs: %w", err)
   113  		}
   114  		if len(runs) == 0 {
   115  			return errors.New("no recent runs have failed; please specify a specific `<run-id>`")
   116  		}
   117  		runID, err = shared.PromptForRun(cs, runs)
   118  		if err != nil {
   119  			return err
   120  		}
   121  	}
   122  
   123  	debugMsg := ""
   124  	if opts.Debug {
   125  		debugMsg = " with debug logging enabled"
   126  	}
   127  
   128  	if opts.JobID != "" {
   129  		err = rerunJob(client, repo, selectedJob, opts.Debug)
   130  		if err != nil {
   131  			return err
   132  		}
   133  		if opts.IO.IsStdoutTTY() {
   134  			fmt.Fprintf(opts.IO.Out, "%s Requested rerun of job %s on run %s%s\n",
   135  				cs.SuccessIcon(),
   136  				cs.Cyanf("%d", selectedJob.ID),
   137  				cs.Cyanf("%d", selectedJob.RunID),
   138  				debugMsg)
   139  		}
   140  	} else {
   141  		opts.IO.StartProgressIndicator()
   142  		run, err := shared.GetRun(client, repo, runID)
   143  		opts.IO.StopProgressIndicator()
   144  		if err != nil {
   145  			return fmt.Errorf("failed to get run: %w", err)
   146  		}
   147  
   148  		err = rerunRun(client, repo, run, opts.OnlyFailed, opts.Debug)
   149  		if err != nil {
   150  			return err
   151  		}
   152  		if opts.IO.IsStdoutTTY() {
   153  			onlyFailedMsg := ""
   154  			if opts.OnlyFailed {
   155  				onlyFailedMsg = "(failed jobs) "
   156  			}
   157  			fmt.Fprintf(opts.IO.Out, "%s Requested rerun %sof run %s%s\n",
   158  				cs.SuccessIcon(),
   159  				onlyFailedMsg,
   160  				cs.Cyanf("%d", run.ID),
   161  				debugMsg)
   162  		}
   163  	}
   164  
   165  	return nil
   166  }
   167  
   168  func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFailed, debug bool) error {
   169  	runVerb := "rerun"
   170  	if onlyFailed {
   171  		runVerb = "rerun-failed-jobs"
   172  	}
   173  
   174  	body, err := requestBody(debug)
   175  	if err != nil {
   176  		return fmt.Errorf("failed to create rerun body: %w", err)
   177  	}
   178  
   179  	path := fmt.Sprintf("repos/%s/actions/runs/%d/%s", ghrepo.FullName(repo), run.ID, runVerb)
   180  
   181  	err = client.REST(repo.RepoHost(), "POST", path, body, nil)
   182  	if err != nil {
   183  		var httpError api.HTTPError
   184  		if errors.As(err, &httpError) && httpError.StatusCode == 403 {
   185  			return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken", run.ID)
   186  		}
   187  		return fmt.Errorf("failed to rerun: %w", err)
   188  	}
   189  	return nil
   190  }
   191  
   192  func rerunJob(client *api.Client, repo ghrepo.Interface, job *shared.Job, debug bool) error {
   193  	body, err := requestBody(debug)
   194  	if err != nil {
   195  		return fmt.Errorf("failed to create rerun body: %w", err)
   196  	}
   197  
   198  	path := fmt.Sprintf("repos/%s/actions/jobs/%d/rerun", ghrepo.FullName(repo), job.ID)
   199  
   200  	err = client.REST(repo.RepoHost(), "POST", path, body, nil)
   201  	if err != nil {
   202  		var httpError api.HTTPError
   203  		if errors.As(err, &httpError) && httpError.StatusCode == 403 {
   204  			return fmt.Errorf("job %d cannot be rerun", job.ID)
   205  		}
   206  		return fmt.Errorf("failed to rerun: %w", err)
   207  	}
   208  	return nil
   209  }
   210  
   211  type RerunPayload struct {
   212  	Debug bool `json:"enable_debug_logging"`
   213  }
   214  
   215  func requestBody(debug bool) (io.Reader, error) {
   216  	if !debug {
   217  		return nil, nil
   218  	}
   219  	params := &RerunPayload{
   220  		debug,
   221  	}
   222  
   223  	body := &bytes.Buffer{}
   224  	enc := json.NewEncoder(body)
   225  	if err := enc.Encode(params); err != nil {
   226  		return nil, err
   227  	}
   228  	return body, nil
   229  }