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

     1  package shared
     2  
     3  import (
     4  	"archive/zip"
     5  	"errors"
     6  	"fmt"
     7  	"net/url"
     8  	"reflect"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/AlecAivazis/survey/v2"
    13  	"github.com/ungtb10d/cli/v2/api"
    14  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    15  	workflowShared "github.com/ungtb10d/cli/v2/pkg/cmd/workflow/shared"
    16  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    17  	"github.com/ungtb10d/cli/v2/pkg/prompt"
    18  )
    19  
    20  const (
    21  	// Run statuses
    22  	Queued     Status = "queued"
    23  	Completed  Status = "completed"
    24  	InProgress Status = "in_progress"
    25  	Requested  Status = "requested"
    26  	Waiting    Status = "waiting"
    27  
    28  	// Run conclusions
    29  	ActionRequired Conclusion = "action_required"
    30  	Cancelled      Conclusion = "cancelled"
    31  	Failure        Conclusion = "failure"
    32  	Neutral        Conclusion = "neutral"
    33  	Skipped        Conclusion = "skipped"
    34  	Stale          Conclusion = "stale"
    35  	StartupFailure Conclusion = "startup_failure"
    36  	Success        Conclusion = "success"
    37  	TimedOut       Conclusion = "timed_out"
    38  
    39  	AnnotationFailure Level = "failure"
    40  	AnnotationWarning Level = "warning"
    41  )
    42  
    43  type Status string
    44  type Conclusion string
    45  type Level string
    46  
    47  var RunFields = []string{
    48  	"name",
    49  	"displayTitle",
    50  	"headBranch",
    51  	"headSha",
    52  	"createdAt",
    53  	"updatedAt",
    54  	"startedAt",
    55  	"status",
    56  	"conclusion",
    57  	"event",
    58  	"number",
    59  	"databaseId",
    60  	"workflowDatabaseId",
    61  	"workflowName",
    62  	"url",
    63  }
    64  
    65  var SingleRunFields = append(RunFields, "jobs")
    66  
    67  type Run struct {
    68  	Name           string    `json:"name"` // the semantics of this field are unclear
    69  	DisplayTitle   string    `json:"display_title"`
    70  	CreatedAt      time.Time `json:"created_at"`
    71  	UpdatedAt      time.Time `json:"updated_at"`
    72  	StartedAt      time.Time `json:"run_started_at"`
    73  	Status         Status
    74  	Conclusion     Conclusion
    75  	Event          string
    76  	ID             int64
    77  	workflowName   string // cache column
    78  	WorkflowID     int64  `json:"workflow_id"`
    79  	Number         int64  `json:"run_number"`
    80  	Attempts       uint8  `json:"run_attempt"`
    81  	HeadBranch     string `json:"head_branch"`
    82  	JobsURL        string `json:"jobs_url"`
    83  	HeadCommit     Commit `json:"head_commit"`
    84  	HeadSha        string `json:"head_sha"`
    85  	URL            string `json:"html_url"`
    86  	HeadRepository Repo   `json:"head_repository"`
    87  	Jobs           []Job  `json:"-"` // populated by GetJobs
    88  }
    89  
    90  func (r *Run) StartedTime() time.Time {
    91  	if r.StartedAt.IsZero() {
    92  		return r.CreatedAt
    93  	}
    94  	return r.StartedAt
    95  }
    96  
    97  func (r *Run) Duration(now time.Time) time.Duration {
    98  	endTime := r.UpdatedAt
    99  	if r.Status != Completed {
   100  		endTime = now
   101  	}
   102  	d := endTime.Sub(r.StartedTime())
   103  	if d < 0 {
   104  		return 0
   105  	}
   106  	return d.Round(time.Second)
   107  }
   108  
   109  type Repo struct {
   110  	Owner struct {
   111  		Login string
   112  	}
   113  	Name string
   114  }
   115  
   116  type Commit struct {
   117  	Message string
   118  }
   119  
   120  // Title is the display title for a run, falling back to the commit subject if unavailable
   121  func (r Run) Title() string {
   122  	if r.DisplayTitle != "" {
   123  		return r.DisplayTitle
   124  	}
   125  
   126  	commitLines := strings.Split(r.HeadCommit.Message, "\n")
   127  	if len(commitLines) > 0 {
   128  		return commitLines[0]
   129  	} else {
   130  		return r.HeadSha[0:8]
   131  	}
   132  }
   133  
   134  // WorkflowName returns the human-readable name of the workflow that this run belongs to.
   135  // TODO: consider lazy-loading the underlying API data to avoid extra API calls unless necessary
   136  func (r Run) WorkflowName() string {
   137  	return r.workflowName
   138  }
   139  
   140  func (r *Run) ExportData(fields []string) map[string]interface{} {
   141  	v := reflect.ValueOf(r).Elem()
   142  	fieldByName := func(v reflect.Value, field string) reflect.Value {
   143  		return v.FieldByNameFunc(func(s string) bool {
   144  			return strings.EqualFold(field, s)
   145  		})
   146  	}
   147  	data := map[string]interface{}{}
   148  
   149  	for _, f := range fields {
   150  		switch f {
   151  		case "databaseId":
   152  			data[f] = r.ID
   153  		case "workflowDatabaseId":
   154  			data[f] = r.WorkflowID
   155  		case "workflowName":
   156  			data[f] = r.WorkflowName()
   157  		case "jobs":
   158  			jobs := make([]interface{}, 0, len(r.Jobs))
   159  			for _, j := range r.Jobs {
   160  				steps := make([]interface{}, 0, len(j.Steps))
   161  				for _, s := range j.Steps {
   162  					steps = append(steps, map[string]interface{}{
   163  						"name":       s.Name,
   164  						"status":     s.Status,
   165  						"conclusion": s.Conclusion,
   166  						"number":     s.Number,
   167  					})
   168  				}
   169  				var completedAt *time.Time
   170  				if !j.CompletedAt.IsZero() {
   171  					completedAt = &j.CompletedAt
   172  				}
   173  				jobs = append(jobs, map[string]interface{}{
   174  					"databaseId":  j.ID,
   175  					"status":      j.Status,
   176  					"conclusion":  j.Conclusion,
   177  					"name":        j.Name,
   178  					"steps":       steps,
   179  					"startedAt":   j.StartedAt,
   180  					"completedAt": completedAt,
   181  					"url":         j.URL,
   182  				})
   183  				data[f] = jobs
   184  			}
   185  		default:
   186  			sf := fieldByName(v, f)
   187  			data[f] = sf.Interface()
   188  		}
   189  	}
   190  
   191  	return data
   192  }
   193  
   194  type Job struct {
   195  	ID          int64
   196  	Status      Status
   197  	Conclusion  Conclusion
   198  	Name        string
   199  	Steps       Steps
   200  	StartedAt   time.Time `json:"started_at"`
   201  	CompletedAt time.Time `json:"completed_at"`
   202  	URL         string    `json:"html_url"`
   203  	RunID       int64     `json:"run_id"`
   204  }
   205  
   206  type Step struct {
   207  	Name       string
   208  	Status     Status
   209  	Conclusion Conclusion
   210  	Number     int
   211  	Log        *zip.File
   212  }
   213  
   214  type Steps []Step
   215  
   216  func (s Steps) Len() int           { return len(s) }
   217  func (s Steps) Less(i, j int) bool { return s[i].Number < s[j].Number }
   218  func (s Steps) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
   219  
   220  type Annotation struct {
   221  	JobName   string
   222  	Message   string
   223  	Path      string
   224  	Level     Level `json:"annotation_level"`
   225  	StartLine int   `json:"start_line"`
   226  }
   227  
   228  func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string {
   229  	switch a.Level {
   230  	case AnnotationFailure:
   231  		return cs.FailureIcon()
   232  	case AnnotationWarning:
   233  		return cs.WarningIcon()
   234  	default:
   235  		return "-"
   236  	}
   237  }
   238  
   239  type CheckRun struct {
   240  	ID int64
   241  }
   242  
   243  func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) {
   244  	var result []*Annotation
   245  
   246  	path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID)
   247  
   248  	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
   249  	if err != nil {
   250  		var httpError api.HTTPError
   251  		if errors.As(err, &httpError) && httpError.StatusCode == 404 {
   252  			return []Annotation{}, nil
   253  		}
   254  		return nil, err
   255  	}
   256  
   257  	out := []Annotation{}
   258  
   259  	for _, annotation := range result {
   260  		annotation.JobName = job.Name
   261  		out = append(out, *annotation)
   262  	}
   263  
   264  	return out, nil
   265  }
   266  
   267  func IsFailureState(c Conclusion) bool {
   268  	switch c {
   269  	case ActionRequired, Failure, StartupFailure, TimedOut:
   270  		return true
   271  	default:
   272  		return false
   273  	}
   274  }
   275  
   276  type RunsPayload struct {
   277  	TotalCount   int   `json:"total_count"`
   278  	WorkflowRuns []Run `json:"workflow_runs"`
   279  }
   280  
   281  type FilterOptions struct {
   282  	Branch     string
   283  	Actor      string
   284  	WorkflowID int64
   285  	// avoid loading workflow name separately and use the provided one
   286  	WorkflowName string
   287  }
   288  
   289  // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory
   290  func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, f func(Run) bool) ([]Run, error) {
   291  	runs, err := GetRuns(client, repo, opts, 50)
   292  	if err != nil {
   293  		return nil, err
   294  	}
   295  
   296  	var filtered []Run
   297  	for _, run := range runs.WorkflowRuns {
   298  		if f(run) {
   299  			filtered = append(filtered, run)
   300  		}
   301  		if len(filtered) == limit {
   302  			break
   303  		}
   304  	}
   305  
   306  	return filtered, nil
   307  }
   308  
   309  func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) (*RunsPayload, error) {
   310  	path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
   311  	if opts != nil && opts.WorkflowID > 0 {
   312  		path = fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), opts.WorkflowID)
   313  	}
   314  
   315  	perPage := limit
   316  	if limit > 100 {
   317  		perPage = 100
   318  	}
   319  	path += fmt.Sprintf("?per_page=%d", perPage)
   320  	path += "&exclude_pull_requests=true" // significantly reduces payload size
   321  
   322  	if opts != nil {
   323  		if opts.Branch != "" {
   324  			path += fmt.Sprintf("&branch=%s", url.QueryEscape(opts.Branch))
   325  		}
   326  		if opts.Actor != "" {
   327  			path += fmt.Sprintf("&actor=%s", url.QueryEscape(opts.Actor))
   328  		}
   329  	}
   330  
   331  	var result *RunsPayload
   332  
   333  pagination:
   334  	for path != "" {
   335  		var response RunsPayload
   336  		var err error
   337  		path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response)
   338  		if err != nil {
   339  			return nil, err
   340  		}
   341  
   342  		if result == nil {
   343  			result = &response
   344  			if len(result.WorkflowRuns) == limit {
   345  				break pagination
   346  			}
   347  		} else {
   348  			for _, run := range response.WorkflowRuns {
   349  				result.WorkflowRuns = append(result.WorkflowRuns, run)
   350  				if len(result.WorkflowRuns) == limit {
   351  					break pagination
   352  				}
   353  			}
   354  		}
   355  	}
   356  
   357  	if opts != nil && opts.WorkflowName != "" {
   358  		for i := range result.WorkflowRuns {
   359  			result.WorkflowRuns[i].workflowName = opts.WorkflowName
   360  		}
   361  	} else if len(result.WorkflowRuns) > 0 {
   362  		if err := preloadWorkflowNames(client, repo, result.WorkflowRuns); err != nil {
   363  			return result, err
   364  		}
   365  	}
   366  
   367  	return result, nil
   368  }
   369  
   370  func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run) error {
   371  	workflows, err := workflowShared.GetWorkflows(client, repo, 0)
   372  	if err != nil {
   373  		return err
   374  	}
   375  
   376  	workflowMap := map[int64]string{}
   377  	for _, wf := range workflows {
   378  		workflowMap[wf.ID] = wf.Name
   379  	}
   380  
   381  	for i, run := range runs {
   382  		if _, ok := workflowMap[run.WorkflowID]; !ok {
   383  			// Look up workflow by ID because it may have been deleted
   384  			workflow, err := workflowShared.GetWorkflow(client, repo, run.WorkflowID)
   385  			if err != nil {
   386  				return err
   387  			}
   388  			workflowMap[run.WorkflowID] = workflow.Name
   389  		}
   390  		runs[i].workflowName = workflowMap[run.WorkflowID]
   391  	}
   392  	return nil
   393  }
   394  
   395  type JobsPayload struct {
   396  	Jobs []Job
   397  }
   398  
   399  func GetJobs(client *api.Client, repo ghrepo.Interface, run *Run) ([]Job, error) {
   400  	if run.Jobs != nil {
   401  		return run.Jobs, nil
   402  	}
   403  	var result JobsPayload
   404  	if err := client.REST(repo.RepoHost(), "GET", run.JobsURL, nil, &result); err != nil {
   405  		return nil, err
   406  	}
   407  	run.Jobs = result.Jobs
   408  	return result.Jobs, nil
   409  }
   410  
   411  func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, error) {
   412  	path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID)
   413  
   414  	var result Job
   415  	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
   416  	if err != nil {
   417  		return nil, err
   418  	}
   419  
   420  	return &result, nil
   421  }
   422  
   423  func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) {
   424  	var selected int
   425  	now := time.Now()
   426  
   427  	candidates := []string{}
   428  
   429  	for _, run := range runs {
   430  		symbol, _ := Symbol(cs, run.Status, run.Conclusion)
   431  		candidates = append(candidates,
   432  			// TODO truncate commit message, long ones look terrible
   433  			fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
   434  	}
   435  
   436  	// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
   437  	// become contiguous
   438  	//nolint:staticcheck // SA1019: prompt.SurveyAskOne is deprecated: use Prompter
   439  	err := prompt.SurveyAskOne(&survey.Select{
   440  		Message:  "Select a workflow run",
   441  		Options:  candidates,
   442  		PageSize: 10,
   443  	}, &selected)
   444  
   445  	if err != nil {
   446  		return "", err
   447  	}
   448  
   449  	return fmt.Sprintf("%d", runs[selected].ID), nil
   450  }
   451  
   452  func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) {
   453  	var result Run
   454  
   455  	path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID)
   456  
   457  	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
   458  	if err != nil {
   459  		return nil, err
   460  	}
   461  
   462  	// Set name to workflow name
   463  	workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID)
   464  	if err != nil {
   465  		return nil, err
   466  	} else {
   467  		result.workflowName = workflow.Name
   468  	}
   469  
   470  	return &result, nil
   471  }
   472  
   473  type colorFunc func(string) string
   474  
   475  func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) {
   476  	noColor := func(s string) string { return s }
   477  	if status == Completed {
   478  		switch conclusion {
   479  		case Success:
   480  			return cs.SuccessIconWithColor(noColor), cs.Green
   481  		case Skipped, Neutral:
   482  			return "-", cs.Gray
   483  		default:
   484  			return cs.FailureIconWithColor(noColor), cs.Red
   485  		}
   486  	}
   487  
   488  	return "*", cs.Yellow
   489  }
   490  
   491  func PullRequestForRun(client *api.Client, repo ghrepo.Interface, run Run) (int, error) {
   492  	type response struct {
   493  		Repository struct {
   494  			PullRequests struct {
   495  				Nodes []struct {
   496  					Number         int
   497  					HeadRepository struct {
   498  						Owner struct {
   499  							Login string
   500  						}
   501  						Name string
   502  					}
   503  				}
   504  			}
   505  		}
   506  		Number int
   507  	}
   508  
   509  	variables := map[string]interface{}{
   510  		"owner":       repo.RepoOwner(),
   511  		"repo":        repo.RepoName(),
   512  		"headRefName": run.HeadBranch,
   513  	}
   514  
   515  	query := `
   516  		query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) {
   517  			repository(owner: $owner, name: $repo) {
   518  				pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
   519  					nodes {
   520  						number
   521  						headRepository {
   522  							owner {
   523  								login
   524  							}
   525  							name
   526  						}
   527  					}
   528  				}
   529  			}
   530  		}`
   531  
   532  	var resp response
   533  
   534  	err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
   535  	if err != nil {
   536  		return -1, err
   537  	}
   538  
   539  	prs := resp.Repository.PullRequests.Nodes
   540  	if len(prs) == 0 {
   541  		return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
   542  	}
   543  
   544  	number := -1
   545  
   546  	for _, pr := range prs {
   547  		if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name {
   548  			number = pr.Number
   549  		}
   550  	}
   551  
   552  	if number == -1 {
   553  		return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
   554  	}
   555  
   556  	return number, nil
   557  }
   558  
   559  func preciseAgo(now time.Time, createdAt time.Time) string {
   560  	ago := now.Sub(createdAt)
   561  
   562  	if ago < 30*24*time.Hour {
   563  		s := ago.Truncate(time.Second).String()
   564  		return fmt.Sprintf("%s ago", s)
   565  	}
   566  
   567  	return createdAt.Format("Jan _2, 2006")
   568  }