cmd/coordinator/internal/legacydash: convert app/appengine to library
Add a copy of x/build/app/appengine while making minimal modifications
to make it a package that coordinator can import instead of a command.
It'll be integrated into cmd/coordinator in the next CL.
The CL following that will delete the app/appengine code.
For golang/go#34744.
Change-Id: I6ee6e9547144c56fc04147a17f46c83d1bb37933
Reviewed-on: https://go-review.googlesource.com/c/build/+/336790
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
Trust: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Carlos Amedee <carlos@golang.org>
diff --git a/cmd/coordinator/dash.go b/cmd/coordinator/dash.go
index e21c9b5..d3c5d6b 100644
--- a/cmd/coordinator/dash.go
+++ b/cmd/coordinator/dash.go
@@ -42,7 +42,7 @@
// If resp is non-nil the server's response is decoded into the value pointed
// to by resp (resp must be a pointer).
func dash(meth, cmd string, args url.Values, req, resp interface{}) error {
- const builderVersion = 1 // keep in sync with dashboard/app/build/handler.go
+ const builderVersion = 1 // keep in sync with cmd/coordinator/internal/legacydash/handler.go
argsCopy := url.Values{"version": {fmt.Sprint(builderVersion)}}
for k, v := range args {
if k == "version" {
diff --git a/cmd/coordinator/internal/legacydash/README.md b/cmd/coordinator/internal/legacydash/README.md
new file mode 100644
index 0000000..561b293
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/README.md
@@ -0,0 +1,7 @@
+<!-- Auto-generated by x/build/update-readmes.go -->
+
+[![Go Reference](https://pkg.go.dev/badge/golang.org/x/build/cmd/coordinator/internal/legacydash.svg)](https://pkg.go.dev/golang.org/x/build/cmd/coordinator/internal/legacydash)
+
+# golang.org/x/build/cmd/coordinator/internal/legacydash
+
+Package legacydash holds the serving code for the build dashboard (build.golang.org) and its remaining HTTP API endpoints.
diff --git a/cmd/coordinator/internal/legacydash/build.go b/cmd/coordinator/internal/legacydash/build.go
new file mode 100644
index 0000000..f6896fe
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/build.go
@@ -0,0 +1,460 @@
+// Copyright 2011 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+ "bytes"
+ "compress/gzip"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "math/rand"
+ pathpkg "path"
+ "strings"
+
+ "cloud.google.com/go/datastore"
+ "golang.org/x/build/dashboard"
+ "golang.org/x/build/internal/loghash"
+)
+
+const (
+ maxDatastoreStringLen = 500
+)
+
+func dsKey(kind, name string, parent *datastore.Key) *datastore.Key {
+ dk := datastore.NameKey(kind, name, parent)
+ dk.Namespace = "Git"
+ return dk
+}
+
+// A Package describes a package that is listed on the dashboard.
+type Package struct {
+ Name string // "Go", "arch", "net", ...
+ Path string // empty for the main Go tree, else "golang.org/x/foo"
+}
+
+func (p *Package) String() string {
+ return fmt.Sprintf("%s: %q", p.Path, p.Name)
+}
+
+func (p *Package) Key() *datastore.Key {
+ key := p.Path
+ if key == "" {
+ key = "go"
+ }
+ return dsKey("Package", key, nil)
+}
+
+// filterDatastoreError returns err, unless it's just about datastore
+// not being able to load an entity with old legacy struct fields into
+// the Commit type that has since removed those fields.
+func filterDatastoreError(err error) error {
+ return filterAppEngineError(err, func(err error) bool {
+ if em, ok := err.(*datastore.ErrFieldMismatch); ok {
+ switch em.FieldName {
+ case "NeedsBenchmarking", "TryPatch", "FailNotificationSent":
+ // Removed in CLs 208397 and 208324.
+ return true
+ case "PackagePath", "ParentHash", "Num", "User", "Desc", "Time", "Branch", "NextNum", "Kind":
+ // Removed in move to maintner in CL 208697.
+ return true
+ }
+ }
+ return false
+ })
+}
+
+// filterNoSuchEntity returns err, unless it's just about datastore
+// not being able to load an entity because it doesn't exist.
+func filterNoSuchEntity(err error) error {
+ return filterAppEngineError(err, func(err error) bool {
+ return err == datastore.ErrNoSuchEntity
+ })
+}
+
+// filterAppEngineError returns err, unless ignore(err) is true,
+// in which case it returns nil. If err is an datastore.MultiError,
+// it returns either nil (if all errors are ignored) or a deep copy
+// with the non-ignored errors.
+func filterAppEngineError(err error, ignore func(error) bool) error {
+ if err == nil || ignore(err) {
+ return nil
+ }
+ if me, ok := err.(datastore.MultiError); ok {
+ me2 := make(datastore.MultiError, 0, len(me))
+ for _, err := range me {
+ if e2 := filterAppEngineError(err, ignore); e2 != nil {
+ me2 = append(me2, e2)
+ }
+ }
+ if len(me2) == 0 {
+ return nil
+ }
+ return me2
+ }
+ return err
+}
+
+// getOrMakePackageInTx fetches a Package by path from the datastore,
+// creating it if necessary.
+func getOrMakePackageInTx(ctx context.Context, tx *datastore.Transaction, path string) (*Package, error) {
+ p := &Package{Path: path}
+ if path != "" {
+ p.Name = pathpkg.Base(path)
+ } else {
+ p.Name = "Go"
+ }
+ err := tx.Get(p.Key(), p)
+ err = filterDatastoreError(err)
+ if err == datastore.ErrNoSuchEntity {
+ if _, err := tx.Put(p.Key(), p); err != nil {
+ return nil, err
+ }
+ return p, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return p, nil
+}
+
+type builderAndGoHash struct {
+ builder, goHash string
+}
+
+// A Commit describes an individual commit in a package.
+//
+// Each Commit entity is a descendant of its associated Package entity.
+// In other words, all Commits with the same PackagePath belong to the same
+// datastore entity group.
+type Commit struct {
+ PackagePath string // (empty for main repo commits)
+ Hash string
+
+ // ResultData is the Data string of each build Result for this Commit.
+ // For non-Go commits, only the Results for the current Go tip, weekly,
+ // and release Tags are stored here. This is purely de-normalized data.
+ // The complete data set is stored in Result entities.
+ ResultData []string `datastore:",noindex"`
+}
+
+func (com *Commit) Key() *datastore.Key {
+ if com.Hash == "" {
+ panic("tried Key on Commit with empty Hash")
+ }
+ p := Package{Path: com.PackagePath}
+ key := com.PackagePath + "|" + com.Hash
+ return dsKey("Commit", key, p.Key())
+}
+
+// Valid reports whether the commit is valid.
+func (c *Commit) Valid() bool {
+ // Valid really just means the hash is populated.
+ return validHash(c.Hash)
+}
+
+// each result line is approx 105 bytes. This constant is a tradeoff between
+// build history and the AppEngine datastore limit of 1mb.
+const maxResults = 1000
+
+// AddResult adds the denormalized Result data to the Commit's
+// ResultData field.
+func (com *Commit) AddResult(tx *datastore.Transaction, r *Result) error {
+ err := tx.Get(com.Key(), com)
+ if err == datastore.ErrNoSuchEntity {
+ // If it doesn't exist, we create it below.
+ } else {
+ err = filterDatastoreError(err)
+ if err != nil {
+ return fmt.Errorf("Commit.AddResult, getting Commit: %v", err)
+ }
+ }
+
+ var resultExists bool
+ for i, s := range com.ResultData {
+ // if there already exists result data for this builder at com, overwrite it.
+ if strings.HasPrefix(s, r.Builder+"|") && strings.HasSuffix(s, "|"+r.GoHash) {
+ resultExists = true
+ com.ResultData[i] = r.Data()
+ }
+ }
+ if !resultExists {
+ // otherwise, add the new result data for this builder.
+ com.ResultData = trim(append(com.ResultData, r.Data()), maxResults)
+ }
+ if !com.Valid() {
+ return errors.New("putting Commit: commit is not valid")
+ }
+ if _, err := tx.Put(com.Key(), com); err != nil {
+ return fmt.Errorf("putting Commit: %v", err)
+ }
+ return nil
+}
+
+// removeResult removes the denormalized Result data from the ResultData field
+// for the given builder and go hash.
+// It must be called from within the datastore transaction that gets and puts
+// the Commit. Note this is slightly different to AddResult, above.
+func (com *Commit) RemoveResult(r *Result) {
+ var rd []string
+ for _, s := range com.ResultData {
+ if strings.HasPrefix(s, r.Builder+"|") && strings.HasSuffix(s, "|"+r.GoHash) {
+ continue
+ }
+ rd = append(rd, s)
+ }
+ com.ResultData = rd
+}
+
+func trim(s []string, n int) []string {
+ l := min(len(s), n)
+ return s[len(s)-l:]
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// Result returns the build Result for this Commit for the given builder/goHash.
+//
+// For the main Go repo, goHash is the empty string.
+func (c *Commit) Result(builder, goHash string) *Result {
+ return result(c.ResultData, c.Hash, c.PackagePath, builder, goHash)
+}
+
+// Result returns the build Result for this commit for the given builder/goHash.
+//
+// For the main Go repo, goHash is the empty string.
+func (c *CommitInfo) Result(builder, goHash string) *Result {
+ if r := result(c.ResultData, c.Hash, c.PackagePath, builder, goHash); r != nil {
+ return r
+ }
+ if u, ok := c.BuildingURLs[builderAndGoHash{builder, goHash}]; ok {
+ return &Result{
+ Builder: builder,
+ BuildingURL: u,
+ Hash: c.Hash,
+ GoHash: goHash,
+ }
+ }
+ if fakeResults {
+ // Create a fake random result.
+ switch rand.Intn(3) {
+ default:
+ return nil
+ case 1:
+ return &Result{
+ Builder: builder,
+ Hash: c.Hash,
+ GoHash: goHash,
+ OK: true,
+ }
+ case 2:
+ return &Result{
+ Builder: builder,
+ Hash: c.Hash,
+ GoHash: goHash,
+ LogHash: "fakefailureurl",
+ }
+ }
+ }
+ return nil
+}
+
+func result(resultData []string, hash, packagePath, builder, goHash string) *Result {
+ for _, r := range resultData {
+ if !strings.HasPrefix(r, builder) {
+ // Avoid strings.SplitN alloc in the common case.
+ continue
+ }
+ p := strings.SplitN(r, "|", 4)
+ if len(p) != 4 || p[0] != builder || p[3] != goHash {
+ continue
+ }
+ return partsToResult(hash, packagePath, p)
+ }
+ return nil
+}
+
+// isUntested reports whether a cell in the build.golang.org grid is
+// an untested configuration.
+//
+// repo is "go", "net", etc.
+// branch is the branch of repo "master" or "release-branch.go1.12"
+// goBranch applies only if repo != "go" and is of form "master" or "release-branch.go1.N"
+//
+// As a special case, "tip" is an alias for "master", since this app
+// still uses a bunch of hg terms from when we used hg.
+func isUntested(builder, repo, branch, goBranch string) bool {
+ if branch == "tip" {
+ branch = "master"
+ }
+ if goBranch == "tip" {
+ goBranch = "master"
+ }
+ bc, ok := dashboard.Builders[builder]
+ if !ok {
+ // Unknown builder, so not tested.
+ return true
+ }
+ return !bc.BuildsRepoPostSubmit(repo, branch, goBranch)
+}
+
+// knownIssue returns a known issue for the named builder,
+// or zero if there isn't a known issue.
+func knownIssue(builder string) int {
+ bc, ok := dashboard.Builders[builder]
+ if !ok {
+ // Unknown builder.
+ return 0
+ }
+ return bc.KnownIssue
+}
+
+// Results returns the build Results for this Commit.
+func (c *CommitInfo) Results() (results []*Result) {
+ for _, r := range c.ResultData {
+ p := strings.SplitN(r, "|", 4)
+ if len(p) != 4 {
+ continue
+ }
+ results = append(results, partsToResult(c.Hash, c.PackagePath, p))
+ }
+ return
+}
+
+// ResultGoHashes, for non-go repos, returns the list of Go hashes that
+// this repo has been (or should be) built at.
+//
+// For the main Go repo it always returns a slice with 1 element: the
+// empty string.
+func (c *CommitInfo) ResultGoHashes() []string {
+ // For the main repo, just return the empty string
+ // (there's no corresponding main repo hash for a main repo Commit).
+ // This function is only really useful for sub-repos.
+ if c.PackagePath == "" {
+ return []string{""}
+ }
+ var hashes []string
+ for _, r := range c.ResultData {
+ p := strings.SplitN(r, "|", 4)
+ if len(p) != 4 {
+ continue
+ }
+ // Append only new results (use linear scan to preserve order).
+ if !contains(hashes, p[3]) {
+ hashes = append(hashes, p[3])
+ }
+ }
+ // Return results in reverse order (newest first).
+ reverse(hashes)
+ return hashes
+}
+
+func contains(t []string, s string) bool {
+ for _, s2 := range t {
+ if s2 == s {
+ return true
+ }
+ }
+ return false
+}
+
+func reverse(s []string) {
+ for i := 0; i < len(s)/2; i++ {
+ j := len(s) - i - 1
+ s[i], s[j] = s[j], s[i]
+ }
+}
+
+// partsToResult creates a Result from ResultData substrings.
+func partsToResult(hash, packagePath string, p []string) *Result {
+ return &Result{
+ Builder: p[0],
+ Hash: hash,
+ PackagePath: packagePath,
+ GoHash: p[3],
+ OK: p[1] == "true",
+ LogHash: p[2],
+ }
+}
+
+// A Result describes a build result for a Commit on an OS/architecture.
+//
+// Each Result entity is a descendant of its associated Package entity.
+type Result struct {
+ Builder string // "os-arch[-note]"
+ PackagePath string // (empty for Go commits, else "golang.org/x/foo")
+ Hash string
+
+ // The Go Commit this was built against (when PackagePath != ""; empty for Go commits).
+ GoHash string
+
+ BuildingURL string `datastore:"-"` // non-empty if currently building
+ OK bool
+ Log string `datastore:"-"` // for JSON unmarshaling only
+ LogHash string `datastore:",noindex"` // Key to the Log record.
+
+ RunTime int64 // time to build+test in nanoseconds
+}
+
+func (r *Result) Key() *datastore.Key {
+ p := Package{Path: r.PackagePath}
+ key := r.Builder + "|" + r.PackagePath + "|" + r.Hash + "|" + r.GoHash
+ return dsKey("Result", key, p.Key())
+}
+
+func (r *Result) Valid() error {
+ if !validHash(r.Hash) {
+ return errors.New("invalid Hash")
+ }
+ if r.PackagePath != "" && !validHash(r.GoHash) {
+ return errors.New("invalid GoHash")
+ }
+ return nil
+}
+
+// Data returns the Result in string format
+// to be stored in Commit's ResultData field.
+func (r *Result) Data() string {
+ return fmt.Sprintf("%v|%v|%v|%v", r.Builder, r.OK, r.LogHash, r.GoHash)
+}
+
+// A Log is a gzip-compressed log file stored under the SHA1 hash of the
+// uncompressed log text.
+type Log struct {
+ CompressedLog []byte `datastore:",noindex"`
+}
+
+func (l *Log) Text() ([]byte, error) {
+ d, err := gzip.NewReader(bytes.NewBuffer(l.CompressedLog))
+ if err != nil {
+ return nil, fmt.Errorf("reading log data: %v", err)
+ }
+ b, err := ioutil.ReadAll(d)
+ if err != nil {
+ return nil, fmt.Errorf("reading log data: %v", err)
+ }
+ return b, nil
+}
+
+func PutLog(c context.Context, text string) (hash string, err error) {
+ b := new(bytes.Buffer)
+ z, _ := gzip.NewWriterLevel(b, gzip.BestCompression)
+ io.WriteString(z, text)
+ z.Close()
+ hash = loghash.New(text)
+ key := dsKey("Log", hash, nil)
+ _, err = datastoreClient.Put(c, key, &Log{b.Bytes()})
+ return
+}
diff --git a/cmd/coordinator/internal/legacydash/dash.go b/cmd/coordinator/internal/legacydash/dash.go
new file mode 100644
index 0000000..51180dd
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/dash.go
@@ -0,0 +1,131 @@
+// Copyright 2013 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.
+
+//go:build go1.16
+// +build go1.16
+
+// Package legacydash holds the serving code for the build dashboard
+// (build.golang.org) and its remaining HTTP API endpoints.
+//
+// It's a code transplant of the previous app/appengine application,
+// converted into a package that coordinator can import and use.
+// A newer version of the build dashboard is in development in
+// the golang.org/x/build/cmd/coordinator/internal/dashboard package.
+package legacydash
+
+import (
+ "embed"
+ "net/http"
+ "sort"
+
+ "cloud.google.com/go/datastore"
+ "github.com/NYTimes/gziphandler"
+ "golang.org/x/build/maintner/maintnerd/apipb"
+ "golang.org/x/build/repos"
+)
+
+var (
+ // Datastore client to a GCP project where build results are stored.
+ // Typically this is the golang-org GCP project.
+ datastoreClient *datastore.Client
+
+ // Maintner client for the maintner service.
+ // Typically the one at maintner.golang.org.
+ maintnerClient apipb.MaintnerServiceClient
+
+ // The builder master key.
+ masterKey string
+
+ // TODO(golang.org/issue/38337): Keep moving away from package scope
+ // variables during future refactors.
+)
+
+// fakeResults controls whether to make up fake random results. If true, datastore is not used.
+const fakeResults = false
+
+// Handler sets a datastore client, maintner client, and builder master key
+// at the package scope, and returns an HTTP mux for the legacy dashboard.
+func Handler(dc *datastore.Client, mc apipb.MaintnerServiceClient, key string) http.Handler {
+ datastoreClient = dc
+ maintnerClient = mc
+ masterKey = key
+
+ mux := http.NewServeMux()
+
+ // authenticated handlers
+ mux.Handle("/clear-results", hstsGzip(AuthHandler(clearResultsHandler))) // called by coordinator for x/build/cmd/retrybuilds
+ mux.Handle("/result", hstsGzip(AuthHandler(resultHandler))) // called by coordinator after build
+
+ // public handlers
+ mux.Handle("/", hstsGzip(http.HandlerFunc(uiHandler)))
+ mux.Handle("/log/", hstsGzip(http.HandlerFunc(logHandler)))
+
+ // static handler
+ fs := http.FileServer(http.FS(static))
+ mux.Handle("/static/", hstsGzip(fs))
+
+ return mux
+}
+
+//go:embed static
+var static embed.FS
+
+// hstsGzip is short for hstsHandler(GzipHandler(h)).
+func hstsGzip(h http.Handler) http.Handler {
+ return hstsHandler(gziphandler.GzipHandler(h))
+}
+
+// hstsHandler returns a Handler that sets the HSTS header but
+// otherwise just wraps h.
+func hstsHandler(h http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload")
+ h.ServeHTTP(w, r)
+ })
+}
+
+// Dashboard describes a unique build dashboard.
+//
+// (There used to be more than one dashboard, so this is now somewhat
+// less important than it once was.)
+type Dashboard struct {
+ Name string // This dashboard's name (always "Go" nowadays)
+ Packages []*Package // The project's packages to build
+}
+
+// packageWithPath returns the Package in d with the provided importPath,
+// or nil if none is found.
+func (d *Dashboard) packageWithPath(importPath string) *Package {
+ for _, p := range d.Packages {
+ if p.Path == importPath {
+ return p
+ }
+ }
+ return nil
+}
+
+// goDash is the dashboard for the main go repository.
+var goDash = &Dashboard{
+ Name: "Go",
+ Packages: []*Package{
+ {Name: "Go"},
+ },
+}
+
+func init() {
+ var add []*Package
+ for _, r := range repos.ByGerritProject {
+ if !r.ShowOnDashboard() {
+ continue
+ }
+ add = append(add, &Package{
+ Name: r.GoGerritProject,
+ Path: r.ImportPath,
+ })
+ }
+ sort.Slice(add, func(i, j int) bool {
+ return add[i].Name < add[j].Name
+ })
+ goDash.Packages = append(goDash.Packages, add...)
+}
diff --git a/cmd/coordinator/internal/legacydash/handler.go b/cmd/coordinator/internal/legacydash/handler.go
new file mode 100644
index 0000000..7472a86
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/handler.go
@@ -0,0 +1,275 @@
+// Copyright 2011 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/md5"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "unicode/utf8"
+
+ "cloud.google.com/go/datastore"
+)
+
+const (
+ commitsPerPage = 30
+ builderVersion = 1 // must match x/build/cmd/coordinator/dash.go's value
+)
+
+// resultHandler records a build result.
+// It reads a JSON-encoded Result value from the request body,
+// creates a new Result entity, and creates or updates the relevant Commit entity.
+// If the Log field is not empty, resultHandler creates a new Log entity
+// and updates the LogHash field before putting the Commit entity.
+func resultHandler(r *http.Request) (interface{}, error) {
+ if r.Method != "POST" {
+ return nil, errBadMethod(r.Method)
+ }
+
+ v, _ := strconv.Atoi(r.FormValue("version"))
+ if v != builderVersion {
+ return nil, fmt.Errorf("rejecting POST from builder; need version %v instead of %v",
+ builderVersion, v)
+ }
+
+ ctx := r.Context()
+ res := new(Result)
+ defer r.Body.Close()
+ if err := json.NewDecoder(r.Body).Decode(res); err != nil {
+ return nil, fmt.Errorf("decoding Body: %v", err)
+ }
+ if err := res.Valid(); err != nil {
+ return nil, fmt.Errorf("validating Result: %v", err)
+ }
+ // store the Log text if supplied
+ if len(res.Log) > 0 {
+ hash, err := PutLog(ctx, res.Log)
+ if err != nil {
+ return nil, fmt.Errorf("putting Log: %v", err)
+ }
+ res.LogHash = hash
+ }
+ tx := func(tx *datastore.Transaction) error {
+ if _, err := getOrMakePackageInTx(ctx, tx, res.PackagePath); err != nil {
+ return fmt.Errorf("GetPackage: %v", err)
+ }
+ // put Result
+ if _, err := tx.Put(res.Key(), res); err != nil {
+ return fmt.Errorf("putting Result: %v", err)
+ }
+ // add Result to Commit
+ com := &Commit{PackagePath: res.PackagePath, Hash: res.Hash}
+ if err := com.AddResult(tx, res); err != nil {
+ return fmt.Errorf("AddResult: %v", err)
+ }
+ return nil
+ }
+ _, err := datastoreClient.RunInTransaction(ctx, tx)
+ return nil, err
+}
+
+// logHandler displays log text for a given hash.
+// It handles paths like "/log/hash".
+func logHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-type", "text/plain; charset=utf-8")
+ c := r.Context()
+ hash := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
+ key := dsKey("Log", hash, nil)
+ l := new(Log)
+ if err := datastoreClient.Get(c, key, l); err != nil {
+ if err == datastore.ErrNoSuchEntity {
+ // Fall back to default namespace;
+ // maybe this was on the old dashboard.
+ key.Namespace = ""
+ err = datastoreClient.Get(c, key, l)
+ }
+ if err != nil {
+ logErr(w, r, err)
+ return
+ }
+ }
+ b, err := l.Text()
+ if err != nil {
+ logErr(w, r, err)
+ return
+ }
+ w.Write(b)
+}
+
+// clearResultsHandler purge a single build failure from the dashboard.
+// It currently only supports the main Go repo.
+func clearResultsHandler(r *http.Request) (interface{}, error) {
+ if r.Method != "POST" {
+ return nil, errBadMethod(r.Method)
+ }
+ builder := r.FormValue("builder")
+ hash := r.FormValue("hash")
+ if builder == "" {
+ return nil, errors.New("missing 'builder'")
+ }
+ if hash == "" {
+ return nil, errors.New("missing 'hash'")
+ }
+
+ ctx := r.Context()
+
+ _, err := datastoreClient.RunInTransaction(ctx, func(tx *datastore.Transaction) error {
+ c := &Commit{
+ PackagePath: "", // TODO(adg): support clearing sub-repos
+ Hash: hash,
+ }
+ err := tx.Get(c.Key(), c)
+ err = filterDatastoreError(err)
+ if err == datastore.ErrNoSuchEntity {
+ // Doesn't exist, so no build to clear.
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+
+ r := c.Result(builder, "")
+ if r == nil {
+ // No result, so nothing to clear.
+ return nil
+ }
+ c.RemoveResult(r)
+ _, err = tx.Put(c.Key(), c)
+ if err != nil {
+ return err
+ }
+ return tx.Delete(r.Key())
+ })
+ return nil, err
+}
+
+type dashHandler func(*http.Request) (interface{}, error)
+
+type dashResponse struct {
+ Response interface{}
+ Error string
+}
+
+// errBadMethod is returned by a dashHandler when
+// the request has an unsuitable method.
+type errBadMethod string
+
+func (e errBadMethod) Error() string {
+ return "bad method: " + string(e)
+}
+
+func builderKeyRevoked(builder string) bool {
+ switch builder {
+ case "plan9-amd64-mischief":
+ // Broken and unmaintained for months.
+ // It's polluting the dashboard.
+ return true
+ case "linux-arm-onlinenet":
+ // Requested to be revoked by Dave Cheney.
+ // The machine is in a fail+report loop
+ // and can't be accessed. Revoke it for now.
+ return true
+ }
+ return false
+}
+
+// AuthHandler wraps a http.HandlerFunc with a handler that validates the
+// supplied key and builder query parameters.
+func AuthHandler(h dashHandler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c := r.Context()
+
+ // Put the URL Query values into r.Form to avoid parsing the
+ // request body when calling r.FormValue.
+ r.Form = r.URL.Query()
+
+ var err error
+ var resp interface{}
+
+ // Validate key query parameter for POST requests only.
+ key := r.FormValue("key")
+ builder := r.FormValue("builder")
+ if r.Method == "POST" && !validKey(c, key, builder) {
+ err = fmt.Errorf("invalid key %q for builder %q", key, builder)
+ }
+
+ // Call the original HandlerFunc and return the response.
+ if err == nil {
+ resp, err = h(r)
+ }
+
+ // Write JSON response.
+ dashResp := &dashResponse{Response: resp}
+ if err != nil {
+ log.Printf("%v", err)
+ dashResp.Error = err.Error()
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if err = json.NewEncoder(w).Encode(dashResp); err != nil {
+ log.Printf("encoding response: %v", err)
+ }
+ })
+}
+
+// validHash reports whether hash looks like a valid git commit hash.
+func validHash(hash string) bool {
+ // TODO: correctly validate a hash: check that it's exactly 40
+ // lowercase hex digits. But this is what we historically did:
+ return hash != ""
+}
+
+func validKey(c context.Context, key, builder string) bool {
+ if isMasterKey(c, key) {
+ return true
+ }
+ if builderKeyRevoked(builder) {
+ return false
+ }
+ return key == builderKey(c, builder)
+}
+
+func isMasterKey(ctx context.Context, k string) bool {
+ return k == masterKey
+}
+
+func builderKey(ctx context.Context, builder string) string {
+ h := hmac.New(md5.New, []byte(masterKey))
+ h.Write([]byte(builder))
+ return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+func logErr(w http.ResponseWriter, r *http.Request, err error) {
+ log.Printf("Error: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ fmt.Fprint(w, "Error: ", html.EscapeString(err.Error()))
+}
+
+// limitStringLength essentially does return s[:max],
+// but it ensures that we dot not split UTF-8 rune in half.
+// Otherwise appengine python scripts will break badly.
+func limitStringLength(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ for {
+ s = s[:max]
+ r, size := utf8.DecodeLastRuneInString(s)
+ if r != utf8.RuneError || size != 1 {
+ return s
+ }
+ max--
+ }
+}
diff --git a/cmd/coordinator/internal/legacydash/static/status_alert.gif b/cmd/coordinator/internal/legacydash/static/status_alert.gif
new file mode 100644
index 0000000..495d9d2
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/static/status_alert.gif
Binary files differ
diff --git a/cmd/coordinator/internal/legacydash/static/status_good.gif b/cmd/coordinator/internal/legacydash/static/status_good.gif
new file mode 100644
index 0000000..ef9c5a8
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/static/status_good.gif
Binary files differ
diff --git a/cmd/coordinator/internal/legacydash/static/style.css b/cmd/coordinator/internal/legacydash/static/style.css
new file mode 100644
index 0000000..34c6603
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/static/style.css
@@ -0,0 +1,317 @@
+* { box-sizing: border-box; }
+
+.dashboards {
+ padding: 0.5em;
+}
+.dashboards > a {
+ padding: 0.5em;
+ background: #eee;
+ color: blue;
+}
+
+body {
+ margin: 0;
+ font-family: sans-serif;
+ padding: 0; margin: 0;
+ color: #222;
+ display: inline-block;
+ min-width: 100%;
+}
+
+.container {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+p, pre, ul, ol { margin: 20px; }
+
+h1, h2, h3, h4 {
+ margin: 20px 0;
+ padding: 0;
+ color: #375EAB;
+ font-weight: bold;
+}
+
+h1 { font-size: 24px; }
+h2 { font-size: 20px; }
+h3 { font-size: 20px; }
+h4 { font-size: 16px; }
+
+h2 { background: #E0EBF5; padding: 2px 5px; }
+h3, h4 { margin: 20px 5px; }
+
+dl, dd { font-size: 14px; }
+dl { margin: 20px; }
+dd { margin: 2px 20px; }
+
+.clear {
+ clear: both;
+}
+
+.button {
+ padding: 10px;
+
+ color: #222;
+ border: 1px solid #375EAB;
+ background: #E0EBF5;
+
+ border-radius: 5px;
+
+ cursor: pointer;
+
+ margin-left: 60px;
+}
+
+/* navigation bar */
+
+#topbar {
+ padding: 10px 10px;
+ background: #E0EBF5;
+}
+
+#topbar a {
+ color: #222;
+}
+#topbar h1 {
+ float: left;
+ margin: 0;
+ padding-top: 5px;
+}
+
+#topbar nav {
+ float: left;
+ margin-left: 20px;
+}
+#topbar nav a {
+ display: inline-block;
+ padding: 10px;
+
+ margin: 0;
+ margin-right: 5px;
+
+ color: white;
+ background: #375EAB;
+
+ text-decoration: none;
+ font-size: 16px;
+
+ border: 1px solid #375EAB;
+ -webkit-border-radius: 5px;
+ -moz-border-radius: 5px;
+ border-radius: 5px;
+}
+
+.page {
+ margin-top: 20px;
+}
+
+/* settings panels */
+aside {
+ margin-top: 5px;
+}
+
+.panel {
+ border: 1px solid #aaa;
+ border-radius: 5px;
+ margin-bottom: 5px;
+}
+
+.panel h1 {
+ font-size: 16px;
+ margin: 0;
+ padding: 2px 8px;
+}
+
+.panel select {
+ padding: 5px;
+ border: 0;
+ width: 100%;
+}
+
+/* results table */
+
+table {
+ margin: 5px;
+ border-collapse: collapse;
+ font-size: 11px;
+}
+
+table td, table th, table td, table th {
+ vertical-align: top;
+ padding: 2px 6px;
+}
+
+table tr:nth-child(2n+1) {
+ background: #F4F4F4;
+}
+
+table tr.commit:hover {
+ background-color: #ffff99 !important;
+}
+
+table thead tr {
+ background: #fff !important;
+}
+
+/* build results */
+
+.build td, .build th, .packages td, .packages th {
+ vertical-align: top;
+ padding: 2px 4px;
+ font-size: 10pt;
+}
+
+.build .hash {
+ font-family: monospace;
+ font-size: 9pt;
+}
+
+.build .result {
+ text-align: center;
+ width: 2em;
+}
+
+.build .col-desc, .build .col-result, .build .col-metric, .build .col-numresults {
+ border-right: 1px solid #ccc;
+}
+
+.build .row-commit {
+ border-top: 2px solid #ccc;
+}
+
+.build .arch {
+ font-size: 83%;
+ font-weight: normal;
+}
+
+.build .time {
+ color: #666;
+}
+
+.build .ok {
+ font-size: 83%;
+}
+
+.build .desc, .build .time, .build .user {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.build .desc {
+ max-width: 150px;
+}
+
+.build .user {
+ max-width: 50px;
+}
+
+tr.subheading2 th {
+ max-width: 4em;
+ overflow: hidden;
+ word-wrap: none;
+}
+
+.good { text-decoration: none; color: #000000; border: 2px solid #00E700}
+.bad { text-decoration: none; text-shadow: 1px 1px 0 #000000; color: #FFFFFF; background: #E70000;}
+.noise { text-decoration: none; color: #888; }
+.fail { color: #C00; }
+
+/* pagination */
+
+.paginate nav {
+ padding: 0.5em;
+ margin: 10px 0;
+}
+
+.paginate nav a {
+ padding: 0.5em;
+ background: #E0EBF5;
+ color: blue;
+
+ -webkit-border-radius: 5px;
+ -moz-border-radius: 5px;
+ border-radius: 5px;
+}
+
+.paginate nav a.inactive {
+ color: #888;
+ cursor: default;
+ text-decoration: none;
+}
+
+/* diffs */
+
+.diff-meta {
+ font-family: monospace;
+ margin-bottom: 10px;
+}
+
+.diff-container {
+ padding: 10px;
+}
+
+.diff table .metric {
+ font-weight: bold;
+}
+
+.diff {
+ border: 1px solid #aaa;
+ border-radius: 5px;
+ margin-bottom: 5px;
+ margin-right: 10px;
+ float: left;
+}
+
+.diff h1 {
+ font-size: 16px;
+ margin: 0;
+ padding: 2px 8px;
+}
+
+/* positioning elements */
+
+.page {
+ position: relative;
+ width: 100%;
+}
+
+aside {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 200px;
+}
+
+.main-content {
+ position: absolute;
+ top: 0;
+ left: 210px;
+ right: 5px;
+ min-height: 200px;
+ overflow: hidden;
+}
+
+@media only screen and (max-width: 900px) {
+ aside {
+ position: relative;
+ display: block;
+ width: auto;
+ }
+
+ .main-content {
+ position: static;
+ padding: 0;
+ }
+
+ aside .panel {
+ float: left;
+ width: auto;
+ margin-right: 5px;
+ }
+ aside .button {
+ float: left;
+ margin: 0;
+ }
+}
diff --git a/cmd/coordinator/internal/legacydash/ui.go b/cmd/coordinator/internal/legacydash/ui.go
new file mode 100644
index 0000000..6a24a46
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/ui.go
@@ -0,0 +1,965 @@
+// Copyright 2011 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "cloud.google.com/go/datastore"
+ "golang.org/x/build/dashboard"
+ "golang.org/x/build/maintner/maintnerd/apipb"
+ "golang.org/x/build/repos"
+ "golang.org/x/build/types"
+ "golang.org/x/sync/errgroup"
+ "grpc.go4.org"
+ "grpc.go4.org/codes"
+)
+
+// uiHandler is the HTTP handler for the https://build.golang.org/.
+func uiHandler(w http.ResponseWriter, r *http.Request) {
+ view, err := viewForRequest(r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ dashReq, err := dashboardRequest(view, r)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ ctx := r.Context()
+ tb := &uiTemplateDataBuilder{
+ view: view,
+ req: dashReq,
+ }
+ var rpcs errgroup.Group
+ rpcs.Go(func() error {
+ var err error
+ tb.res, err = maintnerClient.GetDashboard(ctx, dashReq)
+ return err
+ })
+ if view.ShowsActiveBuilds() {
+ rpcs.Go(func() error {
+ tb.activeBuilds = getActiveBuilds(ctx)
+ return nil
+ })
+ }
+ if err := rpcs.Wait(); err != nil {
+ http.Error(w, "maintner.GetDashboard: "+err.Error(), httpStatusOfErr(err))
+ return
+ }
+ data, err := tb.buildTemplateData(ctx)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ view.ServeDashboard(w, r, data)
+}
+
+// dashboardView is something that can render uiTemplateData.
+// See viewForRequest.
+type dashboardView interface {
+ ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData)
+ // ShowsActiveBuilds reports whether this view uses
+ // information about the currently active builds.
+ ShowsActiveBuilds() bool
+}
+
+// viewForRequest selects the dashboardView based on the HTTP
+// request's "mode" parameter. Any error should be considered
+// an HTTP 400 Bad Request.
+func viewForRequest(r *http.Request) (dashboardView, error) {
+ if r.Method != "GET" && r.Method != "HEAD" {
+ return nil, errors.New("unsupported method")
+ }
+ switch r.FormValue("mode") {
+ case "failures":
+ return failuresView{}, nil
+ case "json":
+ return jsonView{}, nil
+ case "":
+ return htmlView{}, nil
+ }
+ return nil, errors.New("unsupported mode argument")
+}
+
+type commitInPackage struct {
+ packagePath string // "" for Go, else package import path
+ commit string // git commit hash
+}
+
+// uiTemplateDataBuilder builds the uiTemplateData used by the various
+// dashboardViews. That is, it maps the maintner protobuf response to
+// the data structure needed by the dashboardView/template.
+type uiTemplateDataBuilder struct {
+ view dashboardView
+ req *apipb.DashboardRequest
+ res *apipb.DashboardResponse
+ activeBuilds []types.ActivePostSubmitBuild // optional; for blue gopher links
+
+ // testCommitData, if non-nil, provides an alternate data
+ // source to use for testing instead of making real datastore
+ // calls. The keys are stringified datastore.Keys.
+ testCommitData map[string]*Commit
+}
+
+// getCommitsToLoad returns a set (all values are true) of which commits to load
+// from the datastore. If fakeResults is on, it returns an empty set.
+func (tb *uiTemplateDataBuilder) getCommitsToLoad() map[commitInPackage]bool {
+ if fakeResults {
+ return nil
+ }
+ m := make(map[commitInPackage]bool)
+ add := func(packagePath, commit string) {
+ m[commitInPackage{packagePath: packagePath, commit: commit}] = true
+ }
+
+ for _, dc := range tb.res.Commits {
+ add(tb.req.Repo, dc.Commit)
+ }
+ // We also want to load the Commits for the x/repo heads.
+ if tb.showXRepoSection() {
+ for _, rh := range tb.res.RepoHeads {
+ if path := repoImportPath(rh); path != "" {
+ add(path, rh.Commit.Commit)
+ }
+ }
+ }
+ return m
+}
+
+// loadDatastoreCommits loads the commits given in the keys of the
+// want map. The returned map is keyed by the git hash and may not
+// contain items that didn't exist in the datastore. (It is not an
+// error if 1 or all don't exist.)
+func (tb *uiTemplateDataBuilder) loadDatastoreCommits(ctx context.Context, want map[commitInPackage]bool) (map[string]*Commit, error) {
+ ret := map[string]*Commit{}
+
+ // Allow tests to fake what the datastore would've loaded, and
+ // thus also allow tests to be run without a real (or fake) datastore.
+ if m := tb.testCommitData; m != nil {
+ for k := range want {
+ if c, ok := m[k.commit]; ok {
+ ret[k.commit] = c
+ }
+ }
+ return ret, nil
+ }
+
+ var keys []*datastore.Key
+ for k := range want {
+ key := (&Commit{
+ PackagePath: k.packagePath,
+ Hash: k.commit,
+ }).Key()
+ keys = append(keys, key)
+ }
+ commits, err := fetchCommits(ctx, keys)
+ if err != nil {
+ return nil, fmt.Errorf("fetchCommits: %v", err)
+ }
+ for _, c := range commits {
+ ret[c.Hash] = c
+ }
+ return ret, nil
+}
+
+// formatGitAuthor formats the git author name and email (as split by
+// maintner) back into the unified string how they're stored in a git
+// commit, so the shortUser func (used by the HTML template) can parse
+// back out the email part's username later. Maybe we could plumb down
+// the parsed proto into the template later.
+func formatGitAuthor(name, email string) string {
+ name = strings.TrimSpace(name)
+ email = strings.TrimSpace(email)
+ if name != "" && email != "" {
+ return fmt.Sprintf("%s <%s>", name, email)
+ }
+ if name != "" {
+ return name
+ }
+ return "<" + email + ">"
+}
+
+// newCommitInfo returns a new CommitInfo populated for the template
+// data given a repo name and a dashboard commit from that repo, using
+// previously loaded datastore commit info in tb.
+func (tb *uiTemplateDataBuilder) newCommitInfo(dsCommits map[string]*Commit, repo string, dc *apipb.DashCommit) *CommitInfo {
+ branch := dc.Branch
+ if branch == "" {
+ branch = "master"
+ }
+ ci := &CommitInfo{
+ Hash: dc.Commit,
+ PackagePath: repo,
+ User: formatGitAuthor(dc.AuthorName, dc.AuthorEmail),
+ Desc: cleanTitle(dc.Title, tb.req.Branch),
+ Time: time.Unix(dc.CommitTimeSec, 0),
+ Branch: branch,
+ }
+ if dsc, ok := dsCommits[dc.Commit]; ok {
+ ci.ResultData = dsc.ResultData
+ }
+ // For non-go repos, add the rows for the Go commits that were
+ // at HEAD overlapping in time with dc.Commit.
+ if !tb.isGoRepo() {
+ if dc.GoCommitAtTime != "" {
+ ci.addEmptyResultGoHash(dc.GoCommitAtTime)
+ }
+ if dc.GoCommitLatest != "" && dc.GoCommitLatest != dc.GoCommitAtTime {
+ ci.addEmptyResultGoHash(dc.GoCommitLatest)
+ }
+ }
+ return ci
+}
+
+// showXRepoSection reports whether the dashboard should show the state of the x/foo repos at the bottom of
+// the page in the three branches (master, latest release branch, two releases ago).
+func (tb *uiTemplateDataBuilder) showXRepoSection() bool {
+ return tb.req.Page == 0 &&
+ (tb.branch() == "master" || tb.req.Branch == "mixed") &&
+ tb.isGoRepo()
+}
+
+func (tb *uiTemplateDataBuilder) isGoRepo() bool { return tb.req.Repo == "" || tb.req.Repo == "go" }
+
+// repoGerritProj returns the Gerrit project name on go.googlesource.com for
+// the repo requested, or empty if unknown.
+func (tb *uiTemplateDataBuilder) repoGerritProj() string {
+ if tb.isGoRepo() {
+ return "go"
+ }
+ if r, ok := repos.ByImportPath[tb.req.Repo]; ok {
+ return r.GoGerritProject
+ }
+ return ""
+}
+
+// branch returns the request branch, or "master" if empty.
+func (tb *uiTemplateDataBuilder) branch() string {
+ if tb.req.Branch == "" {
+ return "master"
+ }
+ return tb.req.Branch
+}
+
+// repoImportPath returns the import path for rh, unless rh is the
+// main "go" repo or is configured to be hidden from the dashboard, in
+// which case it returns the empty string.
+func repoImportPath(rh *apipb.DashRepoHead) string {
+ if rh.GerritProject == "go" {
+ return ""
+ }
+ ri, ok := repos.ByGerritProject[rh.GerritProject]
+ if !ok || !ri.ShowOnDashboard() {
+ return ""
+ }
+ return ri.ImportPath
+}
+
+func (tb *uiTemplateDataBuilder) buildTemplateData(ctx context.Context) (*uiTemplateData, error) {
+ dsCommits, err := tb.loadDatastoreCommits(ctx, tb.getCommitsToLoad())
+ if err != nil {
+ return nil, err
+ }
+
+ var commits []*CommitInfo
+ for _, dc := range tb.res.Commits {
+ ci := tb.newCommitInfo(dsCommits, tb.req.Repo, dc)
+ commits = append(commits, ci)
+ }
+
+ // x/ repo sections at bottom (each is a "TagState", for historical reasons)
+ var xRepoSections []*TagState
+ if tb.showXRepoSection() {
+ for _, gorel := range tb.res.Releases {
+ ts := &TagState{
+ Name: gorel.BranchName,
+ Tag: &CommitInfo{ // only a minimally populated version is needed by the template
+ Hash: gorel.BranchCommit,
+ },
+ }
+ for _, rh := range tb.res.RepoHeads {
+ path := repoImportPath(rh)
+ if path == "" {
+ continue
+ }
+ ts.Packages = append(ts.Packages, &PackageState{
+ Package: &Package{
+ Name: rh.GerritProject,
+ Path: path,
+ },
+ Commit: tb.newCommitInfo(dsCommits, path, rh.Commit),
+ })
+ }
+ builders := map[string]bool{}
+ for _, pkg := range ts.Packages {
+ addBuilders(builders, pkg.Package.Name, ts.Branch())
+ }
+ ts.Builders = builderKeys(builders)
+
+ sort.Slice(ts.Packages, func(i, j int) bool {
+ return ts.Packages[i].Package.Name < ts.Packages[j].Package.Name
+ })
+ xRepoSections = append(xRepoSections, ts)
+ }
+ }
+
+ // Release Branches
+ var releaseBranches []string
+ for _, gr := range tb.res.Releases {
+ if gr.BranchName != "master" {
+ releaseBranches = append(releaseBranches, gr.BranchName)
+ }
+ }
+
+ gerritProject := "go"
+ if repo := repos.ByImportPath[tb.req.Repo]; repo != nil {
+ gerritProject = repo.GoGerritProject
+ }
+
+ data := &uiTemplateData{
+ Dashboard: goDash,
+ Package: goDash.packageWithPath(tb.req.Repo),
+ Commits: commits,
+ TagState: xRepoSections,
+ Pagination: &Pagination{},
+ Branches: tb.res.Branches,
+ Branch: tb.req.Branch,
+ Repo: gerritProject,
+ }
+
+ builders := buildersOfCommits(commits)
+ if tb.branch() == "mixed" {
+ for _, gr := range tb.res.Releases {
+ addBuilders(builders, tb.repoGerritProj(), gr.BranchName)
+ }
+ } else {
+ addBuilders(builders, tb.repoGerritProj(), tb.branch())
+ }
+ data.Builders = builderKeys(builders)
+
+ if tb.res.CommitsTruncated {
+ data.Pagination.Next = int(tb.req.Page) + 1
+ }
+ if tb.req.Page > 0 {
+ data.Pagination.Prev = int(tb.req.Page) - 1
+ data.Pagination.HasPrev = true
+ }
+
+ if tb.view.ShowsActiveBuilds() {
+ // Populate building URLs for the HTML UI only.
+ data.populateBuildingURLs(ctx, tb.activeBuilds)
+ }
+
+ return data, nil
+}
+
+// htmlView renders the HTML (default) form of https://build.golang.org/ with no mode parameter.
+type htmlView struct{}
+
+func (htmlView) ShowsActiveBuilds() bool { return true }
+func (htmlView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
+ var buf bytes.Buffer
+ if err := uiTemplate.Execute(&buf, data); err != nil {
+ logErr(w, r, err)
+ return
+ }
+ buf.WriteTo(w)
+}
+
+// dashboardRequest is a pure function that maps the provided HTTP
+// request to a maintner DashboardRequest and lightly validates the
+// HTTP request for the root dashboard handler. (It does not validate
+// that, say, branches or repos are valid.)
+// Any returned error is an HTTP 400 Bad Request.
+func dashboardRequest(view dashboardView, r *http.Request) (*apipb.DashboardRequest, error) {
+ page := 0
+ if s := r.FormValue("page"); s != "" {
+ var err error
+ page, err = strconv.Atoi(r.FormValue("page"))
+ if err != nil {
+ return nil, fmt.Errorf("invalid page value %q", s)
+ }
+ if page < 0 {
+ return nil, errors.New("negative page")
+ }
+ }
+
+ repo := r.FormValue("repo") // empty for main go repo, else e.g. "golang.org/x/net"
+
+ branch := r.FormValue("branch")
+ if branch == "" {
+ branch = "master"
+ }
+ return &apipb.DashboardRequest{
+ Page: int32(page),
+ Branch: branch,
+ Repo: repo,
+ MaxCommits: commitsPerPage,
+ }, nil
+}
+
+// cleanTitle returns a cleaned version of the provided title for
+// users viewing the provided viewBranch.
+func cleanTitle(title, viewBranch string) string {
+ // Don't rewrite anything for master and mixed.
+ if viewBranch == "master" || viewBranch == "mixed" {
+ return title
+ }
+ // Strip the "[release-branch.go1.n]" prefixes from commit messages
+ // when looking at a branch.
+ if strings.HasPrefix(title, "[") {
+ if i := strings.IndexByte(title, ']'); i != -1 {
+ return strings.TrimSpace(title[i+1:])
+ }
+ }
+ return title
+}
+
+// failuresView renders https://build.golang.org/?mode=failures, where it outputs
+// one line per failure on the front page, in the form:
+// hash builder failure-url
+type failuresView struct{}
+
+func (failuresView) ShowsActiveBuilds() bool { return false }
+func (failuresView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
+ w.Header().Set("Content-Type", "text/plain")
+ for _, c := range data.Commits {
+ for _, b := range data.Builders {
+ res := c.Result(b, "")
+ if res == nil || res.OK || res.LogHash == "" {
+ continue
+ }
+ url := fmt.Sprintf("https://%v/log/%v", r.Host, res.LogHash)
+ fmt.Fprintln(w, c.Hash, b, url)
+ }
+ }
+ // TODO: this doesn't include the TagState commit. It would be
+ // needed if we want to do golang.org/issue/36131, to permit
+ // the retrybuilds command to wipe flaky non-go builds.
+}
+
+// jsonView renders https://build.golang.org/?mode=json.
+// The output is a types.BuildStatus JSON object.
+type jsonView struct{}
+
+func (jsonView) ShowsActiveBuilds() bool { return false }
+func (jsonView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
+ res := toBuildStatus(r.Host, data)
+ v, _ := json.MarshalIndent(res, "", "\t")
+ w.Header().Set("Content-Type", "text/json; charset=utf-8")
+ w.Write(v)
+}
+
+func toBuildStatus(host string, data *uiTemplateData) types.BuildStatus {
+ // cell returns one of "" (no data), "ok", or a failure URL.
+ cell := func(res *Result) string {
+ switch {
+ case res == nil:
+ return ""
+ case res.OK:
+ return "ok"
+ }
+ return fmt.Sprintf("https://%v/log/%v", host, res.LogHash)
+ }
+
+ builders := data.allBuilders()
+
+ var res types.BuildStatus
+ res.Builders = builders
+
+ // First the commits from the main section (the requested repo)
+ for _, c := range data.Commits {
+ // The logic below works for both the go repo and other subrepos: if c is
+ // in the main go repo, ResultGoHashes returns a slice of length 1
+ // containing the empty string.
+ for _, h := range c.ResultGoHashes() {
+ rev := types.BuildRevision{
+ Repo: data.Repo,
+ Results: make([]string, len(res.Builders)),
+ GoRevision: h,
+ }
+ commitToBuildRevision(c, &rev)
+ for i, b := range res.Builders {
+ rev.Results[i] = cell(c.Result(b, h))
+ }
+ res.Revisions = append(res.Revisions, rev)
+ }
+ }
+
+ // Then the one commit each for the subrepos for each of the tracked tags.
+ // (tip, Go 1.4, etc)
+ for _, ts := range data.TagState {
+ for _, pkgState := range ts.Packages {
+ goRev := ts.Tag.Hash
+ goBranch := ts.Name
+ if goBranch == "tip" {
+ // Normalize old hg terminology into
+ // our git branch name.
+ goBranch = "master"
+ }
+ rev := types.BuildRevision{
+ Repo: pkgState.Package.Name,
+ GoRevision: goRev,
+ Results: make([]string, len(res.Builders)),
+ GoBranch: goBranch,
+ }
+ commitToBuildRevision(pkgState.Commit, &rev)
+ for i, b := range res.Builders {
+ rev.Results[i] = cell(pkgState.Commit.Result(b, goRev))
+ }
+ res.Revisions = append(res.Revisions, rev)
+ }
+ }
+ return res
+}
+
+// commitToBuildRevision fills in the fields of BuildRevision rev that
+// are derived from Commit c.
+func commitToBuildRevision(c *CommitInfo, rev *types.BuildRevision) {
+ rev.Revision = c.Hash
+ rev.Date = c.Time.Format(time.RFC3339)
+ rev.Author = c.User
+ rev.Desc = c.Desc
+ rev.Branch = c.Branch
+}
+
+type Pagination struct {
+ Next, Prev int
+ HasPrev bool
+}
+
+// fetchCommits loads any commits that exist given by keys.
+// It is not an error if a commit doesn't exist.
+// Only commits that were found in datastore are returned,
+// in an unspecified order.
+func fetchCommits(ctx context.Context, keys []*datastore.Key) ([]*Commit, error) {
+ if len(keys) == 0 {
+ return nil, nil
+ }
+ out := make([]*Commit, len(keys))
+ for i := range keys {
+ out[i] = new(Commit)
+ }
+
+ err := datastoreClient.GetMulti(ctx, keys, out)
+ err = filterDatastoreError(err)
+ err = filterNoSuchEntity(err)
+ if err != nil {
+ return nil, err
+ }
+ filtered := out[:0]
+ for _, c := range out {
+ if c.Valid() { // that is, successfully loaded
+ filtered = append(filtered, c)
+ }
+ }
+ return filtered, nil
+}
+
+// buildersOfCommits returns the set of builders that provided
+// Results for the provided commits.
+func buildersOfCommits(commits []*CommitInfo) map[string]bool {
+ m := make(map[string]bool)
+ for _, commit := range commits {
+ for _, r := range commit.Results() {
+ if r.Builder != "" {
+ m[r.Builder] = true
+ }
+ }
+ }
+ return m
+}
+
+// addBuilders adds builders to the provide map that should be active for
+// the named Gerrit project & branch. (Issue 19930)
+func addBuilders(builders map[string]bool, gerritProj, branch string) {
+ for name, bc := range dashboard.Builders {
+ if bc.BuildsRepoPostSubmit(gerritProj, branch, branch) {
+ builders[name] = true
+ }
+ }
+}
+
+func builderKeys(m map[string]bool) (s []string) {
+ s = make([]string, 0, len(m))
+ for k := range m {
+ s = append(s, k)
+ }
+ sort.Sort(builderOrder(s))
+ return
+}
+
+// builderOrder implements sort.Interface, sorting builder names
+// ("darwin-amd64", etc) first by builderPriority and then alphabetically.
+type builderOrder []string
+
+func (s builderOrder) Len() int { return len(s) }
+func (s builderOrder) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
+func (s builderOrder) Less(i, j int) bool {
+ pi, pj := builderPriority(s[i]), builderPriority(s[j])
+ if pi == pj {
+ return s[i] < s[j]
+ }
+ return pi < pj
+}
+
+func builderPriority(builder string) (p int) {
+ // Group race builders together.
+ if isRace(builder) {
+ return 2
+ }
+ // If the OS has a specified priority, use it.
+ if p, ok := osPriority[builderOS(builder)]; ok {
+ return p
+ }
+ // The rest.
+ return 10
+}
+
+func isRace(s string) bool {
+ return strings.Contains(s, "-race-") || strings.HasSuffix(s, "-race")
+}
+
+func unsupported(builder string) bool {
+ return unsupportedOS(builderOS(builder))
+}
+
+func unsupportedOS(os string) bool {
+ if os == "race" || os == "android" || os == "all" {
+ return false
+ }
+ p, ok := osPriority[os]
+ return !ok || p > 1
+}
+
+// Priorities for specific operating systems.
+var osPriority = map[string]int{
+ "all": 0,
+ "darwin": 1,
+ "freebsd": 1,
+ "linux": 1,
+ "windows": 1,
+ // race == 2
+ "android": 3,
+ "openbsd": 4,
+ "netbsd": 5,
+ "dragonfly": 6,
+}
+
+// TagState represents the state of all Packages at a branch.
+type TagState struct {
+ Name string // Go branch name: "master", "release-branch.go1.4", etc
+ Tag *CommitInfo // current Go commit on the Name branch
+ Packages []*PackageState
+ Builders []string
+}
+
+// Branch returns the git branch name, converting from the old
+// terminology we used from Go's hg days into git terminology.
+func (ts *TagState) Branch() string {
+ if ts.Name == "tip" {
+ return "master"
+ }
+ return ts.Name
+}
+
+// PackageState represents the state of a Package (x/foo repo) for given Go branch.
+type PackageState struct {
+ Package *Package
+ Commit *CommitInfo
+}
+
+// A CommitInfo is a struct for use by html/template package.
+// It is not stored in the datastore.
+type CommitInfo struct {
+ Hash string
+
+ // ResultData is a copy of the Commit.ResultData field from datastore.
+ ResultData []string
+
+ // BuildingURLs contains the status URL values for builds that
+ // are currently in progress for this commit.
+ BuildingURLs map[builderAndGoHash]string
+
+ PackagePath string // (empty for main repo commits)
+ User string // "Foo Bar <foo@bar.com>"
+ Desc string // git commit title
+ Time time.Time // commit time
+ Branch string // "master", "release-branch.go1.14"
+}
+
+// addEmptyResultGoHash adds an empty result containing goHash to
+// ci.ResultData, unless ci already contains a result for that hash.
+// This is used for non-go repos to show the go commits (both earliest
+// and latest) that correspond to this repo's commit time. We add an
+// empty result so it shows up on the dashboard (both for humans, and
+// in JSON form for the coordinator to pick up as work). Once the
+// coordinator does that work and posts its result, then ResultData
+// will be populate and this turns into a no-op.
+func (ci *CommitInfo) addEmptyResultGoHash(goHash string) {
+ for _, exist := range ci.ResultData {
+ if strings.Contains(exist, goHash) {
+ return
+ }
+ }
+ ci.ResultData = append(ci.ResultData, (&Result{GoHash: goHash}).Data())
+}
+
+type uiTemplateData struct {
+ Dashboard *Dashboard
+ Package *Package
+ Commits []*CommitInfo
+ Builders []string // builders for just the main section; not the "TagState" sections
+ TagState []*TagState // x/foo repo overviews at master + last two releases
+ Pagination *Pagination
+ Branches []string
+ Branch string
+ Repo string // the repo gerrit project name. "go" if unspecified in the request.
+}
+
+// getActiveBuilds returns the builds that coordinator is currently doing.
+// This isn't critical functionality so errors are logged but otherwise ignored for now.
+// Once this is merged into the coordinator we won't need to make an RPC to get
+// this info. See https://github.com/golang/go/issues/34744#issuecomment-563398753.
+func getActiveBuilds(ctx context.Context) (builds []types.ActivePostSubmitBuild) {
+ ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ req, _ := http.NewRequest("GET", "https://farmer.golang.org/status/post-submit-active.json", nil)
+ req = req.WithContext(ctx)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ log.Printf("getActiveBuilds: Do: %v", err)
+ return
+ }
+ defer res.Body.Close()
+ if res.StatusCode != 200 {
+ log.Printf("getActiveBuilds: %v", res.Status)
+ return
+ }
+ if err := json.NewDecoder(res.Body).Decode(&builds); err != nil {
+ log.Printf("getActiveBuilds: JSON decode: %v", err)
+ }
+ return builds
+}
+
+// populateBuildingURLs populates each commit in Commits' buildingURLs map with the
+// URLs of builds which are currently in progress.
+func (td *uiTemplateData) populateBuildingURLs(ctx context.Context, activeBuilds []types.ActivePostSubmitBuild) {
+ // active maps from a build record with its status URL zeroed
+ // out to to the actual value of that status URL.
+ active := map[types.ActivePostSubmitBuild]string{}
+ for _, rec := range activeBuilds {
+ statusURL := rec.StatusURL
+ rec.StatusURL = ""
+ active[rec] = statusURL
+ }
+
+ condAdd := func(c *CommitInfo, rec types.ActivePostSubmitBuild) {
+ su, ok := active[rec]
+ if !ok {
+ return
+ }
+ if c.BuildingURLs == nil {
+ c.BuildingURLs = make(map[builderAndGoHash]string)
+ }
+ c.BuildingURLs[builderAndGoHash{rec.Builder, rec.GoCommit}] = su
+ }
+
+ for _, b := range td.Builders {
+ for _, c := range td.Commits {
+ condAdd(c, types.ActivePostSubmitBuild{Builder: b, Commit: c.Hash})
+ }
+ }
+
+ // Gather pending commits for sub-repos.
+ for _, ts := range td.TagState {
+ goHash := ts.Tag.Hash
+ for _, b := range td.Builders {
+ for _, pkg := range ts.Packages {
+ c := pkg.Commit
+ condAdd(c, types.ActivePostSubmitBuild{
+ Builder: b,
+ Commit: c.Hash,
+ GoCommit: goHash,
+ })
+ }
+ }
+ }
+}
+
+// allBuilders returns the list of builders, unified over the main
+// section and any x/foo branch overview (TagState) sections.
+func (td *uiTemplateData) allBuilders() []string {
+ m := map[string]bool{}
+ for _, b := range td.Builders {
+ m[b] = true
+ }
+ for _, ts := range td.TagState {
+ for _, b := range ts.Builders {
+ m[b] = true
+ }
+ }
+ return builderKeys(m)
+}
+
+var uiTemplate = template.Must(
+ template.New("ui.html").Funcs(tmplFuncs).Parse(uiHTML),
+)
+
+//go:embed ui.html
+var uiHTML string
+
+var tmplFuncs = template.FuncMap{
+ "builderSpans": builderSpans,
+ "builderSubheading": builderSubheading,
+ "builderSubheading2": builderSubheading2,
+ "shortDesc": shortDesc,
+ "shortHash": shortHash,
+ "shortUser": shortUser,
+ "unsupported": unsupported,
+ "isUntested": isUntested,
+ "knownIssue": knownIssue,
+ "formatTime": formatTime,
+}
+
+func formatTime(t time.Time) string {
+ if t.Year() != time.Now().Year() {
+ return t.Format("02 Jan 06")
+ }
+ return t.Format("02 Jan 15:04")
+}
+
+func splitDash(s string) (string, string) {
+ i := strings.Index(s, "-")
+ if i >= 0 {
+ return s[:i], s[i+1:]
+ }
+ return s, ""
+}
+
+// builderOS returns the os tag for a builder string
+func builderOS(s string) string {
+ os, _ := splitDash(s)
+ return os
+}
+
+// builderOSOrRace returns the builder OS or, if it is a race builder, "race".
+func builderOSOrRace(s string) string {
+ if isRace(s) {
+ return "race"
+ }
+ return builderOS(s)
+}
+
+// builderArch returns the arch tag for a builder string
+func builderArch(s string) string {
+ _, arch := splitDash(s)
+ arch, _ = splitDash(arch) // chop third part
+ return arch
+}
+
+// builderSubheading returns a short arch tag for a builder string
+// or, if it is a race builder, the builder OS.
+func builderSubheading(s string) string {
+ if isRace(s) {
+ return builderOS(s)
+ }
+ return builderArch(s)
+}
+
+// builderSubheading2 returns any third part of a hyphenated builder name.
+// For instance, for "linux-amd64-nocgo", it returns "nocgo".
+// For race builders it returns the empty string.
+func builderSubheading2(s string) string {
+ if isRace(s) {
+ return ""
+ }
+ _, secondThird := splitDash(s)
+ _, third := splitDash(secondThird)
+ return third
+}
+
+type builderSpan struct {
+ N int
+ OS string
+ Unsupported bool
+}
+
+// builderSpans creates a list of tags showing
+// the builder's operating system names, spanning
+// the appropriate number of columns.
+func builderSpans(s []string) []builderSpan {
+ var sp []builderSpan
+ for len(s) > 0 {
+ i := 1
+ os := builderOSOrRace(s[0])
+ u := unsupportedOS(os)
+ for i < len(s) && builderOSOrRace(s[i]) == os {
+ i++
+ }
+ sp = append(sp, builderSpan{i, os, u})
+ s = s[i:]
+ }
+ return sp
+}
+
+// shortDesc returns the first line of a description.
+func shortDesc(desc string) string {
+ if i := strings.Index(desc, "\n"); i != -1 {
+ desc = desc[:i]
+ }
+ return limitStringLength(desc, 100)
+}
+
+// shortHash returns a short version of a hash.
+func shortHash(hash string) string {
+ if len(hash) > 7 {
+ hash = hash[:7]
+ }
+ return hash
+}
+
+// shortUser returns a shortened version of a user string.
+func shortUser(user string) string {
+ if i, j := strings.Index(user, "<"), strings.Index(user, ">"); 0 <= i && i < j {
+ user = user[i+1 : j]
+ }
+ if i := strings.Index(user, "@"); i >= 0 {
+ return user[:i]
+ }
+ return user
+}
+
+func httpStatusOfErr(err error) int {
+ fmt.Fprintf(os.Stderr, "Got error: %#v, code %v\n", err, grpc.Code(err))
+ switch grpc.Code(err) {
+ case codes.NotFound:
+ return http.StatusNotFound
+ case codes.InvalidArgument:
+ return http.StatusBadRequest
+ default:
+ return http.StatusInternalServerError
+ }
+}
diff --git a/cmd/coordinator/internal/legacydash/ui.html b/cmd/coordinator/internal/legacydash/ui.html
new file mode 100644
index 0000000..3ff7ac8
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/ui.html
@@ -0,0 +1,250 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>{{$.Dashboard.Name}} Build Dashboard</title>
+ <link rel="stylesheet" href="/static/style.css"/>
+ <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
+ <script>
+ var showUnsupported = window.location.hash.substr(1) != "short";
+ function redraw() {
+ showUnsupported = !$("#showshort").prop('checked');
+ $('.unsupported')[showUnsupported?'show':'hide']();
+ window.location.hash = showUnsupported?'':'short';
+ }
+ $(document).ready(function() {
+ $("#showshort").attr('checked', !showUnsupported).change(redraw);
+ redraw();
+ })
+ </script>
+ </head>
+
+ <body>
+ <header id="topbar">
+ <h1>Go Dashboard</h1>
+ <div class="clear"></div>
+ </header>
+
+ <form action="." method="GET">
+ <input type="hidden" name="repo" value="{{.Package.Path}}">
+ <nav class="dashboards">
+ {{if not (eq .Branch "")}}
+ <label>
+ <select name="branch" onchange="this.form.submit()">
+ {{range $.Branches}}
+ <option value="{{.}}"{{if eq $.Branch .}} selected{{end}}>{{.}}</option>
+ {{end}}
+ </select>
+ </label>
+ {{end}}
+ <label>
+ <input type=checkbox id="showshort">
+ show only <a href="http://golang.org/wiki/PortingPolicy">first-class ports</a>
+ </label>
+ </nav>
+ </form>
+ {{with $.Package.Name}}<h2>{{.}}</h2>{{end}}
+
+ <div class="page">
+
+ {{if $.Commits}}
+
+ <table class="build">
+ <colgroup class="col-hash" {{if $.Package.Path}}span="2"{{end}}></colgroup>
+ <colgroup class="col-user"></colgroup>
+ <colgroup class="col-time"></colgroup>
+ <colgroup class="col-desc"></colgroup>
+ {{range $.Builders | builderSpans}}
+ <colgroup class="col-result{{if .Unsupported}} unsupported{{end}}" span="{{.N}}"></colgroup>
+ {{end}}
+ <tr>
+ <!-- extra row to make alternating colors use dark for first result -->
+ </tr>
+ <tr>
+ {{if $.Package.Path}}
+ <th colspan="2">revision</th>
+ {{else}}
+ <th> </th>
+ {{end}}
+ <th></th>
+ <th></th>
+ <th></th>
+ {{range $.Builders | builderSpans}}
+ <th {{if .Unsupported}}class="unsupported"{{end}} colspan="{{.N}}">{{.OS}}</th>
+ {{end}}
+ </tr>
+
+ <tr>
+ {{if $.Package.Path}}
+ <th class="result arch">repo</th>
+ <th class="result arch">{{$.Dashboard.Name}}</th>
+ {{else}}
+ <th> </th>
+ {{end}}
+ <th></th>
+ <th></th>
+ <th></th>
+ {{range $.Builders}}
+ <th class="result arch{{if (unsupported .)}} unsupported{{end}}{{if knownIssue .}} noise{{end}}" title="{{.}}">{{builderSubheading .}}</th>
+ {{end}}
+ </tr>
+
+ <tr class="subheading2">
+ <th {{if $.Package.Path}}colspan="2"{{end}}> </th>
+ <th></th>
+ <th></th>
+ <th></th>
+ {{range $.Builders}}
+ <th class="result arch{{if (unsupported .)}} unsupported{{end}}{{if knownIssue .}} noise{{end}}" title="{{.}}">{{builderSubheading2 .}}</th>
+ {{end}}
+ </tr>
+
+ {{range $c := $.Commits}}
+ {{range $i, $h := $c.ResultGoHashes}}
+ <tr class="commit">
+ {{if $i}}
+ <td> </td>
+ {{if $h}}
+ <td class="hash"><a href="https://go-review.googlesource.com/q/{{$h}}">{{shortHash $h}}</a></td>
+ {{end}}
+ <td> </td>
+ <td> </td>
+ <td> </td>
+ {{else}}
+ <td class="hash"><a href="https://go-review.googlesource.com/q/{{$c.Hash}}">{{shortHash $c.Hash}}</a></td>
+ {{if $h}}
+ <td class="hash"><a href="https://go-review.googlesource.com/q/{{$h}}">{{shortHash $h}}</a></td>
+ {{end}}
+ <td class="user" title="{{$c.User}}">{{shortUser $c.User}}</td>
+ <td class="time">{{formatTime $c.Time}}</td>
+ <td class="desc" title="{{$c.Desc}}">{{shortDesc $c.Desc}}</td>
+ {{end}}
+ {{range $builderName := $.Builders}}
+ <td class="result{{if (unsupported .)}} unsupported{{end}}">
+ {{if and (eq $.Repo "go") (isUntested $builderName "go" $.Branch "")}}•{{else}}
+ {{with $c.Result $builderName $h}}
+ {{if .BuildingURL}}
+ <a href="{{.BuildingURL}}"><img src="https://golang.org/favicon.ico" height=16 width=16 border=0></a>
+ {{else if .OK}}
+ <span class="ok{{if knownIssue $builderName}} noise{{end}}">ok</span>
+ {{else if knownIssue $builderName}}
+ <a href="/log/{{.LogHash}}" class="noise" title="Builder {{$builderName}} has a known issue. See golang.org/issue/{{knownIssue $builderName}}.">fail</a>
+ {{else}}
+ <a href="/log/{{.LogHash}}" class="fail">fail</a>
+ {{end}}
+ {{else}}
+
+ {{end}}
+ {{end}}
+ </td>
+ {{end}}
+ </tr>
+ {{end}}
+ {{end}}
+ </table>
+
+ {{with $.Pagination}}
+ <div class="paginate">
+ <nav>
+ <a {{if .HasPrev}}href="?{{with $.Package.Path}}repo={{.}}&{{end}}page={{.Prev}}{{with $.Branch}}&branch={{.}}{{end}}"{{else}}class="inactive"{{end}}>newer</a>
+ <a {{if .Next}}href="?{{with $.Package.Path}}repo={{.}}&{{end}}page={{.Next}}{{with $.Branch}}&branch={{.}}{{end}}"{{else}}class="inactive"{{end}}>older</a>
+ <a {{if .HasPrev}}href=".{{with $.Branch}}?branch={{.}}{{end}}"{{else}}class="inactive"{{end}}>latest</a>
+ </nav>
+ </div>
+ {{end}}
+
+ {{else}}
+ <p>No commits to display. Hm.</p>
+ {{end}}
+
+ {{range $.TagState}}
+ {{$goHash := .Tag.Hash}}
+ {{$goBranch := .Branch}}
+ {{$builders := .Builders}}
+ {{if .Packages}}
+ <h2>
+ golang.org/x repos at Go {{$goBranch}}
+ <small>(<a href="https://go-review.googlesource.com/q/{{.Tag.Hash}}">{{shortHash .Tag.Hash}}</a>)</small>
+ </h2>
+
+ <table class="build">
+ <colgroup class="col-package"></colgroup>
+ <colgroup class="col-hash"></colgroup>
+ <colgroup class="col-user"></colgroup>
+ <colgroup class="col-time"></colgroup>
+ <colgroup class="col-desc"></colgroup>
+ {{range $builders | builderSpans}}
+ <colgroup class="col-result{{if .Unsupported}} unsupported{{end}}" span="{{.N}}"></colgroup>
+ {{end}}
+ <tr>
+ <!-- extra row to make alternating colors use dark for first result -->
+ </tr>
+ <tr>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ {{range $builders | builderSpans}}
+ <th {{if .Unsupported}}class="unsupported"{{end}} colspan="{{.N}}">{{.OS}}</th>
+ {{end}}
+ </tr>
+ <tr>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ {{range $builders}}
+ <th class="result arch{{if (unsupported .)}} unsupported{{end}}" title="{{.}}">{{builderSubheading .}}</th>
+ {{end}}
+ </tr>
+ <tr class="subheading2">
+ <th> </th>
+ <th></th>
+ <th></th>
+ <th></th>
+ <th></th>
+ {{range $builders}}
+ <th class="result arch{{if (unsupported .)}} unsupported{{end}}" title="{{.}}">{{builderSubheading2 .}}</th>
+ {{end}}
+ </tr>
+ {{range $pkg := .Packages}}
+ <tr class="commit">
+ <td><a title="{{.Package.Path}}" href="?repo={{.Package.Path}}">{{.Package.Name}}</a></td>
+ <td class="hash">
+ {{$h := $pkg.Commit.Hash}}
+ <a href="https://go-review.googlesource.com/q/{{$h}}">{{shortHash $h}}</a>
+ </td>
+ {{with $pkg.Commit}}
+ <td class="user" title="{{.User}}">{{shortUser .User}}</td>
+ <td class="time">{{formatTime .Time}}</td>
+ <td class="desc" title="{{.Desc}}">{{shortDesc .Desc}}</td>
+ {{end}}
+ {{range $builderName := $builders}}
+ <td class="result{{if (unsupported .)}} unsupported{{end}}">
+ {{if isUntested $builderName $pkg.Package.Name "master" $goBranch}}
+ •
+ {{else}}
+ {{with $pkg.Commit.Result $builderName $goHash}}
+ {{if .BuildingURL}}
+ <a href="{{.BuildingURL}}"><img src="https://golang.org/favicon.ico" height=16 width=16 border=0></a>
+ {{else if .OK}}
+ <span class="ok">ok</span>
+ {{else}}
+ <a href="/log/{{.LogHash}}" class="fail">fail</a>
+ {{end}}
+ {{else}}
+
+ {{end}}
+ {{end}}
+ </td>
+ {{end}}
+ </tr>
+ {{end}}
+ </table>
+ {{end}}
+ {{end}}
+
+ </div>
+ </body>
+</html>
diff --git a/cmd/coordinator/internal/legacydash/ui_test.go b/cmd/coordinator/internal/legacydash/ui_test.go
new file mode 100644
index 0000000..4b76c54
--- /dev/null
+++ b/cmd/coordinator/internal/legacydash/ui_test.go
@@ -0,0 +1,544 @@
+// Copyright 2019 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.
+
+//go:build go1.16
+// +build go1.16
+
+package legacydash
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "golang.org/x/build/dashboard"
+ "golang.org/x/build/maintner/maintnerd/apipb"
+ "golang.org/x/build/types"
+)
+
+func TestUITemplateDataBuilder(t *testing.T) {
+ // Thin the list of builders to make this test's data lighter
+ // and require less maintenance keeping it in sync.
+ origBuilders := dashboard.Builders
+ defer func() { dashboard.Builders = origBuilders }()
+ dashboard.Builders = map[string]*dashboard.BuildConfig{
+ "linux-amd64": origBuilders["linux-amd64"],
+ "linux-386": origBuilders["linux-386"],
+ }
+
+ tests := []struct {
+ name string // test subname
+ view dashboardView // one of htmlView{}, jsonView{}, or failuresView{}
+ req *apipb.DashboardRequest // what we pretend we sent to maintner
+ res *apipb.DashboardResponse // what we pretend we got back from maintner
+ testCommitData map[string]*Commit // what we pretend we loaded from datastore
+ activeBuilds []types.ActivePostSubmitBuild
+ want *uiTemplateData // what we're hoping we generated for the view/template
+ }{
+ // Basic test.
+ {
+ name: "html,zero_value_req,no_commits",
+ view: htmlView{},
+ req: &apipb.DashboardRequest{},
+ res: &apipb.DashboardResponse{
+ Branches: []string{"release.foo", "release.bar", "dev.blah"},
+ },
+ want: &uiTemplateData{
+ Dashboard: goDash,
+ Repo: "go",
+ Package: &Package{Name: "Go", Path: ""},
+ Branches: []string{"release.foo", "release.bar", "dev.blah"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Pagination: &Pagination{},
+ },
+ },
+
+ // Basic test + two commits: one that's in datastore and one that's not.
+ {
+ name: "html,zero_value_req,has_commit",
+ view: htmlView{},
+ req: &apipb.DashboardRequest{},
+ // Have only one commit load from the datastore:
+ testCommitData: map[string]*Commit{
+ "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac": {
+ PackagePath: "",
+ Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ ResultData: []string{
+ "openbsd-amd64|true||", // pretend openbsd-amd64 passed (and thus exists)
+ },
+ },
+ },
+ activeBuilds: []types.ActivePostSubmitBuild{
+ {Builder: "linux-amd64", Commit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac", StatusURL: "http://fake-status"},
+ },
+ res: &apipb.DashboardResponse{
+ Branches: []string{"release.foo", "release.bar", "dev.blah"},
+ Commits: []*apipb.DashCommit{
+ // This is the maintner commit response that is in the datastore:
+ {
+ Commit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ AuthorName: "Foo Bar",
+ AuthorEmail: "foo@example.com",
+ CommitTimeSec: 1257894001,
+ Title: "runtime: fix all the bugs",
+ Branch: "master",
+ },
+ // And another commit that's not in the datastore:
+ {
+ Commit: "ffffffffffffffffffffffffffffffffffffffff",
+ AuthorName: "Fancy Fred",
+ AuthorEmail: "f@eff.tld",
+ CommitTimeSec: 1257894000,
+ Title: "all: add effs",
+ Branch: "master",
+ },
+ },
+ CommitsTruncated: true, // pretend there's a page 2
+ },
+ want: &uiTemplateData{
+ Dashboard: goDash,
+ Repo: "go",
+ Package: &Package{Name: "Go", Path: ""},
+ Branches: []string{"release.foo", "release.bar", "dev.blah"},
+ Builders: []string{"linux-386", "linux-amd64", "openbsd-amd64"},
+ Pagination: &Pagination{
+ Next: 1,
+ },
+ Commits: []*CommitInfo{
+ {
+ Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ User: "Foo Bar <foo@example.com>",
+ Desc: "runtime: fix all the bugs",
+ Time: time.Unix(1257894001, 0),
+ ResultData: []string{
+ "openbsd-amd64|true||",
+ },
+ Branch: "master",
+ BuildingURLs: map[builderAndGoHash]string{{builder: "linux-amd64"}: "http://fake-status"},
+ },
+ {
+ Hash: "ffffffffffffffffffffffffffffffffffffffff",
+ User: "Fancy Fred <f@eff.tld>",
+ Desc: "all: add effs",
+ Time: time.Unix(1257894000, 0),
+ Branch: "master",
+ },
+ },
+ },
+ },
+
+ // Test that we generate the TagState (sections at
+ // bottom with the x/foo repo state).
+ {
+ name: "html,zero_value_req,has_xrepos",
+ view: htmlView{},
+ req: &apipb.DashboardRequest{},
+ testCommitData: map[string]*Commit{},
+ res: &apipb.DashboardResponse{
+ Branches: []string{"release.foo", "release.bar", "dev.blah"},
+ Commits: []*apipb.DashCommit{
+ {
+ Commit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ AuthorName: "Foo Bar",
+ AuthorEmail: "foo@example.com",
+ CommitTimeSec: 1257894001,
+ Title: "runtime: fix all the bugs",
+ Branch: "master",
+ },
+ },
+ Releases: []*apipb.GoRelease{
+ {
+ BranchName: "master",
+ BranchCommit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ },
+ {
+ BranchName: "release-branch.go1.99",
+ BranchCommit: "ffffffffffffffffffffffffffffffffffffffff",
+ },
+ },
+ RepoHeads: []*apipb.DashRepoHead{
+ {
+ GerritProject: "go",
+ Commit: &apipb.DashCommit{
+ Commit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ AuthorName: "Foo Bar",
+ AuthorEmail: "foo@example.com",
+ CommitTimeSec: 1257894001,
+ Title: "runtime: fix all the bugs",
+ Branch: "master",
+ },
+ },
+ {
+ GerritProject: "net",
+ Commit: &apipb.DashCommit{
+ Commit: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ AuthorName: "Ee Yore",
+ AuthorEmail: "e@e.net",
+ CommitTimeSec: 1257894001,
+ Title: "all: fix networking",
+ Branch: "master",
+ },
+ },
+ {
+ GerritProject: "sys",
+ Commit: &apipb.DashCommit{
+ Commit: "dddddddddddddddddddddddddddddddddddddddd",
+ AuthorName: "Sys Tem",
+ AuthorEmail: "sys@s.net",
+ CommitTimeSec: 1257894001,
+ Title: "sys: support more systems",
+ Branch: "master",
+ },
+ },
+ },
+ },
+ want: &uiTemplateData{
+ Dashboard: goDash,
+ Repo: "go",
+ Package: &Package{Name: "Go", Path: ""},
+ Branches: []string{"release.foo", "release.bar", "dev.blah"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Pagination: &Pagination{},
+ Commits: []*CommitInfo{
+ {
+ Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ User: "Foo Bar <foo@example.com>",
+ Desc: "runtime: fix all the bugs",
+ Time: time.Unix(1257894001, 0),
+ Branch: "master",
+ },
+ },
+ TagState: []*TagState{
+ {
+ Name: "master",
+ Tag: &CommitInfo{Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Packages: []*PackageState{
+ {
+ Package: &Package{Name: "net", Path: "golang.org/x/net"},
+ Commit: &CommitInfo{
+ Hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ PackagePath: "golang.org/x/net",
+ User: "Ee Yore <e@e.net>",
+ Desc: "all: fix networking",
+ Time: time.Unix(1257894001, 0),
+ Branch: "master",
+ },
+ },
+ {
+ Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+ Commit: &CommitInfo{
+ Hash: "dddddddddddddddddddddddddddddddddddddddd",
+ PackagePath: "golang.org/x/sys",
+ User: "Sys Tem <sys@s.net>",
+ Desc: "sys: support more systems",
+ Time: time.Unix(1257894001, 0),
+ Branch: "master",
+ },
+ },
+ },
+ },
+ {
+ Name: "release-branch.go1.99",
+ Tag: &CommitInfo{Hash: "ffffffffffffffffffffffffffffffffffffffff"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Packages: []*PackageState{
+ {
+ Package: &Package{Name: "net", Path: "golang.org/x/net"},
+ Commit: &CommitInfo{
+ Hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ PackagePath: "golang.org/x/net",
+ User: "Ee Yore <e@e.net>",
+ Desc: "all: fix networking",
+ Time: time.Unix(1257894001, 0),
+ Branch: "master",
+ },
+ },
+ {
+ Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+ Commit: &CommitInfo{
+ Hash: "dddddddddddddddddddddddddddddddddddddddd",
+ PackagePath: "golang.org/x/sys",
+ User: "Sys Tem <sys@s.net>",
+ Desc: "sys: support more systems",
+ Time: time.Unix(1257894001, 0),
+ Branch: "master",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+
+ // Test viewing a non-go repo.
+ {
+ name: "html,other_repo",
+ view: htmlView{},
+ req: &apipb.DashboardRequest{Repo: "golang.org/x/net"},
+ testCommitData: map[string]*Commit{},
+ res: &apipb.DashboardResponse{
+ Branches: []string{"master", "dev.blah"},
+ Commits: []*apipb.DashCommit{
+ {
+ Commit: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ AuthorName: "Foo Bar",
+ AuthorEmail: "foo@example.com",
+ CommitTimeSec: 1257894001,
+ Title: "net: fix all the bugs",
+ Branch: "master",
+ GoCommitAtTime: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ GoCommitLatest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ },
+ },
+ },
+ want: &uiTemplateData{
+ Dashboard: goDash,
+ Repo: "net",
+ Package: &Package{Name: "net", Path: "golang.org/x/net"},
+ Branches: []string{"master", "dev.blah"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Pagination: &Pagination{},
+ Commits: []*CommitInfo{
+ {
+ PackagePath: "golang.org/x/net",
+ Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ User: "Foo Bar <foo@example.com>",
+ Desc: "net: fix all the bugs",
+ Time: time.Unix(1257894001, 0),
+ Branch: "master",
+ ResultData: []string{
+ "|false||aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "|false||bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ },
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tb := &uiTemplateDataBuilder{
+ view: tt.view,
+ req: tt.req,
+ res: tt.res,
+ activeBuilds: tt.activeBuilds,
+ testCommitData: tt.testCommitData,
+ }
+ data, err := tb.buildTemplateData(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ diff := cmp.Diff(tt.want, data, cmpopts.IgnoreUnexported(CommitInfo{}))
+ if diff != "" {
+ t.Errorf("mismatch want->got:\n%s", diff)
+ }
+ })
+ }
+}
+
+func TestToBuildStatus(t *testing.T) {
+ tests := []struct {
+ name string
+ data *uiTemplateData
+ want types.BuildStatus
+ }{
+ {
+ name: "go repo",
+ data: &uiTemplateData{
+ Dashboard: goDash,
+ Repo: "go",
+ Package: &Package{Name: "Go", Path: ""},
+ Branches: []string{"release.foo", "release.bar", "dev.blah"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Pagination: &Pagination{},
+ Commits: []*CommitInfo{
+ {
+ Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ User: "Foo Bar <foo@example.com>",
+ Desc: "runtime: fix all the bugs",
+ Time: time.Unix(1257894001, 0).UTC(),
+ Branch: "master",
+ },
+ },
+ TagState: []*TagState{
+ {
+ Name: "master",
+ Tag: &CommitInfo{Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Packages: []*PackageState{
+ {
+ Package: &Package{Name: "net", Path: "golang.org/x/net"},
+ Commit: &CommitInfo{
+ Hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ PackagePath: "golang.org/x/net",
+ User: "Ee Yore <e@e.net>",
+ Desc: "all: fix networking",
+ Time: time.Unix(1257894001, 0).UTC(),
+ Branch: "master",
+ },
+ },
+ {
+ Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+ Commit: &CommitInfo{
+ Hash: "dddddddddddddddddddddddddddddddddddddddd",
+ PackagePath: "golang.org/x/sys",
+ User: "Sys Tem <sys@s.net>",
+ Desc: "sys: support more systems",
+ Time: time.Unix(1257894001, 0).UTC(),
+ Branch: "master",
+ },
+ },
+ },
+ },
+ {
+ Name: "release-branch.go1.99",
+ Tag: &CommitInfo{Hash: "ffffffffffffffffffffffffffffffffffffffff"},
+ Builders: []string{"linux-386", "linux-amd64"},
+ Packages: []*PackageState{
+ {
+ Package: &Package{Name: "net", Path: "golang.org/x/net"},
+ Commit: &CommitInfo{
+ Hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ PackagePath: "golang.org/x/net",
+ User: "Ee Yore <e@e.net>",
+ Desc: "all: fix networking",
+ Time: time.Unix(1257894001, 0).UTC(),
+ Branch: "master",
+ },
+ },
+ {
+ Package: &Package{Name: "sys", Path: "golang.org/x/sys"},
+ Commit: &CommitInfo{
+ Hash: "dddddddddddddddddddddddddddddddddddddddd",
+ PackagePath: "golang.org/x/sys",
+ User: "Sys Tem <sys@s.net>",
+ Desc: "sys: support more systems",
+ Time: time.Unix(1257894001, 0).UTC(),
+ Branch: "master",
+ },
+ },
+ },
+ },
+ },
+ },
+ want: types.BuildStatus{
+ Builders: []string{"linux-386", "linux-amd64"},
+ Revisions: []types.BuildRevision{
+ {
+ Repo: "go",
+ Revision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ Date: "2009-11-10T23:00:01Z",
+ Branch: "master",
+ Author: "Foo Bar <foo@example.com>",
+ Desc: "runtime: fix all the bugs",
+ Results: []string{"", ""},
+ },
+ {
+ Repo: "net",
+ Revision: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ GoRevision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ Date: "2009-11-10T23:00:01Z",
+ Branch: "master",
+ GoBranch: "master",
+ Author: "Ee Yore <e@e.net>",
+ Desc: "all: fix networking",
+ Results: []string{"", ""},
+ },
+ {
+ Repo: "sys",
+ Revision: "dddddddddddddddddddddddddddddddddddddddd",
+ GoRevision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ Date: "2009-11-10T23:00:01Z",
+ Branch: "master",
+ GoBranch: "master",
+ Author: "Sys Tem <sys@s.net>",
+ Desc: "sys: support more systems",
+ Results: []string{"", ""},
+ },
+ {
+ Repo: "net",
+ Revision: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ GoRevision: "ffffffffffffffffffffffffffffffffffffffff",
+ Date: "2009-11-10T23:00:01Z",
+ Branch: "master",
+ GoBranch: "release-branch.go1.99",
+ Author: "Ee Yore <e@e.net>",
+ Desc: "all: fix networking",
+ Results: []string{"", ""},
+ },
+ {
+ Repo: "sys",
+ Revision: "dddddddddddddddddddddddddddddddddddddddd",
+ GoRevision: "ffffffffffffffffffffffffffffffffffffffff",
+ Date: "2009-11-10T23:00:01Z",
+ Branch: "master",
+ GoBranch: "release-branch.go1.99",
+ Author: "Sys Tem <sys@s.net>",
+ Desc: "sys: support more systems",
+ Results: []string{"", ""},
+ },
+ },
+ },
+ },
+ {
+ name: "other repo",
+ data: &uiTemplateData{
+ Dashboard: goDash,
+ Repo: "tools",
+ Builders: []string{"linux", "windows"},
+ Commits: []*CommitInfo{
+ {
+ PackagePath: "golang.org/x/tools",
+ Hash: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ User: "Foo Bar <foo@example.com>",
+ Desc: "tools: fix all the bugs",
+ Time: time.Unix(1257894001, 0).UTC(),
+ Branch: "master",
+ ResultData: []string{
+ "linux|false|123|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "windows|false|456|bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ },
+ },
+ },
+ },
+ want: types.BuildStatus{
+ Builders: []string{"linux", "windows"},
+ Revisions: []types.BuildRevision{
+ {
+ Repo: "tools",
+ Revision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ GoRevision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ Date: "2009-11-10T23:00:01Z",
+ Branch: "master",
+ Author: "Foo Bar <foo@example.com>",
+ Desc: "tools: fix all the bugs",
+ Results: []string{"", "https://build.golang.org/log/456"},
+ },
+ {
+ Repo: "tools",
+ Revision: "26957168c4c0cdcc7ca4f0b19d0eb19474d224ac",
+ GoRevision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ Date: "2009-11-10T23:00:01Z",
+ Branch: "master",
+ Author: "Foo Bar <foo@example.com>",
+ Desc: "tools: fix all the bugs",
+ Results: []string{"https://build.golang.org/log/123", ""},
+ },
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := toBuildStatus("build.golang.org", tt.data)
+ if diff := cmp.Diff(tt.want, got); diff != "" {
+ t.Errorf("buildStatus(...) mismatch (-want +got):\n%s", diff)
+ }
+ })
+ }
+}