blob: a399a49c0195ca63770d4e045dc732d34afd2352 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// An ad-hoc tool to query test timing data from LUCI.
//
// Output CSV with the following columns:
//
// commit hash, commit time, [builder,] status, pass duration, fail duration
//
// The "builder" column is omitted if only one builder
// is queried (the -builder flag).
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"regexp"
"slices"
"strings"
"time"
bbpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/api/gitiles"
gpb "go.chromium.org/luci/common/proto/gitiles"
"go.chromium.org/luci/grpc/prpc"
rdbpb "go.chromium.org/luci/resultdb/proto/v1"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
const resultDBHost = "results.api.cr.dev"
const crBuildBucketHost = "cr-buildbucket.appspot.com"
const gitilesHost = "go.googlesource.com"
// LUCIClient is a LUCI client.
type LUCIClient struct {
HTTPClient *http.Client
GitilesClient gpb.GitilesClient
BuildsClient bbpb.BuildsClient
BuildersClient bbpb.BuildersClient
ResultDBClient rdbpb.ResultDBClient
// TraceSteps controls whether to log each step name as it's executed.
TraceSteps bool
nProc int
}
// NewLUCIClient creates a LUCI client.
// nProc controls concurrency. NewLUCIClient panics if nProc is non-positive.
func NewLUCIClient(nProc int) *LUCIClient {
if nProc < 1 {
panic(fmt.Errorf("nProc is %d, want 1 or higher", nProc))
}
c := new(http.Client)
gitilesClient, err := gitiles.NewRESTClient(c, gitilesHost, false)
if err != nil {
log.Fatal(err)
}
buildsClient := bbpb.NewBuildsClient(&prpc.Client{
C: c,
Host: crBuildBucketHost,
})
buildersClient := bbpb.NewBuildersClient(&prpc.Client{
C: c,
Host: crBuildBucketHost,
})
resultDBClient := rdbpb.NewResultDBClient(&prpc.Client{
C: c,
Host: resultDBHost,
})
return &LUCIClient{
HTTPClient: c,
GitilesClient: gitilesClient,
BuildsClient: buildsClient,
BuildersClient: buildersClient,
ResultDBClient: resultDBClient,
nProc: nProc,
}
}
type BuilderConfigProperties struct {
Repo string `json:"project,omitempty"`
GoBranch string `json:"go_branch,omitempty"`
Target struct {
GOARCH string `json:"goarch,omitempty"`
GOOS string `json:"goos,omitempty"`
} `json:"target"`
KnownIssue int `json:"known_issue,omitempty"`
}
type Builder struct {
Name string
*BuilderConfigProperties
}
type BuildResult struct {
ID int64
Status bbpb.Status
Commit string // commit hash
Time time.Time // commit time
GoCommit string // for subrepo build, go commit hash
BuildTime time.Time // build end time
Builder string
*BuilderConfigProperties
InvocationID string // ResultDB invocation ID
LogURL string // textual log of the whole run
LogText string
StepLogURL string // textual log of the (last) failed step, if any
StepLogText string
Failures []*Failure
}
type Commit struct {
Hash string
Time time.Time
}
type Project struct {
Repo string
GoBranch string
}
type Dashboard struct {
Project
Builders []Builder
Commits []Commit
Results [][]*BuildResult // indexed by builder, then by commit
}
type Failure struct {
TestID string
Status rdbpb.TestStatus
LogURL string
LogText string
}
// ListCommits fetches the list of commits from Gerrit.
func (c *LUCIClient) ListCommits(ctx context.Context, repo, goBranch string, since time.Time) []Commit {
if c.TraceSteps {
log.Println("ListCommits", repo, goBranch)
}
branch := "master"
if repo == "go" {
branch = goBranch
}
var commits []Commit
var pageToken string
nextPage:
resp, err := c.GitilesClient.Log(ctx, &gpb.LogRequest{
Project: repo,
Committish: "refs/heads/" + branch,
PageSize: 1000,
PageToken: pageToken,
})
if err != nil {
log.Fatal(err)
}
for _, c := range resp.GetLog() {
commitTime := c.GetCommitter().GetTime().AsTime()
if commitTime.Before(since) {
goto done
}
commits = append(commits, Commit{
Hash: c.GetId(),
Time: commitTime,
})
}
if resp.GetNextPageToken() != "" {
pageToken = resp.GetNextPageToken()
goto nextPage
}
done:
return commits
}
// ListBuilders fetches the list of builders, on the given repo and goBranch.
// If repo and goBranch are empty, it fetches all builders.
func (c *LUCIClient) ListBuilders(ctx context.Context, repo, goBranch, builder string) ([]Builder, error) {
if c.TraceSteps {
log.Println("ListBuilders", repo, goBranch)
}
all := repo == "" && goBranch == ""
var builders []Builder
var pageToken string
nextPage:
resp, err := c.BuildersClient.ListBuilders(ctx, &bbpb.ListBuildersRequest{
Project: "golang",
Bucket: "ci",
PageSize: 1000,
PageToken: pageToken,
})
if err != nil {
return nil, err
}
for _, b := range resp.GetBuilders() {
var p BuilderConfigProperties
json.Unmarshal([]byte(b.GetConfig().GetProperties()), &p)
if all || (p.Repo == repo && p.GoBranch == goBranch) {
bName := b.GetId().GetBuilder()
if builder != "" && bName != builder { // just want one builder, skip others
continue
}
builders = append(builders, Builder{bName, &p})
}
}
if resp.GetNextPageToken() != "" {
pageToken = resp.GetNextPageToken()
goto nextPage
}
slices.SortFunc(builders, func(a, b Builder) int {
return strings.Compare(a.Name, b.Name)
})
return builders, nil
}
// GetBuilds fetches builds from one builder.
func (c *LUCIClient) GetBuilds(ctx context.Context, builder string, since time.Time) ([]*bbpb.Build, error) {
if c.TraceSteps {
log.Println("GetBuilds", builder)
}
pred := &bbpb.BuildPredicate{
Builder: &bbpb.BuilderID{Project: "golang", Bucket: "ci", Builder: builder},
CreateTime: &bbpb.TimeRange{StartTime: timestamppb.New(since)},
}
mask, err := fieldmaskpb.New((*bbpb.Build)(nil), "id", "builder", "output", "status", "steps", "infra", "end_time")
if err != nil {
return nil, err
}
var builds []*bbpb.Build
var pageToken string
nextPage:
resp, err := c.BuildsClient.SearchBuilds(ctx, &bbpb.SearchBuildsRequest{
Predicate: pred,
Mask: &bbpb.BuildMask{Fields: mask},
PageSize: 1000,
PageToken: pageToken,
})
if err != nil {
return nil, err
}
builds = append(builds, resp.GetBuilds()...)
if resp.GetNextPageToken() != "" {
pageToken = resp.GetNextPageToken()
goto nextPage
}
return builds, nil
}
// ReadBoard reads the build dashboard dash, then fills in the content.
func (c *LUCIClient) ReadBoard(ctx context.Context, dash *Dashboard, builder string, since time.Time) error {
if c.TraceSteps {
log.Println("ReadBoard", dash.Repo, dash.GoBranch)
}
dash.Commits = c.ListCommits(ctx, dash.Repo, dash.GoBranch, since)
var err error
dash.Builders, err = c.ListBuilders(ctx, dash.Repo, dash.GoBranch, builder)
if err != nil {
return err
}
dashMap := make([]map[string]*BuildResult, len(dash.Builders)) // indexed by builder, then keyed by commit hash
// Get builds from builders.
g, groupContext := errgroup.WithContext(ctx)
g.SetLimit(c.nProc)
for i, builder := range dash.Builders {
builder := builder
buildMap := make(map[string]*BuildResult)
dashMap[i] = buildMap
g.Go(func() error {
bName := builder.Name
builds, err := c.GetBuilds(groupContext, bName, since)
if err != nil {
return err
}
for _, b := range builds {
id := b.GetId()
var commit, goCommit string
prop := b.GetOutput().GetProperties().GetFields()
for _, s := range prop["sources"].GetListValue().GetValues() {
x := s.GetStructValue().GetFields()["gitilesCommit"].GetStructValue().GetFields()
c := x["id"].GetStringValue()
switch repo := x["project"].GetStringValue(); repo {
case dash.Repo:
commit = c
case "go":
goCommit = c
default:
log.Fatalf("repo mismatch: %s %s %s", repo, dash.Repo, buildURL(id))
}
}
if commit == "" {
switch b.GetStatus() {
case bbpb.Status_SUCCESS:
log.Fatalf("empty commit: %s", buildURL(id))
default:
// unfinished build, or infra failure, ignore
continue
}
}
buildTime := b.GetEndTime().AsTime()
if r0 := buildMap[commit]; r0 != nil {
// A build already exists for the same builder and commit.
// Maybe manually retried, or different go commits on same subrepo commit.
// Pick the one ended at later time.
const printDup = false
if printDup {
fmt.Printf("skip duplicate build: %s %s %d %d\n", bName, shortHash(commit), id, r0.ID)
}
if buildTime.Before(r0.BuildTime) {
continue
}
}
rdb := b.GetInfra().GetResultdb()
if rdb.GetHostname() != resultDBHost {
log.Fatalf("ResultDB host mismatch: %s %s %s", rdb.GetHostname(), resultDBHost, buildURL(id))
}
if b.GetBuilder().GetBuilder() != bName { // sanity check
log.Fatalf("builder mismatch: %s %s %s", b.GetBuilder().GetBuilder(), bName, buildURL(id))
}
r := &BuildResult{
ID: id,
Status: b.GetStatus(),
Commit: commit,
GoCommit: goCommit,
BuildTime: buildTime,
Builder: bName,
BuilderConfigProperties: builder.BuilderConfigProperties,
InvocationID: rdb.GetInvocation(),
}
if r.Status == bbpb.Status_FAILURE {
links := prop["failure"].GetStructValue().GetFields()["links"].GetListValue().GetValues()
for _, l := range links {
m := l.GetStructValue().GetFields()
if strings.Contains(m["name"].GetStringValue(), "(combined output)") {
r.LogURL = m["url"].GetStringValue()
break
}
}
if r.LogURL == "" {
// No log URL, Probably a build failure.
// E.g. https://ci.chromium.org/ui/b/8759448820419452721
// Use the build's stderr instead.
for _, l := range b.GetOutput().GetLogs() {
if l.GetName() == "stderr" {
r.LogURL = l.GetViewUrl()
break
}
}
}
// Fetch the stderr of the failed step.
steps := b.GetSteps()
stepLoop:
for i := len(steps) - 1; i >= 0; i-- {
s := steps[i]
if s.GetStatus() == bbpb.Status_FAILURE {
for _, l := range s.GetLogs() {
if l.GetName() == "stderr" || l.GetName() == "output" {
r.StepLogURL = l.GetViewUrl()
break stepLoop
}
}
}
}
}
buildMap[commit] = r
}
return nil
})
}
if err := g.Wait(); err != nil {
return err
}
// Gather into dashboard.
dash.Results = make([][]*BuildResult, len(dash.Builders))
for i, m := range dashMap {
dash.Results[i] = make([]*BuildResult, len(dash.Commits))
for j, c := range dash.Commits {
r := m[c.Hash]
if r == nil {
continue
}
r.Time = c.Time // fill in commit time
dash.Results[i][j] = r
}
}
return nil
}
func buildURL(buildID int64) string { // keep in sync with buildUrlRE in github.go
return fmt.Sprintf("https://ci.chromium.org/b/%d", buildID)
}
func shortHash(s string) string {
if len(s) > 8 {
return s[:8]
}
return s
}
var (
repo = flag.String("repo", "go", "repo name (defualt: \"go\")")
branch = flag.String("branch", "master", "branch (defualt: \"master\")")
builder = flag.String("builder", "", "builder to query, if unset, query all builders")
test = flag.String("test", "", "test name")
)
func main() {
flag.Parse()
if *test == "" {
flag.Usage()
log.Fatal("test name unset")
}
ctx := context.Background()
c := NewLUCIClient(1)
c.TraceSteps = true
// LUCI keeps data up to 60 days, so there is no point to go back farther
startTime := time.Now().Add(-60 * 24 * time.Hour)
dash := &Dashboard{Project: Project{*repo, *branch}}
c.ReadBoard(ctx, dash, *builder, startTime)
printBuilder := func(string) {}
if len(dash.Builders) > 1 {
printBuilder = func(s string) { fmt.Print(s, ",") }
}
for i, b := range dash.Builders {
for _, r := range dash.Results[i] {
if r == nil {
continue
}
if c.TraceSteps {
log.Println("QueryTestResultsRequest", b.Name, shortHash(r.Commit), r.Time)
}
req := &rdbpb.QueryTestResultsRequest{
Invocations: []string{r.InvocationID},
Predicate: &rdbpb.TestResultPredicate{
TestIdRegexp: regexp.QuoteMeta(*test),
},
}
resp, err := c.ResultDBClient.QueryTestResults(ctx, req)
if err != nil {
log.Fatal(err)
}
for _, rr := range resp.GetTestResults() {
status := rr.GetStatus()
if status == rdbpb.TestStatus_SKIP {
continue
}
dur := rr.GetDuration().AsDuration()
fmt.Print(shortHash(r.Commit), ",", r.Time, ",")
printBuilder(b.Name)
fmt.Print(status, ",")
// Split pass and fail results so it is easy to plot them in
// different colors.
if status == rdbpb.TestStatus_PASS {
fmt.Print(dur.Seconds(), ",")
} else {
fmt.Print(",", dur.Seconds())
}
fmt.Println()
}
}
}
}