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()
+			}
+		}
+	}
+}