github.com/secman-team/gh-api@v1.8.2/pkg/cmd/run/shared/shared.go (about)

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