package shared import ( "errors" "fmt" "net/http" "net/url" "reflect" "strings" "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/iostreams" ) type Prompter interface { Select(string, string, []string) (int, error) } const ( // Run statuses Queued Status = "queued" Completed Status = "completed" InProgress Status = "in_progress" Requested Status = "requested" Waiting Status = "waiting" Pending Status = "pending" // 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 AllStatuses = []string{ "queued", "completed", "in_progress", "requested", "waiting", "pending", "action_required", "cancelled", "failure", "neutral", "skipped", "stale", "startup_failure", "success", "timed_out", } var RunFields = []string{ "name", "displayTitle", "headBranch", "headSha", "createdAt", "updatedAt", "startedAt", "attempt", "status", "conclusion", "event", "number", "databaseId", "workflowDatabaseId", "workflowName", "url", } var SingleRunFields = append(RunFields, "jobs") type Run struct { Name string `json:"name"` // the semantics of this field are unclear DisplayTitle string `json:"display_title"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` StartedAt time.Time `json:"run_started_at"` Status Status Conclusion Conclusion Event string ID int64 workflowName string // cache column WorkflowID int64 `json:"workflow_id"` Number int64 `json:"run_number"` Attempt uint64 `json:"run_attempt"` 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"` Jobs []Job `json:"-"` // populated by GetJobs } func (r *Run) StartedTime() time.Time { if r.StartedAt.IsZero() { return r.CreatedAt } return r.StartedAt } func (r *Run) Duration(now time.Time) time.Duration { endTime := r.UpdatedAt if r.Status != Completed { endTime = now } d := endTime.Sub(r.StartedTime()) if d < 0 { return 0 } return d.Round(time.Second) } type Repo struct { Owner struct { Login string } Name string } type Commit struct { Message string } // Title is the display title for a run, falling back to the commit subject if unavailable func (r Run) Title() string { if r.DisplayTitle != "" { return r.DisplayTitle } commitLines := strings.Split(r.HeadCommit.Message, "\n") if len(commitLines) > 0 { return commitLines[0] } else { return r.HeadSha[0:8] } } // WorkflowName returns the human-readable name of the workflow that this run belongs to. // TODO: consider lazy-loading the underlying API data to avoid extra API calls unless necessary func (r Run) WorkflowName() string { return r.workflowName } 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 case "workflowName": data[f] = r.WorkflowName() case "jobs": jobs := make([]interface{}, 0, len(r.Jobs)) for _, j := range r.Jobs { steps := make([]interface{}, 0, len(j.Steps)) for _, s := range j.Steps { var stepCompletedAt time.Time if !s.CompletedAt.IsZero() { stepCompletedAt = s.CompletedAt } steps = append(steps, map[string]interface{}{ "name": s.Name, "status": s.Status, "conclusion": s.Conclusion, "number": s.Number, "startedAt": s.StartedAt, "completedAt": stepCompletedAt, }) } var jobCompletedAt time.Time if !j.CompletedAt.IsZero() { jobCompletedAt = j.CompletedAt } jobs = append(jobs, map[string]interface{}{ "databaseId": j.ID, "status": j.Status, "conclusion": j.Conclusion, "name": j.Name, "steps": steps, "startedAt": j.StartedAt, "completedAt": jobCompletedAt, "url": j.URL, }) } data[f] = jobs 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 StartedAt time.Time `json:"started_at"` CompletedAt time.Time `json:"completed_at"` } 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 } var ErrMissingAnnotationsPermissions = errors.New("missing annotations permissions error") // GetAnnotations fetches annotations from the REST API. // // If the job has no annotations, an empty slice is returned. // If the API returns a 403, a custom ErrMissingAnnotationsPermissions error is returned. // // When fine-grained PATs support checks:read permission, we can remove the need for this at the call sites. 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) { return nil, err } if httpError.StatusCode == http.StatusNotFound { return []Annotation{}, nil } if httpError.StatusCode == http.StatusForbidden { return nil, ErrMissingAnnotationsPermissions } 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 } } func IsSkipped(c Conclusion) bool { return c == Skipped } type RunsPayload struct { TotalCount int `json:"total_count"` WorkflowRuns []Run `json:"workflow_runs"` } type FilterOptions struct { Branch string Actor string WorkflowID int64 // avoid loading workflow name separately and use the provided one WorkflowName string Status string Event string Created string Commit string } // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, f func(Run) bool) ([]Run, error) { runs, err := GetRuns(client, repo, opts, 50) if err != nil { return nil, err } var filtered []Run for _, run := range runs.WorkflowRuns { if f(run) { filtered = append(filtered, run) } if len(filtered) == limit { break } } return filtered, nil } func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) (*RunsPayload, error) { path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo)) if opts != nil && opts.WorkflowID > 0 { path = fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), opts.WorkflowID) } perPage := limit if limit > 100 { perPage = 100 } path += fmt.Sprintf("?per_page=%d", perPage) path += "&exclude_pull_requests=true" // significantly reduces payload size if opts != nil { if opts.Branch != "" { path += fmt.Sprintf("&branch=%s", url.QueryEscape(opts.Branch)) } if opts.Actor != "" { path += fmt.Sprintf("&actor=%s", url.QueryEscape(opts.Actor)) } if opts.Status != "" { path += fmt.Sprintf("&status=%s", url.QueryEscape(opts.Status)) } if opts.Event != "" { path += fmt.Sprintf("&event=%s", url.QueryEscape(opts.Event)) } if opts.Created != "" { path += fmt.Sprintf("&created=%s", url.QueryEscape(opts.Created)) } if opts.Commit != "" { path += fmt.Sprintf("&head_sha=%s", url.QueryEscape(opts.Commit)) } } var result *RunsPayload pagination: for path != "" { var response RunsPayload var err error path, err = client.RESTWithNext(repo.RepoHost(), "GET", path, nil, &response) if err != nil { return nil, err } if result == nil { result = &response if len(result.WorkflowRuns) == limit { break pagination } } else { for _, run := range response.WorkflowRuns { result.WorkflowRuns = append(result.WorkflowRuns, run) if len(result.WorkflowRuns) == limit { break pagination } } } } if opts != nil && opts.WorkflowName != "" { for i := range result.WorkflowRuns { result.WorkflowRuns[i].workflowName = opts.WorkflowName } } else if len(result.WorkflowRuns) > 0 { if err := preloadWorkflowNames(client, repo, result.WorkflowRuns); err != nil { return result, err } } return result, nil } func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run) error { workflows, err := workflowShared.GetWorkflows(client, repo, 0) if err != nil { return err } workflowMap := map[int64]string{} for _, wf := range workflows { workflowMap[wf.ID] = wf.Name } for i, run := range runs { if _, ok := workflowMap[run.WorkflowID]; !ok { // Look up workflow by ID because it may have been deleted workflow, err := workflowShared.GetWorkflow(client, repo, run.WorkflowID) // If the error is an httpError and it is a 404, this is likely a // organization or enterprise ruleset workflow. The user does not // have permissions to view the details of the workflow, so we cannot // look it up directly without receiving a 404, but it is nonetheless // in the workflow run list. To handle this, we set the workflow name // to an empty string. // Deciding to put this here instead of in GetWorkflow to allow // the caller to decide what a 404 means. if httpErr, ok := err.(api.HTTPError); ok && httpErr.StatusCode == 404 { workflowMap[run.WorkflowID] = "" continue } if err != nil { return err } workflowMap[run.WorkflowID] = workflow.Name } runs[i].workflowName = workflowMap[run.WorkflowID] } return nil } type JobsPayload struct { TotalCount int `json:"total_count"` Jobs []Job } func GetJobs(client *api.Client, repo ghrepo.Interface, run *Run, attempt uint64) ([]Job, error) { if run.Jobs != nil { return run.Jobs, nil } query := url.Values{} query.Set("per_page", "100") jobsPath := fmt.Sprintf("%s?%s", run.JobsURL, query.Encode()) if attempt > 0 { jobsPath = fmt.Sprintf("repos/%s/actions/runs/%d/attempts/%d/jobs?%s", ghrepo.FullName(repo), run.ID, attempt, query.Encode()) } for jobsPath != "" { var resp JobsPayload var err error jobsPath, err = client.RESTWithNext(repo.RepoHost(), http.MethodGet, jobsPath, nil, &resp) if err != nil { run.Jobs = nil return nil, err } run.Jobs = append(run.Jobs, resp.Jobs...) } return run.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 } // SelectRun prompts the user to select a run from a list of runs by using the recommended prompter interface func SelectRun(p Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error) { 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.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime()))) } selected, err := p.Select("Select a workflow run", "", candidates) if err != nil { return "", err } return fmt.Sprintf("%d", runs[selected].ID), nil } func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) { var result Run path := fmt.Sprintf("repos/%s/actions/runs/%s?exclude_pull_requests=true", ghrepo.FullName(repo), runID) if attempt > 0 { path = fmt.Sprintf("repos/%s/actions/runs/%s/attempts/%d?exclude_pull_requests=true", ghrepo.FullName(repo), runID, attempt) } err := client.REST(repo.RepoHost(), "GET", path, nil, &result) if err != nil { return nil, err } if attempt > 0 { result.URL, err = url.JoinPath(result.URL, fmt.Sprintf("/attempts/%d", attempt)) if err != nil { return nil, err } } // Set name to workflow name workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID) if err != nil { return nil, err } else { result.workflowName = workflow.Name } 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.Muted 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") }