github.com/andrewhsu/cli/v2@v2.0.1-0.20210910131313-d4b4061f5b89/pkg/cmd/run/shared/shared.go (about)

     1  package shared
     2  
     3  import (
     4  	"archive/zip"
     5  	"errors"
     6  	"fmt"
     7  	"net/url"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/AlecAivazis/survey/v2"
    12  	"github.com/andrewhsu/cli/v2/api"
    13  	"github.com/andrewhsu/cli/v2/internal/ghrepo"
    14  	"github.com/andrewhsu/cli/v2/pkg/iostreams"
    15  	"github.com/andrewhsu/cli/v2/pkg/prompt"
    16  )
    17  
    18  const (
    19  	// Run statuses
    20  	Queued     Status = "queued"
    21  	Completed  Status = "completed"
    22  	InProgress Status = "in_progress"
    23  	Requested  Status = "requested"
    24  	Waiting    Status = "waiting"
    25  
    26  	// Run conclusions
    27  	ActionRequired Conclusion = "action_required"
    28  	Cancelled      Conclusion = "cancelled"
    29  	Failure        Conclusion = "failure"
    30  	Neutral        Conclusion = "neutral"
    31  	Skipped        Conclusion = "skipped"
    32  	Stale          Conclusion = "stale"
    33  	StartupFailure Conclusion = "startup_failure"
    34  	Success        Conclusion = "success"
    35  	TimedOut       Conclusion = "timed_out"
    36  
    37  	AnnotationFailure Level = "failure"
    38  	AnnotationWarning Level = "warning"
    39  )
    40  
    41  type Status string
    42  type Conclusion string
    43  type Level string
    44  
    45  type Run struct {
    46  	Name           string
    47  	CreatedAt      time.Time `json:"created_at"`
    48  	UpdatedAt      time.Time `json:"updated_at"`
    49  	Status         Status
    50  	Conclusion     Conclusion
    51  	Event          string
    52  	ID             int64
    53  	HeadBranch     string `json:"head_branch"`
    54  	JobsURL        string `json:"jobs_url"`
    55  	HeadCommit     Commit `json:"head_commit"`
    56  	HeadSha        string `json:"head_sha"`
    57  	URL            string `json:"html_url"`
    58  	HeadRepository Repo   `json:"head_repository"`
    59  }
    60  
    61  type Repo struct {
    62  	Owner struct {
    63  		Login string
    64  	}
    65  	Name string
    66  }
    67  
    68  type Commit struct {
    69  	Message string
    70  }
    71  
    72  func (r Run) CommitMsg() string {
    73  	commitLines := strings.Split(r.HeadCommit.Message, "\n")
    74  	if len(commitLines) > 0 {
    75  		return commitLines[0]
    76  	} else {
    77  		return r.HeadSha[0:8]
    78  	}
    79  }
    80  
    81  type Job struct {
    82  	ID          int64
    83  	Status      Status
    84  	Conclusion  Conclusion
    85  	Name        string
    86  	Steps       Steps
    87  	StartedAt   time.Time `json:"started_at"`
    88  	CompletedAt time.Time `json:"completed_at"`
    89  	URL         string    `json:"html_url"`
    90  	RunID       int64     `json:"run_id"`
    91  }
    92  
    93  type Step struct {
    94  	Name       string
    95  	Status     Status
    96  	Conclusion Conclusion
    97  	Number     int
    98  	Log        *zip.File
    99  }
   100  
   101  type Steps []Step
   102  
   103  func (s Steps) Len() int           { return len(s) }
   104  func (s Steps) Less(i, j int) bool { return s[i].Number < s[j].Number }
   105  func (s Steps) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
   106  
   107  type Annotation struct {
   108  	JobName   string
   109  	Message   string
   110  	Path      string
   111  	Level     Level `json:"annotation_level"`
   112  	StartLine int   `json:"start_line"`
   113  }
   114  
   115  func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string {
   116  	switch a.Level {
   117  	case AnnotationFailure:
   118  		return cs.FailureIcon()
   119  	case AnnotationWarning:
   120  		return cs.WarningIcon()
   121  	default:
   122  		return "-"
   123  	}
   124  }
   125  
   126  type CheckRun struct {
   127  	ID int64
   128  }
   129  
   130  func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) {
   131  	var result []*Annotation
   132  
   133  	path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID)
   134  
   135  	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
   136  	if err != nil {
   137  		var httpError api.HTTPError
   138  		if errors.As(err, &httpError) && httpError.StatusCode == 404 {
   139  			return []Annotation{}, nil
   140  		}
   141  		return nil, err
   142  	}
   143  
   144  	out := []Annotation{}
   145  
   146  	for _, annotation := range result {
   147  		annotation.JobName = job.Name
   148  		out = append(out, *annotation)
   149  	}
   150  
   151  	return out, nil
   152  }
   153  
   154  func IsFailureState(c Conclusion) bool {
   155  	switch c {
   156  	case ActionRequired, Failure, StartupFailure, TimedOut:
   157  		return true
   158  	default:
   159  		return false
   160  	}
   161  }
   162  
   163  type RunsPayload struct {
   164  	TotalCount   int   `json:"total_count"`
   165  	WorkflowRuns []Run `json:"workflow_runs"`
   166  }
   167  
   168  func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, limit int, f func(Run) bool) ([]Run, error) {
   169  	path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
   170  	runs, err := getRuns(client, repo, path, 50)
   171  	if err != nil {
   172  		return nil, err
   173  	}
   174  	filtered := []Run{}
   175  	for _, run := range runs {
   176  		if f(run) {
   177  			filtered = append(filtered, run)
   178  		}
   179  		if len(filtered) == limit {
   180  			break
   181  		}
   182  	}
   183  
   184  	return filtered, nil
   185  }
   186  
   187  func GetRunsByWorkflow(client *api.Client, repo ghrepo.Interface, limit int, workflowID int64) ([]Run, error) {
   188  	path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), workflowID)
   189  	return getRuns(client, repo, path, limit)
   190  }
   191  
   192  func GetRuns(client *api.Client, repo ghrepo.Interface, limit int) ([]Run, error) {
   193  	path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
   194  	return getRuns(client, repo, path, limit)
   195  }
   196  
   197  func getRuns(client *api.Client, repo ghrepo.Interface, path string, limit int) ([]Run, error) {
   198  	perPage := limit
   199  	page := 1
   200  	if limit > 100 {
   201  		perPage = 100
   202  	}
   203  
   204  	runs := []Run{}
   205  
   206  	for len(runs) < limit {
   207  		var result RunsPayload
   208  
   209  		parsed, err := url.Parse(path)
   210  		if err != nil {
   211  			return nil, err
   212  		}
   213  		query := parsed.Query()
   214  		query.Set("per_page", fmt.Sprintf("%d", perPage))
   215  		query.Set("page", fmt.Sprintf("%d", page))
   216  		parsed.RawQuery = query.Encode()
   217  		pagedPath := parsed.String()
   218  
   219  		err = client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result)
   220  		if err != nil {
   221  			return nil, err
   222  		}
   223  
   224  		if len(result.WorkflowRuns) == 0 {
   225  			break
   226  		}
   227  
   228  		for _, run := range result.WorkflowRuns {
   229  			runs = append(runs, run)
   230  			if len(runs) == limit {
   231  				break
   232  			}
   233  		}
   234  
   235  		if len(result.WorkflowRuns) < perPage {
   236  			break
   237  		}
   238  
   239  		page++
   240  	}
   241  
   242  	return runs, nil
   243  }
   244  
   245  type JobsPayload struct {
   246  	Jobs []Job
   247  }
   248  
   249  func GetJobs(client *api.Client, repo ghrepo.Interface, run Run) ([]Job, error) {
   250  	var result JobsPayload
   251  	if err := client.REST(repo.RepoHost(), "GET", run.JobsURL, nil, &result); err != nil {
   252  		return nil, err
   253  	}
   254  	return result.Jobs, nil
   255  }
   256  
   257  func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) {
   258  	var selected int
   259  	now := time.Now()
   260  
   261  	candidates := []string{}
   262  
   263  	for _, run := range runs {
   264  		symbol, _ := Symbol(cs, run.Status, run.Conclusion)
   265  		candidates = append(candidates,
   266  			// TODO truncate commit message, long ones look terrible
   267  			fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.CommitMsg(), run.Name, run.HeadBranch, preciseAgo(now, run.CreatedAt)))
   268  	}
   269  
   270  	// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
   271  	// become contiguous
   272  	err := prompt.SurveyAskOne(&survey.Select{
   273  		Message:  "Select a workflow run",
   274  		Options:  candidates,
   275  		PageSize: 10,
   276  	}, &selected)
   277  
   278  	if err != nil {
   279  		return "", err
   280  	}
   281  
   282  	return fmt.Sprintf("%d", runs[selected].ID), nil
   283  }
   284  
   285  func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) {
   286  	var result Run
   287  
   288  	path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID)
   289  
   290  	err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
   291  	if err != nil {
   292  		return nil, err
   293  	}
   294  
   295  	return &result, nil
   296  }
   297  
   298  type colorFunc func(string) string
   299  
   300  func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) {
   301  	noColor := func(s string) string { return s }
   302  	if status == Completed {
   303  		switch conclusion {
   304  		case Success:
   305  			return cs.SuccessIconWithColor(noColor), cs.Green
   306  		case Skipped, Cancelled, Neutral:
   307  			return cs.SuccessIconWithColor(noColor), cs.Gray
   308  		default:
   309  			return cs.FailureIconWithColor(noColor), cs.Red
   310  		}
   311  	}
   312  
   313  	return "-", cs.Yellow
   314  }
   315  
   316  func PullRequestForRun(client *api.Client, repo ghrepo.Interface, run Run) (int, error) {
   317  	type response struct {
   318  		Repository struct {
   319  			PullRequests struct {
   320  				Nodes []struct {
   321  					Number         int
   322  					HeadRepository struct {
   323  						Owner struct {
   324  							Login string
   325  						}
   326  						Name string
   327  					}
   328  				}
   329  			}
   330  		}
   331  		Number int
   332  	}
   333  
   334  	variables := map[string]interface{}{
   335  		"owner":       repo.RepoOwner(),
   336  		"repo":        repo.RepoName(),
   337  		"headRefName": run.HeadBranch,
   338  	}
   339  
   340  	query := `
   341  		query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) {
   342  			repository(owner: $owner, name: $repo) {
   343  				pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
   344  					nodes {
   345  						number
   346  						headRepository {
   347  							owner {
   348  								login
   349  							}
   350  							name
   351  						}
   352  					}
   353  				}
   354  			}
   355  		}`
   356  
   357  	var resp response
   358  
   359  	err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
   360  	if err != nil {
   361  		return -1, err
   362  	}
   363  
   364  	prs := resp.Repository.PullRequests.Nodes
   365  	if len(prs) == 0 {
   366  		return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
   367  	}
   368  
   369  	number := -1
   370  
   371  	for _, pr := range prs {
   372  		if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name {
   373  			number = pr.Number
   374  		}
   375  	}
   376  
   377  	if number == -1 {
   378  		return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
   379  	}
   380  
   381  	return number, nil
   382  }
   383  
   384  func preciseAgo(now time.Time, createdAt time.Time) string {
   385  	ago := now.Sub(createdAt)
   386  
   387  	if ago < 30*24*time.Hour {
   388  		s := ago.Truncate(time.Second).String()
   389  		return fmt.Sprintf("%s ago", s)
   390  	}
   391  
   392  	return createdAt.Format("Jan _2, 2006")
   393  }