blob: bb7eeac53931a55ada0b78e46502fa4a3983206b [file]
package try
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"slices"
"sort"
"strings"
"time"
"github.com/fatih/color"
"github.com/urfave/cli/v2"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"golang.org/x/oauth2/google"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/buildbucket"
"go.skia.org/infra/go/exec"
"go.skia.org/infra/go/gerrit"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/repo_root"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/task_scheduler/go/specs"
)
const (
bbBucketPublic = "skia/skia.primary"
bbBucketPrivate = "skia-internal/skia.internal"
gerritURL = "https://skia-review.googlesource.com"
tsJobURLPublic = "https://task-scheduler.skia.org/job/%s"
tsJobURLPrivate = "https://skia-task-scheduler.corp.goog/job/%s"
)
var (
// stdin is an abstraction of os.Stdin which is convenient for testing.
stdin io.Reader = os.Stdin
// tryjobs is an instance of tryJobReader which may be replaced for testing.
tryjobs tryJobReader = &tryJobReaderImpl{}
// gerritProjectToBucket indicates which Gerrit projects are associated with
// which Buildbucket bucket.
gerritProjectToBucket = map[string]string{
// Public projects.
"buildbot": bbBucketPublic,
"common": bbBucketPublic,
"libgifcodec": bbBucketPublic,
"lottie-ci": bbBucketPublic,
"skcms": bbBucketPublic,
"skia": bbBucketPublic,
"skiabot-test": bbBucketPublic,
// Private projects.
"skia-autoroll-internal-config": bbBucketPrivate,
"skia_internal": bbBucketPrivate,
"eskia": bbBucketPrivate,
"infra-internal": bbBucketPrivate,
"internal_test": bbBucketPrivate,
"k8s-config": bbBucketPrivate,
}
// bbBucketToTaskSchedulerURL maps buildbucket buckets to a task scheduler
// job URL.
bbBucketToTaskSchedulerURL = map[string]string{
bbBucketPublic: tsJobURLPublic,
bbBucketPrivate: tsJobURLPrivate,
}
)
// Command returns a cli.Command instance which represents the "try" command.
func Command() []*cli.Command {
yFlag := "y"
bucketFlag := "bucket"
issueFlag := "issue"
patchSetFlag := "patchset"
waitFlag := "wait"
return []*cli.Command{
{
Name: "try",
Usage: "try [-y] [job name or regex]...",
Description: "Run try jobs against the active CL",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: yFlag,
Value: false,
Usage: "Trigger all matching try jobs without asking for confirmation.",
},
&cli.StringFlag{
Name: bucketFlag,
Value: "",
Usage: "Override the Buildbucket bucket used to trigger try jobs.",
},
},
Action: func(ctx *cli.Context) error {
if err := fixupIssue(ctx.Context); err != nil {
return skerr.Wrap(err)
}
return try(ctx.Context, ctx.Args().Slice(), ctx.Bool(yFlag), ctx.String(bucketFlag))
},
},
{
Name: "try-results",
Usage: "try-results",
Description: "Retrieve try jobs for the active CL",
Flags: []cli.Flag{
&cli.Int64Flag{
Name: issueFlag,
Value: 0,
Usage: "Retrieve try jobs for this issue instead of the one for the current branch.",
},
&cli.Int64Flag{
Name: patchSetFlag,
Value: 0,
Usage: "Retrieve try jobs for this patch set instead of the most recent.",
},
&cli.BoolFlag{
Name: waitFlag,
Value: false,
Usage: "Wait for all try jobs to finish before exiting.",
},
},
Action: func(ctx *cli.Context) error {
return tryResults(ctx.Context, ctx.Int64(issueFlag), ctx.Int64(patchSetFlag), ctx.Bool(waitFlag))
},
},
}
}
// try loads the available try jobs, filters by the given request strings, and
// triggers the try jobs selected by the user.
func try(ctx context.Context, jobRequests []string, triggerWithoutPrompt bool, overrideBucket string) error {
// Setup.
jobs, err := tryjobs.getTryJobs(ctx)
if err != nil {
return skerr.Wrap(err)
}
// Filter by the given requested job names/regexes.
filteredJobs := jobs
if len(jobRequests) > 0 {
jobRegexes := make([]*regexp.Regexp, 0, len(jobRequests))
for _, jobRequest := range jobRequests {
jobRegex, err := regexp.Compile(jobRequest)
if err != nil {
return skerr.Wrap(err)
}
jobRegexes = append(jobRegexes, jobRegex)
}
filteredJobs = map[string][]string{}
for bucket, jobList := range jobs {
for _, job := range jobList {
for _, jobRegex := range jobRegexes {
if jobRegex.MatchString(job) {
filteredJobs[bucket] = append(filteredJobs[bucket], job)
break
}
}
}
}
}
// Prompt the user before triggering jobs.
count := 0
for _, jobList := range filteredJobs {
count += len(jobList)
sort.Strings(jobList)
}
if count == 0 {
return skerr.Fmt("Found no jobs matching %v", jobRequests)
}
fmt.Printf("Found %d jobs:\n", count)
for bucket, jobList := range filteredJobs {
fmt.Printf(" %s:\n", bucket)
for _, job := range jobList {
fmt.Printf(" %s\n", job)
}
}
if len(jobRequests) == 0 || count == 0 {
return nil
}
jobsToTrigger := filteredJobs
if !triggerWithoutPrompt {
fmt.Println("Do you want to trigger these jobs? (y/n or i for interactive): ")
reader := bufio.NewReader(stdin)
read, err := reader.ReadString('\n')
if err != nil {
return skerr.Wrap(err)
}
read = strings.TrimSpace(read)
if read != "y" && read != "i" {
return nil
}
if read == "i" {
jobsToTrigger = map[string][]string{}
for bucket, jobList := range filteredJobs {
for _, job := range jobList {
fmt.Printf("Trigger %s? (y/n):\n", job)
trigger, err := reader.ReadString('\n')
if err != nil {
return skerr.Wrap(err)
}
if strings.TrimSpace(trigger) == "y" {
jobsToTrigger[bucket] = append(jobsToTrigger[bucket], job)
}
}
}
}
}
// Trigger the try jobs.
for bucket, jobList := range jobsToTrigger {
if overrideBucket != "" {
bucket = overrideBucket
}
cmd := []string{"git", "cl", "try", "-B", bucket}
for _, job := range jobList {
cmd = append(cmd, "-b", job)
}
if _, err := exec.RunCwd(ctx, ".", cmd...); err != nil {
return skerr.Wrap(err)
}
}
return nil
}
// fixupIssue ensures that the Gerrit issue is set via "git cl issue",
// regardless of whether "git cl upload" was used to create the change.
func fixupIssue(ctx context.Context) error {
// If the change was uploaded using Depot Tools, the issue configuration
// will already be present in the git config.
output, err := exec.RunCwd(ctx, ".", "git", "branch", "--show-current")
if err != nil {
return skerr.Wrap(err)
}
branch := strings.TrimSpace(output)
if _, err := exec.RunCwd(ctx, ".", "git", "config", "--local", fmt.Sprintf("branch.%s.gerritissue", branch)); err == nil {
return nil
}
// If the above failed, it's probably because the CL was not uploaded
// using Depot Tools. Find the Change-Id line in the most recent commit
// and obtain the issue number using that.
output, err = exec.RunCwd(ctx, ".", "git", "log", "-n1", branch)
if err != nil {
return skerr.Wrap(err)
}
changeId, err := gerrit.ParseChangeId(output)
if err != nil {
return skerr.Wrap(err)
}
ts, err := google.DefaultTokenSource(ctx, gerrit.AuthScope)
if err != nil {
return skerr.Wrap(err)
}
client := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
g, err := gerrit.NewGerrit(gerrit.GerritSkiaURL, client)
if err != nil {
return skerr.Wrap(err)
}
ci, err := g.GetChange(ctx, changeId)
if err != nil {
return skerr.Wrap(err)
}
issue := fmt.Sprintf("%d", ci.Issue)
if _, err := exec.RunCwd(ctx, ".", "git", "cl", "issue", issue); err != nil {
return skerr.Wrap(err)
}
return nil
}
// tryJobReader provides an abstraction for reading the available set of try
// jobs to facilitate testing.
type tryJobReader interface {
// getTryJobs reads tasks.json from the current repo and returns a
// map[string][]string of Buildbucket bucket names to try job names.
getTryJobs(context.Context) (map[string][]string, error)
}
// tryJobReaderImpl is the default tryJobReader implementation which reads from
// the tasks.json file in the current repo.
type tryJobReaderImpl struct{}
// GetTryJobs implements tryJobReader.
func (r *tryJobReaderImpl) getTryJobs(ctx context.Context) (map[string][]string, error) {
repoRoot, err := repo_root.GetLocal()
if err != nil {
return nil, err
}
tasksCfg, err := specs.ReadTasksCfg(repoRoot)
if err != nil {
return nil, err
}
jobs := make([]string, 0, len(tasksCfg.Jobs))
for name := range tasksCfg.Jobs {
jobs = append(jobs, name)
}
// Attempt to determine which Buildbucket bucket to use by obtaining
// information about the active CL.
props, err := getLocalIssueProperties(ctx)
if err != nil {
return nil, skerr.Wrap(err)
}
bbBucket, ok := gerritProjectToBucket[props.GerritProject]
if !ok {
return nil, skerr.Fmt("Unknown Gerrit project %q", props.GerritProject)
}
return map[string][]string{
bbBucket: jobs,
}, nil
}
type issueProperties struct {
GerritHost string `json:"gerrit_host"`
GerritProject string `json:"gerrit_project"`
IssueURL string `json:"issue_url"`
Issue int64 `json:"issue"`
}
func getLocalIssueProperties(ctx context.Context) (*issueProperties, error) {
out, err := exec.RunCwd(ctx, ".", "git", "cl", "issue", "--json=-")
if err != nil {
return nil, skerr.Wrap(err)
}
// Strip the first line of non-JSON output.
out = strings.Join(strings.Split(out, "\n")[1:], "\n")
// Parse the JSON.
var props issueProperties
if err := json.NewDecoder(bytes.NewReader([]byte(out))).Decode(&props); err != nil {
return nil, skerr.Wrap(err)
}
return &props, nil
}
func tryResults(ctx context.Context, issue, patchset int64, wait bool) error {
// Set up HTTP client.
ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail)
if err != nil {
return skerr.Wrap(err)
}
httpClient := httputils.DefaultClientConfig().WithTokenSource(ts).Client()
// Find the issue ID if not provided.
if issue == 0 {
if err := fixupIssue(ctx); err != nil {
return skerr.Wrap(err)
}
props, err := getLocalIssueProperties(ctx)
if err != nil {
return skerr.Wrap(err)
}
issue = props.Issue
}
// Find the patchset ID if not provided.
if patchset == 0 {
g, err := gerrit.NewGerrit(gerritURL, httpClient)
if err != nil {
return skerr.Wrap(err)
}
ci, err := g.GetIssueProperties(ctx, issue)
if err != nil {
return skerr.Wrap(err)
}
patchsets := ci.GetPatchsetIDs()
patchset = patchsets[len(patchsets)-1]
}
bbClient := buildbucket.NewClient(httpClient)
// Print output.
output, done, err := formatTryResults(ctx, bbClient, issue, patchset, gerritURL)
if err != nil {
return skerr.Wrap(err)
}
_, _ = fmt.Print(output)
// Wait if necessary.
for wait && !done {
eraseLines := len(strings.Split(output, "\n"))
eraseStr := fmt.Sprintf("\033[%dA\033[J", eraseLines) // Move cursor up N lines and clear everything after.
time.Sleep(10 * time.Second)
output, done, err = formatTryResults(ctx, bbClient, issue, patchset, gerritURL)
if err != nil {
return skerr.Wrap(err)
}
_, _ = fmt.Print(eraseStr)
_, _ = fmt.Print(output)
}
return nil
}
func formatTryResults(ctx context.Context, bbClient *buildbucket.Client, issue, patchset int64, gerritURL string) (string, bool, error) {
// Retrieve Buildbucket builds for the issue+patchset.
builds, err := bbClient.GetTrybotsForCL(ctx, issue, patchset, gerritURL, nil)
if err != nil {
return "", false, skerr.Wrap(err)
}
// Group by status.
done := true
buildsByStatus := map[buildbucketpb.Status][]*buildbucketpb.Build{}
for _, build := range builds {
buildsByStatus[build.Status] = append(buildsByStatus[build.Status], build)
if build.Status&buildbucketpb.Status_ENDED_MASK == 0 {
done = false
}
}
statuses := make([]buildbucketpb.Status, 0, len(buildsByStatus))
for status := range buildsByStatus {
statuses = append(statuses, status)
}
slices.Sort(statuses)
// Format results.
var sb strings.Builder
fmt.Fprintf(&sb, "Patchset %d:\n", patchset)
for _, s := range statuses {
colorLogf(&sb, s, " %s:\n", s.String())
for _, build := range builds {
colorLogf(&sb, s, " %s: %s\n", build.Builder.Builder, jobURLForBuild(build))
}
}
return sb.String(), done, nil
}
func colorLogf(w io.Writer, status buildbucketpb.Status, format string, a ...interface{}) {
_, _ = getColor(status).Fprintf(w, format, a...)
}
var statusToColor = map[buildbucketpb.Status]*color.Color{
buildbucketpb.Status_CANCELED: color.New(color.FgCyan),
buildbucketpb.Status_FAILURE: color.New(color.FgRed),
buildbucketpb.Status_STARTED: color.New(color.FgYellow),
buildbucketpb.Status_SUCCESS: color.New(color.FgGreen),
buildbucketpb.Status_STATUS_UNSPECIFIED: color.New(color.Reset),
}
func getColor(status buildbucketpb.Status) *color.Color {
color, ok := statusToColor[status]
if !ok {
color = statusToColor[buildbucketpb.Status_STATUS_UNSPECIFIED]
}
return color
}
func jobURLForBuild(build *buildbucketpb.Build) string {
var jobID string
if build.Infra != nil && build.Infra.Backend != nil && build.Infra.Backend.Task != nil && build.Infra.Backend.Task.Id != nil {
jobID = build.Infra.Backend.Task.Id.Id
}
if jobID == "" {
return "(not started yet)"
}
bucket := fmt.Sprintf("%s/%s", build.Builder.Project, build.Builder.Bucket)
tsURL := bbBucketToTaskSchedulerURL[bucket]
return fmt.Sprintf(tsURL, jobID)
}