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" ) // Prompter provides interactive selection prompts. type Prompter interface { Select(string, string, []string) (int, error) } const ( // Queued indicates a workflow run is queued. Queued Status = "queued" // Completed indicates a workflow run has finished. Completed Status = "completed" // InProgress indicates a workflow run is currently executing. InProgress Status = "in_progress" // Requested indicates a workflow run has been requested. Requested Status = "requested" // Waiting indicates a workflow run is waiting to be processed. Waiting Status = "waiting" // Pending indicates a workflow run is pending. Pending Status = "pending" // ActionRequired indicates a workflow run requires action. ActionRequired Conclusion = "action_required" // Cancelled indicates a workflow run was cancelled. Cancelled Conclusion = "cancelled" // Failure indicates a workflow run failed. Failure Conclusion = "failure" // Neutral indicates a workflow run completed with a neutral result. Neutral Conclusion = "neutral" // Skipped indicates a workflow run was skipped. Skipped Conclusion = "skipped" // Stale indicates a workflow run became stale. Stale Conclusion = "stale" // StartupFailure indicates a workflow run failed during startup. StartupFailure Conclusion = "startup_failure" // Success indicates a workflow run completed successfully. Success Conclusion = "success" // TimedOut indicates a workflow run exceeded its time limit. TimedOut Conclusion = "timed_out" // AnnotationFailure represents a failure-level annotation. AnnotationFailure Level = "failure" // AnnotationWarning represents a warning-level annotation. AnnotationWarning Level = "warning" ) // Status represents the status of a workflow run or job. type Status string // Conclusion represents the conclusion of a workflow run or job. type Conclusion string // Level represents the severity level of an annotation. type Level string // AllStatuses contains all valid workflow run status and conclusion values. var AllStatuses = []string{ "queued", "completed", "in_progress", "requested", "waiting", "pending", "action_required", "cancelled", "failure", "neutral", "skipped", "stale", "startup_failure", "success", "timed_out", } // RunFields lists the available fields for exporting workflow run data. var RunFields = []string{ "name", "displayTitle", "headBranch", "headSha", "createdAt", "updatedAt", "startedAt", "attempt", "status", "conclusion", "event", "number", "databaseId", "workflowDatabaseId", "workflowName", "url", } // SingleRunFields extends RunFields with additional fields available for a single run. var SingleRunFields = append(RunFields, "jobs") // Run represents a GitHub Actions workflow run. 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 } // StartedTime returns the effective start time, falling back to CreatedAt. func (r *Run) StartedTime() time.Time { if r.StartedAt.IsZero() { return r.CreatedAt } return r.StartedAt } // Duration returns the elapsed duration of the run. 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) } // Repo represents a repository reference within a workflow run. type Repo struct { Owner struct { Login string } Name string } // Commit represents a Git commit associated with a workflow run. 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 } // ExportData returns the run data as a map for the given fields. 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 } // Job represents a job within a GitHub Actions workflow run. 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"` } // Step represents a step within a workflow job. type Step struct { Name string Status Status Conclusion Conclusion Number int StartedAt time.Time `json:"started_at"` CompletedAt time.Time `json:"completed_at"` } // Steps is a sortable slice of Step values. type Steps []Step // Len returns the number of steps. func (s Steps) Len() int { return len(s) } // Less reports whether step i should sort before step j. func (s Steps) Less(i, j int) bool { return s[i].Number < s[j].Number } // Swap exchanges steps at indices i and j. func (s Steps) Swap(i, j int) { s[i], s[j] = s[j], s[i] } // Annotation represents a check run annotation. type Annotation struct { JobName string Message string Path string Level Level `json:"annotation_level"` StartLine int `json:"start_line"` } // AnnotationSymbol returns the display symbol for an annotation based on its level. func AnnotationSymbol(cs *iostreams.ColorScheme, a Annotation) string { switch a.Level { case AnnotationFailure: return cs.FailureIcon() case AnnotationWarning: return cs.WarningIcon() default: return "-" } } // CheckRun represents a GitHub check run. type CheckRun struct { ID int64 } // ErrMissingAnnotationsPermissions is returned when the token lacks permission to read annotations. 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 } // IsFailureState reports whether the conclusion represents a failure. func IsFailureState(c Conclusion) bool { switch c { case ActionRequired, Failure, StartupFailure, TimedOut: return true default: return false } } // IsSkipped reports whether the conclusion is skipped. func IsSkipped(c Conclusion) bool { return c == Skipped } // RunsPayload is the API response for listing workflow runs. type RunsPayload struct { TotalCount int `json:"total_count"` WorkflowRuns []Run `json:"workflow_runs"` } // FilterOptions specifies filters for querying 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 } // GetRuns fetches workflow runs from the API with the given filters and limit. 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 } // JobsPayload is the API response for listing workflow jobs. type JobsPayload struct { TotalCount int `json:"total_count"` Jobs []Job } // GetJobs fetches the jobs for a workflow run. 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 } // GetJob fetches a single job by its ID. 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 } // GetRun fetches a single workflow run by its ID and optional attempt number. 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 // Symbol returns a display symbol and color function for a given status and conclusion. 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 } // PullRequestForRun finds the pull request number associated with a workflow run. 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") }