golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/coordinator/internal/legacydash/ui.go (about)

     1  // Copyright 2011 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  //go:build linux || darwin
     6  
     7  package legacydash
     8  
     9  import (
    10  	"bytes"
    11  	"context"
    12  	_ "embed"
    13  	"encoding/json"
    14  	"errors"
    15  	"fmt"
    16  	"html/template"
    17  	"log"
    18  	"net/http"
    19  	"os"
    20  	"sort"
    21  	"strconv"
    22  	"strings"
    23  	"time"
    24  
    25  	"cloud.google.com/go/datastore"
    26  	bbpb "go.chromium.org/luci/buildbucket/proto"
    27  	"golang.org/x/build/cmd/coordinator/internal/lucipoll"
    28  	"golang.org/x/build/dashboard"
    29  	"golang.org/x/build/internal/releasetargets"
    30  	"golang.org/x/build/maintner/maintnerd/apipb"
    31  	"golang.org/x/build/repos"
    32  	"golang.org/x/build/types"
    33  	"golang.org/x/sync/errgroup"
    34  	"google.golang.org/grpc"
    35  	"google.golang.org/grpc/codes"
    36  )
    37  
    38  // uiHandler is the HTTP handler for the https://build.golang.org/.
    39  func (h handler) uiHandler(w http.ResponseWriter, r *http.Request) {
    40  	view, err := viewForRequest(r)
    41  	if err != nil {
    42  		http.Error(w, err.Error(), http.StatusBadRequest)
    43  		return
    44  	}
    45  
    46  	dashReq, err := dashboardRequest(view, r)
    47  	if err != nil {
    48  		http.Error(w, err.Error(), http.StatusBadRequest)
    49  		return
    50  	}
    51  
    52  	ctx := r.Context()
    53  	tb := &uiTemplateDataBuilder{
    54  		view: view,
    55  		req:  dashReq,
    56  	}
    57  	var rpcs errgroup.Group
    58  	rpcs.Go(func() error {
    59  		var err error
    60  		tb.res, err = h.maintnerCl.GetDashboard(ctx, dashReq)
    61  		return err
    62  	})
    63  	if view.ShowsActiveBuilds() {
    64  		rpcs.Go(func() error {
    65  			tb.activeBuilds = getActiveBuilds(ctx)
    66  			return nil
    67  		})
    68  	}
    69  	if err := rpcs.Wait(); err != nil {
    70  		http.Error(w, "maintner.GetDashboard: "+err.Error(), httpStatusOfErr(err))
    71  		return
    72  	}
    73  	var luci lucipoll.Snapshot
    74  	if h.LUCI != nil && tb.showLUCI() {
    75  		luci = h.LUCI.PostSubmitSnapshot()
    76  	}
    77  	data, err := tb.buildTemplateData(ctx, h.datastoreCl, luci)
    78  	if err != nil {
    79  		http.Error(w, err.Error(), http.StatusInternalServerError)
    80  		return
    81  	}
    82  	view.ServeDashboard(w, r, data)
    83  }
    84  
    85  // dashboardView is something that can render uiTemplateData.
    86  // See viewForRequest.
    87  type dashboardView interface {
    88  	ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData)
    89  	// ShowsActiveBuilds reports whether this view uses
    90  	// information about the currently active builds.
    91  	ShowsActiveBuilds() bool
    92  }
    93  
    94  // viewForRequest selects the dashboardView based on the HTTP
    95  // request's "mode" parameter. Any error should be considered
    96  // an HTTP 400 Bad Request.
    97  func viewForRequest(r *http.Request) (dashboardView, error) {
    98  	if r.Method != "GET" && r.Method != "HEAD" {
    99  		return nil, errors.New("unsupported method")
   100  	}
   101  	switch r.FormValue("mode") {
   102  	case "failures":
   103  		return failuresView{}, nil
   104  	case "json":
   105  		return jsonView{}, nil
   106  	case "":
   107  		var showLUCI = true
   108  		if legacyOnly, _ := strconv.ParseBool(r.URL.Query().Get("legacyonly")); legacyOnly {
   109  			showLUCI = false
   110  		}
   111  		return htmlView{ShowLUCI: showLUCI}, nil
   112  	}
   113  	return nil, errors.New("unsupported mode argument")
   114  }
   115  
   116  type commitInPackage struct {
   117  	packagePath string // "" for Go, else package import path
   118  	commit      string // git commit hash
   119  }
   120  
   121  // uiTemplateDataBuilder builds the uiTemplateData used by the various
   122  // dashboardViews. That is, it maps the maintner protobuf response to
   123  // the data structure needed by the dashboardView/template.
   124  type uiTemplateDataBuilder struct {
   125  	view         dashboardView
   126  	req          *apipb.DashboardRequest
   127  	res          *apipb.DashboardResponse
   128  	activeBuilds []types.ActivePostSubmitBuild // optional; for blue gopher links
   129  
   130  	// testCommitData, if non-nil, provides an alternate data
   131  	// source to use for testing instead of making real datastore
   132  	// calls. The keys are stringified datastore.Keys.
   133  	testCommitData map[string]*Commit
   134  }
   135  
   136  // getCommitsToLoad returns a set (all values are true) of which commits to load
   137  // from the datastore. If fakeResults is on, it returns an empty set.
   138  func (tb *uiTemplateDataBuilder) getCommitsToLoad() map[commitInPackage]bool {
   139  	if fakeResults {
   140  		return nil
   141  	}
   142  	m := make(map[commitInPackage]bool)
   143  	add := func(packagePath, commit string) {
   144  		m[commitInPackage{packagePath: packagePath, commit: commit}] = true
   145  	}
   146  
   147  	for _, dc := range tb.res.Commits {
   148  		add(tb.req.Repo, dc.Commit)
   149  	}
   150  	// We also want to load the Commits for the x/repo heads.
   151  	if tb.showXRepoSection() {
   152  		for _, rh := range tb.res.RepoHeads {
   153  			if path := repoImportPath(rh); path != "" {
   154  				add(path, rh.Commit.Commit)
   155  			}
   156  		}
   157  	}
   158  	return m
   159  }
   160  
   161  // loadDatastoreCommits loads the commits given in the keys of the
   162  // want map. The returned map is keyed by the git hash and may not
   163  // contain items that didn't exist in the datastore. (It is not an
   164  // error if 1 or all don't exist.)
   165  func (tb *uiTemplateDataBuilder) loadDatastoreCommits(ctx context.Context, cl *datastore.Client, want map[commitInPackage]bool) (map[string]*Commit, error) {
   166  	var keys []*datastore.Key
   167  	for k := range want {
   168  		key := (&Commit{
   169  			PackagePath: k.packagePath,
   170  			Hash:        k.commit,
   171  		}).Key()
   172  		keys = append(keys, key)
   173  	}
   174  	commits, err := fetchCommits(ctx, cl, keys)
   175  	if err != nil {
   176  		return nil, fmt.Errorf("fetchCommits: %v", err)
   177  	}
   178  	var ret = make(map[string]*Commit)
   179  	for _, c := range commits {
   180  		ret[c.Hash] = c
   181  	}
   182  	return ret, nil
   183  }
   184  
   185  // formatGitAuthor formats the git author name and email (as split by
   186  // maintner) back into the unified string how they're stored in a git
   187  // commit, so the shortUser func (used by the HTML template) can parse
   188  // back out the email part's username later. Maybe we could plumb down
   189  // the parsed proto into the template later.
   190  func formatGitAuthor(name, email string) string {
   191  	name = strings.TrimSpace(name)
   192  	email = strings.TrimSpace(email)
   193  	if name != "" && email != "" {
   194  		return fmt.Sprintf("%s <%s>", name, email)
   195  	}
   196  	if name != "" {
   197  		return name
   198  	}
   199  	return "<" + email + ">"
   200  }
   201  
   202  // newCommitInfo returns a new CommitInfo populated for the template
   203  // data given a repo name and a dashboard commit from that repo, using
   204  // previously loaded datastore commit info in tb.
   205  func (tb *uiTemplateDataBuilder) newCommitInfo(dsCommits map[string]*Commit, repo string, dc *apipb.DashCommit) *CommitInfo {
   206  	branch := dc.Branch
   207  	if branch == "" {
   208  		branch = "master"
   209  	}
   210  	ci := &CommitInfo{
   211  		Hash:        dc.Commit,
   212  		PackagePath: repo,
   213  		User:        formatGitAuthor(dc.AuthorName, dc.AuthorEmail),
   214  		Desc:        cleanTitle(dc.Title, tb.branch()),
   215  		Time:        time.Unix(dc.CommitTimeSec, 0),
   216  		Branch:      branch,
   217  	}
   218  	if dsc, ok := dsCommits[dc.Commit]; ok {
   219  		// Remove extra dsc.ResultData capacity to make it
   220  		// okay to append additional data to ci.ResultData.
   221  		ci.ResultData = dsc.ResultData[:len(dsc.ResultData):len(dsc.ResultData)]
   222  	}
   223  	// For non-go repos, add the rows for the Go commits that were
   224  	// at HEAD overlapping in time with dc.Commit.
   225  	if !tb.isGoRepo() {
   226  		if dc.GoCommitAtTime != "" {
   227  			ci.addEmptyResultGoHash(dc.GoCommitAtTime)
   228  		}
   229  		if dc.GoCommitLatest != "" && dc.GoCommitLatest != dc.GoCommitAtTime {
   230  			ci.addEmptyResultGoHash(dc.GoCommitLatest)
   231  		}
   232  	}
   233  	return ci
   234  }
   235  
   236  func (tb *uiTemplateDataBuilder) showLUCI() bool {
   237  	v, ok := tb.view.(htmlView)
   238  	return ok && v == htmlView{ShowLUCI: true} &&
   239  		// Only show LUCI build results for the default top-level view.
   240  		tb.req.Page == 0 &&
   241  		tb.req.Repo == "" &&
   242  		tb.branch() == "master"
   243  }
   244  
   245  // showXRepoSection reports whether the dashboard should show the state of the x/foo repos at the bottom of
   246  // the page in the three branches (master, latest release branch, two releases ago).
   247  func (tb *uiTemplateDataBuilder) showXRepoSection() bool {
   248  	return tb.req.Page == 0 &&
   249  		(tb.branch() == "master" || tb.req.Branch == "mixed") &&
   250  		tb.isGoRepo()
   251  }
   252  
   253  func (tb *uiTemplateDataBuilder) isGoRepo() bool { return tb.req.Repo == "" || tb.req.Repo == "go" }
   254  
   255  // repoGerritProj returns the Gerrit project name on go.googlesource.com for
   256  // the repo requested, or empty if unknown.
   257  func (tb *uiTemplateDataBuilder) repoGerritProj() string {
   258  	if tb.isGoRepo() {
   259  		return "go"
   260  	}
   261  	if r, ok := repos.ByImportPath[tb.req.Repo]; ok {
   262  		return r.GoGerritProject
   263  	}
   264  	return ""
   265  }
   266  
   267  // branch returns the request branch, or "master" if empty.
   268  func (tb *uiTemplateDataBuilder) branch() string {
   269  	if tb.req.Branch == "" {
   270  		return "master"
   271  	}
   272  	return tb.req.Branch
   273  }
   274  
   275  // repoImportPath returns the import path for rh, unless rh is the
   276  // main "go" repo or is configured to be hidden from the dashboard, in
   277  // which case it returns the empty string.
   278  func repoImportPath(rh *apipb.DashRepoHead) string {
   279  	if rh.GerritProject == "go" {
   280  		return ""
   281  	}
   282  	ri, ok := repos.ByGerritProject[rh.GerritProject]
   283  	if !ok || !ri.ShowOnDashboard() {
   284  		return ""
   285  	}
   286  	return ri.ImportPath
   287  }
   288  
   289  // appendLUCIResults appends result data polled from LUCI to commits.
   290  func appendLUCIResults(luci lucipoll.Snapshot, commits []*CommitInfo, repo string) {
   291  	commitBuilds, ok := luci.RepoCommitBuilds[repo]
   292  	if !ok {
   293  		return
   294  	}
   295  	for _, c := range commits {
   296  		builds, ok := commitBuilds[c.Hash]
   297  		if !ok {
   298  			// No builds for this commit.
   299  			continue
   300  		}
   301  		for _, b := range builds {
   302  			builder := luci.Builders[b.BuilderName]
   303  			if builder.Repo != repo || builder.GoBranch != "master" {
   304  				// TODO: Support builder.GoBranch != master for x/ repos.
   305  				// Or maybe it's fine to leave that to appendLUCIResultsXRepo only,
   306  				// and have this appendLUCIResults function deal repo == "go" only.
   307  				// Hmm, maybe it needs to handle repo != "go" for when viewing the
   308  				// individual repo commit views... I'll see later.
   309  				continue
   310  			}
   311  			switch b.Status {
   312  			case bbpb.Status_STARTED, bbpb.Status_SUCCESS, bbpb.Status_FAILURE, bbpb.Status_INFRA_FAILURE:
   313  			default:
   314  				continue
   315  			}
   316  			tagFriendly := builder.Name
   317  			if after, ok := strings.CutPrefix(tagFriendly, fmt.Sprintf("gotip-%s-%s_", builder.Target.GOOS, builder.Target.GOARCH)); ok {
   318  				// Convert os-arch_osversion-mod1-mod2 (an underscore at start of "_osversion")
   319  				// to have os-arch-osversion-mod1-mod2 (a dash at start of "-osversion") form.
   320  				// The tag computation below uses this to find both "osversion-mod1" or "mod1".
   321  				tagFriendly = fmt.Sprintf("gotip-%s-%s-", builder.Target.GOOS, builder.Target.GOARCH) + after
   322  			}
   323  			builderName := strings.TrimPrefix(tagFriendly, "gotip-") + "-🐇"
   324  			// Note: goHash is empty for the main Go repo.
   325  			goHash := ""
   326  			if repo != "go" {
   327  				// TODO: Generalize goHash for non-go builds.
   328  				//
   329  				// If the non-go build was triggered by an x/ repo trigger,
   330  				// its b.GetInput().GetGitilesCommit().GetId() will be the
   331  				// x/ repo commit, and the Go repo commit will be selected
   332  				// at runtime...
   333  				//
   334  				// Can get it out of output properties but that would mean
   335  				// not being able to support STARTED builds.
   336  			}
   337  			switch b.Status {
   338  			case bbpb.Status_STARTED:
   339  				if c.BuildingURLs == nil {
   340  					c.BuildingURLs = make(map[builderAndGoHash]string)
   341  				}
   342  				c.BuildingURLs[builderAndGoHash{builderName, goHash}] = buildURL(b.ID)
   343  			case bbpb.Status_SUCCESS, bbpb.Status_FAILURE:
   344  				c.ResultData = append(c.ResultData, fmt.Sprintf("%s|%t|%s|%s",
   345  					builderName,
   346  					b.Status == bbpb.Status_SUCCESS,
   347  					buildURL(b.ID),
   348  					goHash,
   349  				))
   350  			case bbpb.Status_INFRA_FAILURE:
   351  				c.ResultData = append(c.ResultData, fmt.Sprintf("%s|%s|%s|%s",
   352  					builderName,
   353  					"infra_failure",
   354  					buildURL(b.ID),
   355  					goHash,
   356  				))
   357  			}
   358  		}
   359  	}
   360  }
   361  
   362  func appendLUCIResultsXRepo(luci lucipoll.Snapshot, c *CommitInfo, repo, goBranch, goHash string) {
   363  	commitBuilds, ok := luci.RepoCommitBuilds[repo]
   364  	if !ok {
   365  		return
   366  	}
   367  	builds, ok := commitBuilds[c.Hash]
   368  	if !ok {
   369  		// No builds for this commit.
   370  		return
   371  	}
   372  	for _, b := range builds {
   373  		builder := luci.Builders[b.BuilderName]
   374  		if builder.Repo != repo || builder.GoBranch != goBranch {
   375  			continue
   376  		}
   377  		switch b.Status {
   378  		case bbpb.Status_STARTED, bbpb.Status_SUCCESS, bbpb.Status_FAILURE, bbpb.Status_INFRA_FAILURE:
   379  		default:
   380  			continue
   381  		}
   382  		// TODO: Maybe dedup builder name calculation with addLUCIBuilders and more places.
   383  		// That said, those few places have slightly different details, so maybe it's fine.
   384  		shortGoBranch := "tip"
   385  		if after, ok := strings.CutPrefix(goBranch, "release-branch.go"); ok {
   386  			shortGoBranch = after
   387  		}
   388  		tagFriendly := builder.Name
   389  		if after, ok := strings.CutPrefix(tagFriendly, fmt.Sprintf("x_%s-go%s-%s-%s_", repo, shortGoBranch, builder.Target.GOOS, builder.Target.GOARCH)); ok {
   390  			// Convert os-arch_osversion-mod1-mod2 (an underscore at start of "_osversion")
   391  			// to have os-arch-osversion-mod1-mod2 (a dash at start of "-osversion") form.
   392  			// The tag computation below uses this to find both "osversion-mod1" or "mod1".
   393  			tagFriendly = fmt.Sprintf("x_%s-go%s-%s-%s-", repo, shortGoBranch, builder.Target.GOOS, builder.Target.GOARCH) + after
   394  		}
   395  		// The builder name is "os-arch{-suffix}-🐇". The "🐇" is used to avoid collisions
   396  		// in builder names between LUCI builders and coordinator builders, and to make it
   397  		// possible to tell LUCI builders apart by their name.
   398  		builderName := strings.TrimPrefix(tagFriendly, fmt.Sprintf("x_%s-go%s-", repo, shortGoBranch)) + "-🐇"
   399  		switch b.Status {
   400  		case bbpb.Status_STARTED:
   401  			if c.BuildingURLs == nil {
   402  				c.BuildingURLs = make(map[builderAndGoHash]string)
   403  			}
   404  			c.BuildingURLs[builderAndGoHash{builderName, goHash}] = buildURL(b.ID)
   405  		case bbpb.Status_SUCCESS, bbpb.Status_FAILURE:
   406  			c.ResultData = append(c.ResultData, fmt.Sprintf("%s|%t|%s|%s",
   407  				builderName,
   408  				b.Status == bbpb.Status_SUCCESS,
   409  				buildURL(b.ID),
   410  				goHash,
   411  			))
   412  		case bbpb.Status_INFRA_FAILURE:
   413  			c.ResultData = append(c.ResultData, fmt.Sprintf("%s|%s|%s|%s",
   414  				builderName,
   415  				"infra_failure",
   416  				buildURL(b.ID),
   417  				goHash,
   418  			))
   419  		}
   420  	}
   421  }
   422  
   423  func buildURL(buildID int64) string {
   424  	return fmt.Sprintf("https://ci.chromium.org/b/%d", buildID)
   425  }
   426  
   427  func (tb *uiTemplateDataBuilder) buildTemplateData(ctx context.Context, datastoreCl *datastore.Client, luci lucipoll.Snapshot) (*uiTemplateData, error) {
   428  	wantCommits := tb.getCommitsToLoad()
   429  	var dsCommits map[string]*Commit
   430  	if datastoreCl != nil {
   431  		var err error
   432  		dsCommits, err = tb.loadDatastoreCommits(ctx, datastoreCl, wantCommits)
   433  		if err != nil {
   434  			return nil, err
   435  		}
   436  	} else if m := tb.testCommitData; m != nil {
   437  		// Allow tests to fake what the datastore would've loaded, and
   438  		// thus also allow tests to be run without a real (or fake) datastore.
   439  		dsCommits = make(map[string]*Commit)
   440  		for k := range wantCommits {
   441  			if c, ok := m[k.commit]; ok {
   442  				dsCommits[k.commit] = c
   443  			}
   444  		}
   445  	}
   446  
   447  	var commits []*CommitInfo
   448  	for _, dc := range tb.res.Commits {
   449  		ci := tb.newCommitInfo(dsCommits, tb.req.Repo, dc)
   450  		commits = append(commits, ci)
   451  	}
   452  	appendLUCIResults(luci, commits, tb.repoGerritProj())
   453  
   454  	// x/ repo sections at bottom (each is a "TagState", for historical reasons)
   455  	var xRepoSections []*TagState
   456  	if tb.showXRepoSection() {
   457  		for _, gorel := range tb.res.Releases {
   458  			ts := &TagState{
   459  				Name: gorel.BranchName,
   460  				Tag: &CommitInfo{ // only a minimally populated version is needed by the template
   461  					Hash: gorel.BranchCommit,
   462  				},
   463  			}
   464  			for _, rh := range tb.res.RepoHeads {
   465  				path := repoImportPath(rh)
   466  				if path == "" {
   467  					continue
   468  				}
   469  				ci := tb.newCommitInfo(dsCommits, path, rh.Commit)
   470  				appendLUCIResultsXRepo(luci, ci, rh.GerritProject, ts.Branch(), gorel.BranchCommit)
   471  				ts.Packages = append(ts.Packages, &PackageState{
   472  					Package: &Package{
   473  						Name: rh.GerritProject,
   474  						Path: path,
   475  					},
   476  					Commit: ci,
   477  				})
   478  			}
   479  			builders := map[string]bool{}
   480  			for _, pkg := range ts.Packages {
   481  				addBuilders(builders, pkg.Package.Name, ts.Branch())
   482  			}
   483  			if len(luci.Builders) > 0 {
   484  				for name := range builders {
   485  					if dashboard.BuildersPortedToLUCI[name] {
   486  						// Don't display old builders that have been ported
   487  						// to LUCI if willing to show LUCI builders as well.
   488  						delete(builders, name)
   489  					}
   490  				}
   491  			}
   492  			addLUCIBuilders(luci, builders, ts.Packages, gorel.BranchName)
   493  			ts.Builders = builderKeys(builders)
   494  
   495  			sort.Slice(ts.Packages, func(i, j int) bool {
   496  				return ts.Packages[i].Package.Name < ts.Packages[j].Package.Name
   497  			})
   498  			xRepoSections = append(xRepoSections, ts)
   499  		}
   500  	}
   501  
   502  	gerritProject := "go"
   503  	if repo := repos.ByImportPath[tb.req.Repo]; repo != nil {
   504  		gerritProject = repo.GoGerritProject
   505  	}
   506  
   507  	data := &uiTemplateData{
   508  		Dashboard:  goDash,
   509  		Package:    goDash.packageWithPath(tb.req.Repo),
   510  		Commits:    commits,
   511  		TagState:   xRepoSections,
   512  		Pagination: &Pagination{},
   513  		Branches:   tb.res.Branches,
   514  		Branch:     tb.branch(),
   515  		Repo:       gerritProject,
   516  	}
   517  
   518  	builders := buildersOfCommits(commits)
   519  	if tb.branch() == "mixed" {
   520  		for _, gr := range tb.res.Releases {
   521  			addBuilders(builders, tb.repoGerritProj(), gr.BranchName)
   522  		}
   523  	} else {
   524  		addBuilders(builders, tb.repoGerritProj(), tb.branch())
   525  	}
   526  	if len(luci.Builders) > 0 {
   527  		for name := range builders {
   528  			if dashboard.BuildersPortedToLUCI[name] {
   529  				// Don't display old builders that have been ported
   530  				// to LUCI if willing to show LUCI builders as well.
   531  				delete(builders, name)
   532  			}
   533  		}
   534  	}
   535  	data.Builders = builderKeys(builders)
   536  
   537  	if tb.res.CommitsTruncated {
   538  		data.Pagination.Next = int(tb.req.Page) + 1
   539  	}
   540  	if tb.req.Page > 0 {
   541  		data.Pagination.Prev = int(tb.req.Page) - 1
   542  		data.Pagination.HasPrev = true
   543  	}
   544  
   545  	if tb.view.ShowsActiveBuilds() {
   546  		// Populate building URLs for the HTML UI only.
   547  		data.populateBuildingURLs(ctx, tb.activeBuilds)
   548  
   549  		// Appending LUCI active builds can be done here, but it's done
   550  		// earlier in appendLUCIResults instead because it's convenient
   551  		// to do there in the case of LUCI.
   552  	}
   553  
   554  	return data, nil
   555  }
   556  
   557  // htmlView renders the HTML (default) form of https://build.golang.org/ with no mode parameter.
   558  type htmlView struct {
   559  	// ShowLUCI controls whether to show build results from the LUCI post-submit dashboard
   560  	// in addition to coordinator-backed build results from Datastore.
   561  	//
   562  	// It has no effect if there's no LUCI client.
   563  	ShowLUCI bool
   564  }
   565  
   566  func (htmlView) ShowsActiveBuilds() bool { return true }
   567  func (htmlView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
   568  	var buf bytes.Buffer
   569  	if err := uiTemplate.Execute(&buf, data); err != nil {
   570  		log.Printf("Error: %v", err)
   571  		http.Error(w, "Error: "+err.Error(), http.StatusInternalServerError)
   572  		return
   573  	}
   574  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
   575  	buf.WriteTo(w)
   576  }
   577  
   578  // dashboardRequest is a pure function that maps the provided HTTP
   579  // request to a maintner DashboardRequest and lightly validates the
   580  // HTTP request for the root dashboard handler. (It does not validate
   581  // that, say, branches or repos are valid.)
   582  // Any returned error is an HTTP 400 Bad Request.
   583  func dashboardRequest(view dashboardView, r *http.Request) (*apipb.DashboardRequest, error) {
   584  	page := 0
   585  	if s := r.FormValue("page"); s != "" {
   586  		var err error
   587  		page, err = strconv.Atoi(r.FormValue("page"))
   588  		if err != nil {
   589  			return nil, fmt.Errorf("invalid page value %q", s)
   590  		}
   591  		if page < 0 {
   592  			return nil, errors.New("negative page")
   593  		}
   594  	}
   595  
   596  	repo := r.FormValue("repo")     // empty for main go repo, else e.g. "golang.org/x/net"
   597  	branch := r.FormValue("branch") // empty means "master"
   598  	maxCommits := commitsPerPage
   599  	if branch == "mixed" {
   600  		maxCommits = 0 // let maintner decide
   601  	}
   602  	return &apipb.DashboardRequest{
   603  		Page:       int32(page),
   604  		Repo:       repo,
   605  		Branch:     branch,
   606  		MaxCommits: int32(maxCommits),
   607  	}, nil
   608  }
   609  
   610  // cleanTitle returns a cleaned version of the provided title for
   611  // users viewing the provided viewBranch.
   612  func cleanTitle(title, viewBranch string) string {
   613  	// Don't rewrite anything for master and mixed.
   614  	if viewBranch == "master" || viewBranch == "mixed" {
   615  		return title
   616  	}
   617  	// Strip the "[release-branch.go1.n]" prefixes from commit messages
   618  	// when looking at a branch.
   619  	if strings.HasPrefix(title, "[") {
   620  		if i := strings.IndexByte(title, ']'); i != -1 {
   621  			return strings.TrimSpace(title[i+1:])
   622  		}
   623  	}
   624  	return title
   625  }
   626  
   627  // failuresView renders https://build.golang.org/?mode=failures, where it outputs
   628  // one line per failure on the front page, in the form:
   629  //
   630  //	hash builder failure-url
   631  type failuresView struct{}
   632  
   633  func (failuresView) ShowsActiveBuilds() bool { return false }
   634  func (failuresView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
   635  	w.Header().Set("Content-Type", "text/plain")
   636  	for _, c := range data.Commits {
   637  		for _, b := range data.Builders {
   638  			res := c.Result(b, "")
   639  			if res == nil || res.OK || res.LogHash == "" {
   640  				continue
   641  			}
   642  			url := fmt.Sprintf("https://%v/log/%v", r.Host, res.LogHash)
   643  			fmt.Fprintln(w, c.Hash, b, url)
   644  		}
   645  	}
   646  	// TODO: this doesn't include the TagState commit. It would be
   647  	// needed if we want to do golang.org/issue/36131, to permit
   648  	// the retrybuilds command to wipe flaky non-go builds.
   649  }
   650  
   651  // jsonView renders https://build.golang.org/?mode=json.
   652  // The output is a types.BuildStatus JSON object.
   653  type jsonView struct{}
   654  
   655  func (jsonView) ShowsActiveBuilds() bool { return false }
   656  func (jsonView) ServeDashboard(w http.ResponseWriter, r *http.Request, data *uiTemplateData) {
   657  	res := toBuildStatus(r.Host, data)
   658  	v, _ := json.MarshalIndent(res, "", "\t")
   659  	w.Header().Set("Content-Type", "text/json; charset=utf-8")
   660  	w.Write(v)
   661  }
   662  
   663  func toBuildStatus(host string, data *uiTemplateData) types.BuildStatus {
   664  	// cell returns one of "" (no data), "ok", or a failure URL.
   665  	cell := func(res *DisplayResult) string {
   666  		switch {
   667  		case res == nil:
   668  			return ""
   669  		case res.OK:
   670  			return "ok"
   671  		}
   672  		return fmt.Sprintf("https://%v/log/%v", host, res.LogHash)
   673  	}
   674  
   675  	builders := data.allBuilders()
   676  
   677  	var res types.BuildStatus
   678  	res.Builders = builders
   679  
   680  	// First the commits from the main section (the requested repo)
   681  	for _, c := range data.Commits {
   682  		// The logic below works for both the go repo and other subrepos: if c is
   683  		// in the main go repo, ResultGoHashes returns a slice of length 1
   684  		// containing the empty string.
   685  		for _, h := range c.ResultGoHashes() {
   686  			rev := types.BuildRevision{
   687  				Repo:       data.Repo,
   688  				Results:    make([]string, len(res.Builders)),
   689  				GoRevision: h,
   690  			}
   691  			commitToBuildRevision(c, &rev)
   692  			for i, b := range res.Builders {
   693  				rev.Results[i] = cell(c.Result(b, h))
   694  			}
   695  			res.Revisions = append(res.Revisions, rev)
   696  		}
   697  	}
   698  
   699  	// Then the one commit each for the subrepos for each of the tracked tags.
   700  	// (tip, Go 1.4, etc)
   701  	for _, ts := range data.TagState {
   702  		for _, pkgState := range ts.Packages {
   703  			goRev := ts.Tag.Hash
   704  			goBranch := ts.Name
   705  			if goBranch == "tip" {
   706  				// Normalize old hg terminology into
   707  				// our git branch name.
   708  				goBranch = "master"
   709  			}
   710  			rev := types.BuildRevision{
   711  				Repo:       pkgState.Package.Name,
   712  				GoRevision: goRev,
   713  				Results:    make([]string, len(res.Builders)),
   714  				GoBranch:   goBranch,
   715  			}
   716  			commitToBuildRevision(pkgState.Commit, &rev)
   717  			for i, b := range res.Builders {
   718  				rev.Results[i] = cell(pkgState.Commit.Result(b, goRev))
   719  			}
   720  			res.Revisions = append(res.Revisions, rev)
   721  		}
   722  	}
   723  	return res
   724  }
   725  
   726  // commitToBuildRevision fills in the fields of BuildRevision rev that
   727  // are derived from Commit c.
   728  func commitToBuildRevision(c *CommitInfo, rev *types.BuildRevision) {
   729  	rev.Revision = c.Hash
   730  	rev.Date = c.Time.Format(time.RFC3339)
   731  	rev.Author = c.User
   732  	rev.Desc = c.Desc
   733  	rev.Branch = c.Branch
   734  }
   735  
   736  type Pagination struct {
   737  	Next, Prev int
   738  	HasPrev    bool
   739  }
   740  
   741  // fetchCommits loads any commits that exist given by keys.
   742  // It is not an error if a commit doesn't exist.
   743  // Only commits that were found in datastore are returned,
   744  // in an unspecified order.
   745  func fetchCommits(ctx context.Context, cl *datastore.Client, keys []*datastore.Key) ([]*Commit, error) {
   746  	if len(keys) == 0 {
   747  		return nil, nil
   748  	}
   749  	out := make([]*Commit, len(keys))
   750  	for i := range keys {
   751  		out[i] = new(Commit)
   752  	}
   753  
   754  	err := cl.GetMulti(ctx, keys, out)
   755  	err = filterDatastoreError(err)
   756  	err = filterNoSuchEntity(err)
   757  	if err != nil {
   758  		return nil, err
   759  	}
   760  	filtered := out[:0]
   761  	for _, c := range out {
   762  		if c.Valid() { // that is, successfully loaded
   763  			filtered = append(filtered, c)
   764  		}
   765  	}
   766  	return filtered, nil
   767  }
   768  
   769  // buildersOfCommits returns the set of builders that provided
   770  // Results for the provided commits.
   771  func buildersOfCommits(commits []*CommitInfo) map[string]bool {
   772  	m := make(map[string]bool)
   773  	for _, commit := range commits {
   774  		for _, r := range commit.Results() {
   775  			if r.Builder != "" {
   776  				m[r.Builder] = true
   777  			}
   778  		}
   779  	}
   780  	return m
   781  }
   782  
   783  // addBuilders adds builders to the provided map that should be active for
   784  // the named Gerrit project & branch. (Issue 19930)
   785  func addBuilders(builders map[string]bool, gerritProj, branch string) {
   786  	for name, bc := range dashboard.Builders {
   787  		if bc.BuildsRepoPostSubmit(gerritProj, branch, branch) {
   788  			builders[name] = true
   789  		}
   790  	}
   791  }
   792  
   793  // addLUCIBuilders adds LUCI builders that match the given xRepos and goBranch
   794  // to the provided builders map.
   795  func addLUCIBuilders(luci lucipoll.Snapshot, builders map[string]bool, xRepos []*PackageState, goBranch string) {
   796  	for _, r := range xRepos {
   797  		repoName := r.Package.Name
   798  		for _, b := range luci.Builders {
   799  			if b.Repo != repoName || b.GoBranch != goBranch {
   800  				// Filter out builders whose repo or Go branch doesn't match.
   801  				continue
   802  			}
   803  			shortGoBranch := "tip"
   804  			if after, ok := strings.CutPrefix(b.GoBranch, "release-branch.go"); ok {
   805  				shortGoBranch = after
   806  			}
   807  			tagFriendly := b.Name
   808  			if after, ok := strings.CutPrefix(tagFriendly, fmt.Sprintf("x_%s-go%s-%s-%s_", b.Repo, shortGoBranch, b.Target.GOOS, b.Target.GOARCH)); ok {
   809  				// Convert os-arch_osversion-mod1-mod2 (an underscore at start of "_osversion")
   810  				// to have os-arch-osversion-mod1-mod2 (a dash at start of "-osversion") form.
   811  				// The tag computation below uses this to find both "osversion-mod1" or "mod1".
   812  				tagFriendly = fmt.Sprintf("x_%s-go%s-%s-%s-", b.Repo, shortGoBranch, b.Target.GOOS, b.Target.GOARCH) + after
   813  			}
   814  			// The builder name is "os-arch{-suffix}-🐇". The "🐇" is used to avoid collisions
   815  			// in builder names between LUCI builders and coordinator builders, and to make to
   816  			// possible to tell LUCI builders apart by their name.
   817  			builderName := strings.TrimPrefix(tagFriendly, fmt.Sprintf("x_%s-go%s-", b.Repo, shortGoBranch)) + "-🐇"
   818  			builders[builderName] = true
   819  		}
   820  	}
   821  }
   822  
   823  func builderKeys(m map[string]bool) (s []string) {
   824  	s = make([]string, 0, len(m))
   825  	for k := range m {
   826  		s = append(s, k)
   827  	}
   828  	sort.Sort(builderOrder(s))
   829  	return
   830  }
   831  
   832  // builderOrder implements sort.Interface, sorting builder names
   833  // ("darwin-amd64", etc) first by builderPriority and then alphabetically.
   834  type builderOrder []string
   835  
   836  func (s builderOrder) Len() int      { return len(s) }
   837  func (s builderOrder) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
   838  func (s builderOrder) Less(i, j int) bool {
   839  	pi, pj := builderPriority(s[i]), builderPriority(s[j])
   840  	if pi == pj {
   841  		return s[i] < s[j]
   842  	}
   843  	return pi < pj
   844  }
   845  
   846  func builderPriority(builder string) (p int) {
   847  	// Group race builders together.
   848  	if isRace(builder) {
   849  		return 2
   850  	}
   851  	// If the OS has a specified priority, use it.
   852  	if p, ok := osPriority[builderOS(builder)]; ok {
   853  		return p
   854  	}
   855  	// The rest.
   856  	return 10
   857  }
   858  
   859  func isRace(s string) bool {
   860  	return strings.Contains(s, "-race-") || strings.HasSuffix(s, "-race")
   861  }
   862  
   863  func unsupported(builder string) bool {
   864  	return !releasetargets.IsFirstClass(builderOS(builder), builderArch(builder))
   865  }
   866  
   867  // osPriority encodes priorities for specific operating systems.
   868  var osPriority = map[string]int{
   869  	"all":    0,
   870  	"darwin": 1, "linux": 1, "windows": 1,
   871  	// race == 2
   872  	"freebsd": 3, "openbsd": 3, "netbsd": 3,
   873  	"android": 4, "ios": 4,
   874  }
   875  
   876  // TagState represents the state of all Packages at a branch.
   877  type TagState struct {
   878  	Name     string      // Go branch name: "master", "release-branch.go1.4", etc
   879  	Tag      *CommitInfo // current Go commit on the Name branch
   880  	Packages []*PackageState
   881  	Builders []string
   882  }
   883  
   884  // Branch returns the git branch name, converting from the old
   885  // terminology we used from Go's hg days into git terminology.
   886  func (ts *TagState) Branch() string {
   887  	if ts.Name == "tip" {
   888  		return "master"
   889  	}
   890  	return ts.Name
   891  }
   892  
   893  // PackageState represents the state of a Package (x/foo repo) for given Go branch.
   894  type PackageState struct {
   895  	Package *Package
   896  	Commit  *CommitInfo
   897  }
   898  
   899  // A CommitInfo is a struct for use by html/template package.
   900  // It is not stored in the datastore.
   901  type CommitInfo struct {
   902  	Hash string
   903  
   904  	// ResultData is a copy of the [Commit.ResultData] field from datastore,
   905  	// with an additional rule that the second '|'-separated value may be "infra_failure"
   906  	// to indicate a problem with the infrastructure rather than the code being tested.
   907  	ResultData []string
   908  
   909  	// BuildingURLs contains the status URL values for builds that
   910  	// are currently in progress for this commit.
   911  	BuildingURLs map[builderAndGoHash]string
   912  
   913  	PackagePath string    // (empty for main repo commits)
   914  	User        string    // "Foo Bar <foo@bar.com>"
   915  	Desc        string    // git commit title
   916  	Time        time.Time // commit time
   917  	Branch      string    // "master", "release-branch.go1.14"
   918  }
   919  
   920  // addEmptyResultGoHash adds an empty result containing goHash to
   921  // ci.ResultData, unless ci already contains a result for that hash.
   922  // This is used for non-go repos to show the go commits (both earliest
   923  // and latest) that correspond to this repo's commit time. We add an
   924  // empty result so it shows up on the dashboard (both for humans, and
   925  // in JSON form for the coordinator to pick up as work). Once the
   926  // coordinator does that work and posts its result, then ResultData
   927  // will be populate and this turns into a no-op.
   928  func (ci *CommitInfo) addEmptyResultGoHash(goHash string) {
   929  	for _, exist := range ci.ResultData {
   930  		if strings.Contains(exist, goHash) {
   931  			return
   932  		}
   933  	}
   934  	ci.ResultData = append(ci.ResultData, (&Result{GoHash: goHash}).Data())
   935  }
   936  
   937  type uiTemplateData struct {
   938  	Dashboard  *Dashboard
   939  	Package    *Package
   940  	Commits    []*CommitInfo
   941  	Builders   []string    // builders for just the main section; not the "TagState" sections
   942  	TagState   []*TagState // x/foo repo overviews at master + last two releases
   943  	Pagination *Pagination
   944  	Branches   []string
   945  	Branch     string
   946  	Repo       string // the repo gerrit project name. "go" if unspecified in the request.
   947  }
   948  
   949  // getActiveBuilds returns the builds that coordinator is currently doing.
   950  // This isn't critical functionality so errors are logged but otherwise ignored for now.
   951  // Once this is merged into the coordinator we won't need to make an RPC to get
   952  // this info. See https://github.com/golang/go/issues/34744#issuecomment-563398753.
   953  func getActiveBuilds(ctx context.Context) (builds []types.ActivePostSubmitBuild) {
   954  	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
   955  	defer cancel()
   956  	req, _ := http.NewRequest("GET", "https://farmer.golang.org/status/post-submit-active.json", nil)
   957  	req = req.WithContext(ctx)
   958  	res, err := http.DefaultClient.Do(req)
   959  	if err != nil {
   960  		log.Printf("getActiveBuilds: Do: %v", err)
   961  		return
   962  	}
   963  	defer res.Body.Close()
   964  	if res.StatusCode != 200 {
   965  		log.Printf("getActiveBuilds: %v", res.Status)
   966  		return
   967  	}
   968  	if err := json.NewDecoder(res.Body).Decode(&builds); err != nil {
   969  		log.Printf("getActiveBuilds: JSON decode: %v", err)
   970  	}
   971  	return builds
   972  }
   973  
   974  // populateBuildingURLs populates each commit in Commits' buildingURLs map with the
   975  // URLs of builds which are currently in progress.
   976  func (td *uiTemplateData) populateBuildingURLs(ctx context.Context, activeBuilds []types.ActivePostSubmitBuild) {
   977  	// active maps from a build record with its status URL zeroed
   978  	// out to the actual value of that status URL.
   979  	active := map[types.ActivePostSubmitBuild]string{}
   980  	for _, rec := range activeBuilds {
   981  		statusURL := rec.StatusURL
   982  		rec.StatusURL = ""
   983  		active[rec] = statusURL
   984  	}
   985  
   986  	condAdd := func(c *CommitInfo, rec types.ActivePostSubmitBuild) {
   987  		su, ok := active[rec]
   988  		if !ok {
   989  			return
   990  		}
   991  		if c.BuildingURLs == nil {
   992  			c.BuildingURLs = make(map[builderAndGoHash]string)
   993  		}
   994  		c.BuildingURLs[builderAndGoHash{rec.Builder, rec.GoCommit}] = su
   995  	}
   996  
   997  	for _, b := range td.Builders {
   998  		for _, c := range td.Commits {
   999  			condAdd(c, types.ActivePostSubmitBuild{Builder: b, Commit: c.Hash})
  1000  		}
  1001  	}
  1002  
  1003  	// Gather pending commits for sub-repos.
  1004  	for _, ts := range td.TagState {
  1005  		goHash := ts.Tag.Hash
  1006  		for _, b := range td.Builders {
  1007  			for _, pkg := range ts.Packages {
  1008  				c := pkg.Commit
  1009  				condAdd(c, types.ActivePostSubmitBuild{
  1010  					Builder:  b,
  1011  					Commit:   c.Hash,
  1012  					GoCommit: goHash,
  1013  				})
  1014  			}
  1015  		}
  1016  	}
  1017  }
  1018  
  1019  // allBuilders returns the list of builders, unified over the main
  1020  // section and any x/foo branch overview (TagState) sections.
  1021  func (td *uiTemplateData) allBuilders() []string {
  1022  	m := map[string]bool{}
  1023  	for _, b := range td.Builders {
  1024  		m[b] = true
  1025  	}
  1026  	for _, ts := range td.TagState {
  1027  		for _, b := range ts.Builders {
  1028  			m[b] = true
  1029  		}
  1030  	}
  1031  	return builderKeys(m)
  1032  }
  1033  
  1034  var uiTemplate = template.Must(
  1035  	template.New("ui.html").Funcs(tmplFuncs).Parse(uiHTML),
  1036  )
  1037  
  1038  //go:embed ui.html
  1039  var uiHTML string
  1040  
  1041  var tmplFuncs = template.FuncMap{
  1042  	"builderSpans":       builderSpans,
  1043  	"builderSubheading":  builderSubheading,
  1044  	"builderSubheading2": builderSubheading2,
  1045  	"shortDesc":          shortDesc,
  1046  	"shortHash":          shortHash,
  1047  	"shortUser":          shortUser,
  1048  	"unsupported":        unsupported,
  1049  	"isUntested":         isUntested,
  1050  	"knownIssue":         knownIssue,
  1051  	"formatTime":         formatTime,
  1052  }
  1053  
  1054  func formatTime(t time.Time) string {
  1055  	if t.Year() != time.Now().Year() {
  1056  		return t.Format("02 Jan 06")
  1057  	}
  1058  	return t.Format("02 Jan 15:04")
  1059  }
  1060  
  1061  func splitDash(s string) (string, string) {
  1062  	i := strings.Index(s, "-")
  1063  	if i >= 0 {
  1064  		return s[:i], s[i+1:]
  1065  	}
  1066  	return s, ""
  1067  }
  1068  
  1069  // builderOS returns the os tag for a builder string
  1070  func builderOS(s string) string {
  1071  	os, _ := splitDash(s)
  1072  	return os
  1073  }
  1074  
  1075  // builderOSOrRace returns the builder OS or, if it is a race builder, "race".
  1076  func builderOSOrRace(s string) string {
  1077  	if isRace(s) {
  1078  		return "race"
  1079  	}
  1080  	return builderOS(s)
  1081  }
  1082  
  1083  // builderArch returns the arch tag for a builder string
  1084  func builderArch(s string) string {
  1085  	_, arch := splitDash(s)
  1086  	arch, _ = splitDash(arch) // chop third part
  1087  	return arch
  1088  }
  1089  
  1090  // builderSubheading returns a short arch tag for a builder string
  1091  // or, if it is a race builder, the builder OS.
  1092  func builderSubheading(s string) string {
  1093  	if isRace(s) {
  1094  		return builderOS(s)
  1095  	}
  1096  	return builderArch(s)
  1097  }
  1098  
  1099  // builderSubheading2 returns any third part of a hyphenated builder name.
  1100  // For instance, for "linux-amd64-nocgo", it returns "nocgo".
  1101  // For race builders it returns the empty string.
  1102  func builderSubheading2(s string) string {
  1103  	if isRace(s) {
  1104  		// Remove "race" and just take whatever the third component is after.
  1105  		var split []string
  1106  		for _, sc := range strings.Split(s, "-") {
  1107  			if sc == "race" {
  1108  				continue
  1109  			}
  1110  			split = append(split, sc)
  1111  		}
  1112  		s = strings.Join(split, "-")
  1113  	}
  1114  	_, secondThird := splitDash(s)
  1115  	_, third := splitDash(secondThird)
  1116  	return third
  1117  }
  1118  
  1119  type builderSpan struct {
  1120  	N           int // Total number of builders.
  1121  	FirstN      int // Number of builders for a first-class port.
  1122  	OS          string
  1123  	Unsupported bool // Unsupported means the entire span has no builders for a first-class port.
  1124  }
  1125  
  1126  // builderSpans creates a list of tags showing
  1127  // the builder's operating system names, spanning
  1128  // the appropriate number of columns.
  1129  func builderSpans(s []string) []builderSpan {
  1130  	var sp []builderSpan
  1131  	for len(s) > 0 {
  1132  		i := 1
  1133  		os := builderOSOrRace(s[0])
  1134  		for i < len(s) && builderOSOrRace(s[i]) == os {
  1135  			i++
  1136  		}
  1137  		var f int // First-class ports.
  1138  		for _, b := range s[:i] {
  1139  			if unsupported(b) {
  1140  				continue
  1141  			}
  1142  			f++
  1143  		}
  1144  		sp = append(sp, builderSpan{i, f, os, f == 0})
  1145  		s = s[i:]
  1146  	}
  1147  	return sp
  1148  }
  1149  
  1150  // shortDesc returns the first line of a description.
  1151  func shortDesc(desc string) string {
  1152  	if i := strings.Index(desc, "\n"); i != -1 {
  1153  		desc = desc[:i]
  1154  	}
  1155  	return limitStringLength(desc, 100)
  1156  }
  1157  
  1158  // shortHash returns a short version of a hash.
  1159  func shortHash(hash string) string {
  1160  	if len(hash) > 7 {
  1161  		hash = hash[:7]
  1162  	}
  1163  	return hash
  1164  }
  1165  
  1166  // shortUser returns a shortened version of a user string.
  1167  func shortUser(user string) string {
  1168  	if i, j := strings.Index(user, "<"), strings.Index(user, ">"); 0 <= i && i < j {
  1169  		user = user[i+1 : j]
  1170  	}
  1171  	if i := strings.Index(user, "@"); i >= 0 {
  1172  		return user[:i]
  1173  	}
  1174  	return user
  1175  }
  1176  
  1177  func httpStatusOfErr(err error) int {
  1178  	fmt.Fprintf(os.Stderr, "Got error: %#v, code %v\n", err, grpc.Code(err))
  1179  	switch grpc.Code(err) {
  1180  	case codes.NotFound:
  1181  		return http.StatusNotFound
  1182  	case codes.InvalidArgument:
  1183  		return http.StatusBadRequest
  1184  	default:
  1185  		return http.StatusInternalServerError
  1186  	}
  1187  }