cli/pkg/cmd/run/shared/shared.go
2022-03-03 11:10:22 -08:00

458 lines
10 KiB
Go

package shared
import (
"archive/zip"
"errors"
"fmt"
"net/url"
"reflect"
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
)
const (
// Run statuses
Queued Status = "queued"
Completed Status = "completed"
InProgress Status = "in_progress"
Requested Status = "requested"
Waiting Status = "waiting"
// Run conclusions
ActionRequired Conclusion = "action_required"
Cancelled Conclusion = "cancelled"
Failure Conclusion = "failure"
Neutral Conclusion = "neutral"
Skipped Conclusion = "skipped"
Stale Conclusion = "stale"
StartupFailure Conclusion = "startup_failure"
Success Conclusion = "success"
TimedOut Conclusion = "timed_out"
AnnotationFailure Level = "failure"
AnnotationWarning Level = "warning"
)
type Status string
type Conclusion string
type Level string
var RunFields = []string{
"name",
"headBranch",
"headSha",
"createdAt",
"updatedAt",
"status",
"conclusion",
"event",
"databaseId",
"workflowDatabaseId",
"url",
}
type Run struct {
Name string
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Status Status
Conclusion Conclusion
Event string
ID int64
WorkflowID int64 `json:"workflow_id"`
HeadBranch string `json:"head_branch"`
JobsURL string `json:"jobs_url"`
HeadCommit Commit `json:"head_commit"`
HeadSha string `json:"head_sha"`
URL string `json:"html_url"`
HeadRepository Repo `json:"head_repository"`
}
type Repo struct {
Owner struct {
Login string
}
Name string
}
type Commit struct {
Message string
}
func (r Run) CommitMsg() string {
commitLines := strings.Split(r.HeadCommit.Message, "\n")
if len(commitLines) > 0 {
return commitLines[0]
} else {
return r.HeadSha[0:8]
}
}
func (r *Run) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(r).Elem()
fieldByName := func(v reflect.Value, field string) reflect.Value {
return v.FieldByNameFunc(func(s string) bool {
return strings.EqualFold(field, s)
})
}
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "databaseId":
data[f] = r.ID
case "workflowDatabaseId":
data[f] = r.WorkflowID
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
}
return data
}
type Job struct {
ID int64
Status Status
Conclusion Conclusion
Name string
Steps Steps
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
URL string `json:"html_url"`
RunID int64 `json:"run_id"`
}
type Step struct {
Name string
Status Status
Conclusion Conclusion
Number int
Log *zip.File
}
type Steps []Step
func (s Steps) Len() int { return len(s) }
func (s Steps) Less(i, j int) bool { return s[i].Number < s[j].Number }
func (s Steps) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
type Annotation struct {
JobName string
Message string
Path string
Level Level `json:"annotation_level"`
StartLine int `json:"start_line"`
}
func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string {
switch a.Level {
case AnnotationFailure:
return cs.FailureIcon()
case AnnotationWarning:
return cs.WarningIcon()
default:
return "-"
}
}
type CheckRun struct {
ID int64
}
func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) {
var result []*Annotation
path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", ghrepo.FullName(repo), job.ID)
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
var httpError api.HTTPError
if errors.As(err, &httpError) && httpError.StatusCode == 404 {
return []Annotation{}, nil
}
return nil, err
}
out := []Annotation{}
for _, annotation := range result {
annotation.JobName = job.Name
out = append(out, *annotation)
}
return out, nil
}
func IsFailureState(c Conclusion) bool {
switch c {
case ActionRequired, Failure, StartupFailure, TimedOut:
return true
default:
return false
}
}
type RunsPayload struct {
TotalCount int `json:"total_count"`
WorkflowRuns []Run `json:"workflow_runs"`
}
type FilterOptions struct {
Branch string
Actor string
}
func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, f func(Run) bool) ([]Run, error) {
path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
runs, err := getRuns(client, repo, path, opts, 50)
if err != nil {
return nil, err
}
filtered := []Run{}
for _, run := range runs {
if f(run) {
filtered = append(filtered, run)
}
if len(filtered) == limit {
break
}
}
return filtered, nil
}
func GetRunsByWorkflow(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, workflowID int64) ([]Run, error) {
path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), workflowID)
return getRuns(client, repo, path, opts, limit)
}
func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) ([]Run, error) {
path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
return getRuns(client, repo, path, opts, limit)
}
func getRuns(client *api.Client, repo ghrepo.Interface, path string, opts *FilterOptions, limit int) ([]Run, error) {
perPage := limit
page := 1
if limit > 100 {
perPage = 100
}
runs := []Run{}
for len(runs) < limit {
var result RunsPayload
parsed, err := url.Parse(path)
if err != nil {
return nil, err
}
query := parsed.Query()
query.Set("per_page", fmt.Sprintf("%d", perPage))
query.Set("page", fmt.Sprintf("%d", page))
if opts != nil {
if opts.Branch != "" {
query.Set("branch", opts.Branch)
}
if opts.Actor != "" {
query.Set("actor", opts.Actor)
}
}
parsed.RawQuery = query.Encode()
pagedPath := parsed.String()
err = client.REST(repo.RepoHost(), "GET", pagedPath, nil, &result)
if err != nil {
return nil, err
}
if len(result.WorkflowRuns) == 0 {
break
}
for _, run := range result.WorkflowRuns {
runs = append(runs, run)
if len(runs) == limit {
break
}
}
if len(result.WorkflowRuns) < perPage {
break
}
page++
}
return runs, nil
}
type JobsPayload struct {
Jobs []Job
}
func GetJobs(client *api.Client, repo ghrepo.Interface, run Run) ([]Job, error) {
var result JobsPayload
if err := client.REST(repo.RepoHost(), "GET", run.JobsURL, nil, &result); err != nil {
return nil, err
}
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()
candidates := []string{}
for _, run := range runs {
symbol, _ := Symbol(cs, run.Status, run.Conclusion)
candidates = append(candidates,
// TODO truncate commit message, long ones look terrible
fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.CommitMsg(), run.Name, run.HeadBranch, preciseAgo(now, run.CreatedAt)))
}
// TODO consider custom filter so it's fuzzier. right now matches start anywhere in string but
// become contiguous
err := prompt.SurveyAskOne(&survey.Select{
Message: "Select a workflow run",
Options: candidates,
PageSize: 10,
}, &selected)
if err != nil {
return "", err
}
return fmt.Sprintf("%d", runs[selected].ID), nil
}
func GetRun(client *api.Client, repo ghrepo.Interface, runID string) (*Run, error) {
var result Run
path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID)
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
return nil, err
}
return &result, nil
}
type colorFunc func(string) string
func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (string, colorFunc) {
noColor := func(s string) string { return s }
if status == Completed {
switch conclusion {
case Success:
return cs.SuccessIconWithColor(noColor), cs.Green
case Skipped, Neutral:
return "-", cs.Gray
default:
return cs.FailureIconWithColor(noColor), cs.Red
}
}
return "*", cs.Yellow
}
func PullRequestForRun(client *api.Client, repo ghrepo.Interface, run Run) (int, error) {
type response struct {
Repository struct {
PullRequests struct {
Nodes []struct {
Number int
HeadRepository struct {
Owner struct {
Login string
}
Name string
}
}
}
}
Number int
}
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"headRefName": run.HeadBranch,
}
query := `
query PullRequestForRun($owner: String!, $repo: String!, $headRefName: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(headRefName: $headRefName, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
nodes {
number
headRepository {
owner {
login
}
name
}
}
}
}
}`
var resp response
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return -1, err
}
prs := resp.Repository.PullRequests.Nodes
if len(prs) == 0 {
return -1, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
}
number := -1
for _, pr := range prs {
if pr.HeadRepository.Owner.Login == run.HeadRepository.Owner.Login && pr.HeadRepository.Name == run.HeadRepository.Name {
number = pr.Number
}
}
if number == -1 {
return number, fmt.Errorf("no matching PR found for %s", run.HeadBranch)
}
return number, nil
}
func preciseAgo(now time.Time, createdAt time.Time) string {
ago := now.Sub(createdAt)
if ago < 30*24*time.Hour {
s := ago.Truncate(time.Second).String()
return fmt.Sprintf("%s ago", s)
}
return createdAt.Format("Jan _2, 2006")
}