This completely rewrites the PR lookup mechanism so that the caller must specify the GraphQL fields to query for each PR. Additionally, this fixes some export problems with `pr view --json`. Features: - Each pr command now gets assigned a concept of a Finder. This makes it easier to stub the PR in tests without having to stub the underlying HTTP calls or git invocations. - `pr view --web` is much faster since it only fetches the "url" field. - `pr diff 123` now skips a whole API call where a whole PR was unnecessarily preloaded just to access its diff in a subsequent call. - PullRequestGraphQL query builder is now used to construct queries. - A bunch of individual commands are now freed of having to know about concepts such as BaseRepo, Branch, Config, or Remotes.
393 lines
8.8 KiB
Go
393 lines
8.8 KiB
Go
package shared
|
|
|
|
import (
|
|
"archive/zip"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AlecAivazis/survey/v2"
|
|
"github.com/cli/cli/api"
|
|
"github.com/cli/cli/internal/ghrepo"
|
|
"github.com/cli/cli/pkg/iostreams"
|
|
"github.com/cli/cli/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
|
|
|
|
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
|
|
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]
|
|
}
|
|
}
|
|
|
|
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, Cancelled, Neutral:
|
|
return cs.SuccessIconWithColor(noColor), 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")
|
|
}
|