433 lines
9.5 KiB
Go
433 lines
9.5 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"`
|
|
}
|
|
|
|
func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, limit int, f func(Run) bool) ([]Run, error) {
|
|
path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
|
|
runs, err := getRuns(client, repo, path, 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, limit int, workflowID int64) ([]Run, error) {
|
|
path := fmt.Sprintf("repos/%s/actions/workflows/%d/runs", ghrepo.FullName(repo), workflowID)
|
|
return getRuns(client, repo, path, limit)
|
|
}
|
|
|
|
func GetRuns(client *api.Client, repo ghrepo.Interface, limit int) ([]Run, error) {
|
|
path := fmt.Sprintf("repos/%s/actions/runs", ghrepo.FullName(repo))
|
|
return getRuns(client, repo, path, limit)
|
|
}
|
|
|
|
func getRuns(client *api.Client, repo ghrepo.Interface, path string, 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))
|
|
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 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")
|
|
}
|