cherry/testtiming: add a tool to query test timing data from LUCI The LUCI query code is essentially copied from watchflakes. Change-Id: I811d21b921f9cd8f6558a0637a1a162df77edeab Reviewed-on: https://go-review.googlesource.com/c/scratch/+/598594 TryBot-Bypass: Cherry Mui <cherryyz@google.com> Reviewed-by: Cherry Mui <cherryyz@google.com>
diff --git a/cherry/testtiming/go.mod b/cherry/testtiming/go.mod new file mode 100644 index 0000000..bc02362 --- /dev/null +++ b/cherry/testtiming/go.mod
@@ -0,0 +1,23 @@ +module golang.org/x/scratch/cherry/testtiming + +go 1.23 + +require ( + go.chromium.org/luci v0.0.0-20240716011143-b5eb7a221b66 + golang.org/x/sync v0.7.0 + google.golang.org/protobuf v1.34.2 +) + +require ( + github.com/golang/mock v1.6.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/julienschmidt/httprouter v1.3.0 // indirect + github.com/klauspost/compress v1.17.8 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/grpc v1.61.0 // indirect +)
diff --git a/cherry/testtiming/go.sum b/cherry/testtiming/go.sum new file mode 100644 index 0000000..3f62d76 --- /dev/null +++ b/cherry/testtiming/go.sum
@@ -0,0 +1,61 @@ +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/smarty/assertions v1.15.1 h1:812oFiXI+G55vxsFf+8bIZ1ux30qtkdqzKbEFwyX3Tk= +github.com/smarty/assertions v1.15.1/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.chromium.org/luci v0.0.0-20240716011143-b5eb7a221b66 h1:7M08VAaHGjcRE9gonYdmvUL8PKKek9NUZ7mFSjZyJA4= +go.chromium.org/luci v0.0.0-20240716011143-b5eb7a221b66/go.mod h1:VKWpjBb/iM+b62Tkkvb8Fs6bKxixITrPUpuImWvecvY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= +google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9 h1:4++qSzdWBUy9/2x8L5KZgwZw+mjJZ2yDSCGMVM0YzRs= +google.golang.org/genproto/googleapis/api v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:PVreiBMirk8ypES6aw9d4p6iiBNSIfZEBqr3UGoAi2E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 h1:hZB7eLIaYlW9qXRfCq/qDaPdbeY3757uARz5Vvfv+cY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:YUWgXUFRPfoYK1IHMuxH5K6nPEXSCzIMljnQ59lLRCk= +google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0= +google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
diff --git a/cherry/testtiming/luci.go b/cherry/testtiming/luci.go new file mode 100644 index 0000000..a399a49 --- /dev/null +++ b/cherry/testtiming/luci.go
@@ -0,0 +1,477 @@ +// 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() + } + } + } +}