go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/frontend/ui/build.go (about)

     1  // Copyright 2018 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package ui
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"html"
    23  	"html/template"
    24  	"net/url"
    25  	"regexp"
    26  	"sort"
    27  	"strconv"
    28  	"strings"
    29  	"time"
    30  
    31  	"github.com/golang/protobuf/jsonpb"
    32  	"google.golang.org/protobuf/types/known/structpb"
    33  	"google.golang.org/protobuf/types/known/timestamppb"
    34  
    35  	"go.chromium.org/luci/buildbucket/protoutil"
    36  	"go.chromium.org/luci/common/clock"
    37  	"go.chromium.org/luci/milo/internal/model"
    38  	"go.chromium.org/luci/milo/internal/utils"
    39  
    40  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    41  )
    42  
    43  var crosMainRE = regexp.MustCompile(`^cros/parent_buildbucket_id/(\d+)$`)
    44  
    45  // Step encapsulates a buildbucketpb.Step, and also allows it to carry
    46  // nesting information.
    47  type Step struct {
    48  	*buildbucketpb.Step
    49  	Children  []*Step        `json:"children,omitempty"`
    50  	Collapsed bool           `json:"collapsed,omitempty"`
    51  	Interval  utils.Interval `json:"interval,omitempty"`
    52  }
    53  
    54  // ShortName returns the leaf name of a potentially nested step.
    55  // Eg. With a name of GrandParent|Parent|Child, this returns "Child"
    56  func (s *Step) ShortName() string {
    57  	parts := strings.Split(s.Name, "|")
    58  	if len(parts) == 0 {
    59  		return "ERROR: EMPTY NAME"
    60  	}
    61  	return parts[len(parts)-1]
    62  }
    63  
    64  // Build wraps a buildbucketpb.Build to provide useful templating functions.
    65  // It is used in both BuildPage (in this file) and BuilderPage (builder.go).
    66  type Build struct {
    67  	*buildbucketpb.Build
    68  
    69  	// Now is the current time, used to generate durations that may depend
    70  	// on the current time.
    71  	Now *timestamppb.Timestamp `json:"now,omitempty"`
    72  }
    73  
    74  // CommitLinkHTML returns an HTML link pointing to the output commit, or input commit
    75  // if the output commit is not available.
    76  func (b *Build) CommitLinkHTML() template.HTML {
    77  	c := b.GetOutput().GetGitilesCommit()
    78  	if c == nil {
    79  		c = b.GetInput().GetGitilesCommit()
    80  	}
    81  	if c == nil {
    82  		return ""
    83  	}
    84  
    85  	// Choose a link label.
    86  	var label string
    87  	switch {
    88  	case c.Position != 0:
    89  		label = fmt.Sprintf("%s@{#%d}", c.Ref, c.Position)
    90  	case strings.HasPrefix(c.Ref, "refs/tags/"):
    91  		label = c.Ref
    92  	case c.Id != "":
    93  		label = c.Id
    94  	case c.Ref != "":
    95  		label = c.Ref
    96  	default:
    97  		return ""
    98  	}
    99  
   100  	return NewLink(label, protoutil.GitilesCommitURL(c), "commit "+label).HTML()
   101  }
   102  
   103  // BuildPage represents a build page on Milo.
   104  // The core of the build page is the underlying build proto, but can contain
   105  // extra information depending on the context, for example a blamelist,
   106  // and the user's display preferences.
   107  type BuildPage struct {
   108  	// Build is the underlying build proto for the build page.
   109  	Build
   110  
   111  	// Blame is a list of people and commits that likely caused the build result.
   112  	// It is usually used as the list of commits between the previous run of the
   113  	// build on the same builder, and this run.
   114  	Blame []*Commit `json:"blame,omitempty"`
   115  
   116  	// BuildbucketHost is the hostname for the buildbucket instance this build came from.
   117  	BuildbucketHost string `json:"buildbucket_host,omitempty"`
   118  
   119  	// Errors contains any non-critical errors encountered while rendering the page.
   120  	Errors []error `json:"errors,omitempty"`
   121  
   122  	// Mode to render the steps.
   123  	StepDisplayPref StepDisplayPref `json:"step_display_pref,omitempty"`
   124  
   125  	// Iff true, show all log links whose name starts with '$'.
   126  	ShowDebugLogsPref bool `json:"show_debug_logs_pref,omitempty"`
   127  
   128  	// timelineData caches the results from Timeline().
   129  	timelineData string
   130  
   131  	// steps caches the result of Steps().
   132  	steps []*Step
   133  
   134  	// BlamelistError holds errors related to the blamelist.
   135  	// This determines the behavior of clicking the "blamelist" tab.
   136  	BlamelistError error `json:"blamelist_error,omitempty"`
   137  
   138  	// ForcedBlamelist indicates that the user forced a blamelist load.
   139  	ForcedBlamelist bool `json:"forced_blamelist,omitempty"`
   140  
   141  	// Whether the user is able to perform certain actions on this build
   142  	CanCancel bool `json:"can_cancel,omitempty"`
   143  	CanRetry  bool `json:"can_retry,omitempty"`
   144  }
   145  
   146  // RelatedBuildsTable represents a related builds table on Milo.
   147  type RelatedBuildsTable struct {
   148  	// Build is the underlying build proto for the build page.
   149  	Build `json:"build,omitempty"`
   150  
   151  	// RelatedBuilds are build summaries with the same buildset.
   152  	RelatedBuilds []*Build `json:"related_builds,omitempty"`
   153  }
   154  
   155  func NewBuildPage(c context.Context, b *buildbucketpb.Build) *BuildPage {
   156  	now := timestamppb.New(clock.Now(c))
   157  	return &BuildPage{
   158  		Build: Build{Build: b, Now: now},
   159  	}
   160  }
   161  
   162  // ChangeLinks returns a slice of links to build input gerrit changes.
   163  func (b *Build) ChangeLinks() []*Link {
   164  	changes := b.GetInput().GetGerritChanges()
   165  	ret := make([]*Link, len(changes))
   166  	for i, c := range changes {
   167  		ret[i] = NewPatchLink(c)
   168  	}
   169  	return ret
   170  }
   171  
   172  func (b *Build) RecipeLink() *Link {
   173  	projectName := b.GetBuilder().GetProject()
   174  	cipdPackage := b.GetExe().GetCipdPackage()
   175  	recipeName := b.GetInput().GetProperties().GetFields()["recipe"].GetStringValue()
   176  	// We don't know location of recipes within the repo and getting that
   177  	// information is not trivial, so use code search, which is precise enough.
   178  	csHost := "source.chromium.org"
   179  	if strings.Contains(cipdPackage, "internal") {
   180  		csHost = "source.corp.google.com"
   181  	}
   182  	// TODO(crbug.com/1149540): remove this conditional once the long-term
   183  	// solution for recipe links has been implemented.
   184  	if projectName == "flutter" {
   185  		csHost = "cs.opensource.google"
   186  	}
   187  	u := url.URL{
   188  		Scheme: "https",
   189  		Host:   csHost,
   190  		Path:   "/search/",
   191  		RawQuery: url.Values{
   192  			"q": []string{fmt.Sprintf(`file:recipes/%s.py`, recipeName)},
   193  		}.Encode(),
   194  	}
   195  	return NewLink(recipeName, u.String(), fmt.Sprintf("recipe %s", recipeName))
   196  }
   197  
   198  // BuildbucketLink returns a link to the buildbucket version of the page.
   199  func (bp *BuildPage) BuildbucketLink() *Link {
   200  	if bp.BuildbucketHost == "" {
   201  		return nil
   202  	}
   203  	u := url.URL{
   204  		Scheme: "https",
   205  		Host:   bp.BuildbucketHost,
   206  		Path:   "/rpcexplorer/services/buildbucket.v2.Builds/GetBuild",
   207  		RawQuery: url.Values{
   208  			"request": []string{fmt.Sprintf(`{"id":"%d"}`, bp.Id)},
   209  		}.Encode(),
   210  	}
   211  	return NewLink(
   212  		fmt.Sprintf("%d", bp.Id),
   213  		u.String(),
   214  		"Buildbucket RPC explorer for build")
   215  }
   216  
   217  func (b *Build) BuildSets() []string {
   218  	return protoutil.BuildSets(b.Build)
   219  }
   220  
   221  func (b *Build) BuildSetLinks() []template.HTML {
   222  	buildSets := b.BuildSets()
   223  	links := make([]template.HTML, 0, len(buildSets))
   224  	for _, buildSet := range buildSets {
   225  		result := crosMainRE.FindStringSubmatch(buildSet)
   226  		if result == nil {
   227  			// Don't know how to link, just return the text.
   228  			links = append(links, template.HTML(template.HTMLEscapeString(buildSet)))
   229  		} else {
   230  			// This linking is for legacy ChromeOS builders to show a link to their
   231  			// main builder, it can be removed when these have been transitioned
   232  			// to parallel CQ.
   233  			buildbucketId := result[1]
   234  			builderURL := fmt.Sprintf("https://ci.chromium.org/b/%s", buildbucketId)
   235  			ariaLabel := fmt.Sprintf("Main builder %s", buildbucketId)
   236  			link := NewLink(buildSet, builderURL, ariaLabel)
   237  			links = append(links, link.HTML())
   238  		}
   239  	}
   240  	return links
   241  }
   242  
   243  // Steps converts the flat Steps from the underlying Build into a tree.
   244  // The tree is only calculated on the first call, all subsequent calls return cached information.
   245  // TODO(hinoka): Print nicer error messages instead of panicking for invalid build protos.
   246  func (bp *BuildPage) Steps() []*Step {
   247  	if bp.steps != nil {
   248  		return bp.steps
   249  	}
   250  	collapseGreen := bp.StepDisplayPref == StepDisplayDefault
   251  	// Use a map to store all the known steps, so that children can find their parents.
   252  	// This assumes that parents will always be traversed before children,
   253  	// which is always true in the build proto.
   254  	stepMap := map[string]*Step{}
   255  	for _, step := range bp.Build.Steps {
   256  		s := &Step{
   257  			Step:      step,
   258  			Collapsed: collapseGreen && step.Status == buildbucketpb.Status_SUCCESS,
   259  			Interval:  utils.ToInterval(step.GetStartTime(), step.GetEndTime(), bp.Now),
   260  		}
   261  		stepMap[step.Name] = s
   262  		switch nameParts := strings.Split(step.Name, "|"); len(nameParts) {
   263  		case 0:
   264  			panic("Invalid build.proto: Step with missing name.")
   265  		case 1:
   266  			// Root step.
   267  			bp.steps = append(bp.steps, s)
   268  		default:
   269  			parentName := step.Name[:strings.LastIndex(step.Name, "|")]
   270  			parent, ok := stepMap[parentName]
   271  			if !ok {
   272  				panic("Invalid build.proto: Missing parent.")
   273  			}
   274  			parent.Children = append(parent.Children, s)
   275  		}
   276  	}
   277  	return bp.steps
   278  }
   279  
   280  // HumanStatus returns a human friendly string for the status.
   281  func (b *Build) HumanStatus() string {
   282  	switch b.Status {
   283  	case buildbucketpb.Status_SCHEDULED:
   284  		return "Pending"
   285  	case buildbucketpb.Status_STARTED:
   286  		return "Running"
   287  	case buildbucketpb.Status_SUCCESS:
   288  		return "Success"
   289  	case buildbucketpb.Status_FAILURE:
   290  		return "Failure"
   291  	case buildbucketpb.Status_INFRA_FAILURE:
   292  		return "Infra Failure"
   293  	case buildbucketpb.Status_CANCELED:
   294  		return "Canceled"
   295  	default:
   296  		return "Unknown status"
   297  	}
   298  }
   299  
   300  // ShouldShowCanaryWarning returns true for failed canary builds.
   301  func (b *Build) ShouldShowCanaryWarning() bool {
   302  	return b.Canary && (b.Status == buildbucketpb.Status_FAILURE || b.Status == buildbucketpb.Status_INFRA_FAILURE)
   303  }
   304  
   305  type property struct {
   306  	// Name is the name of the property relative to a build.
   307  	// Note: We call this a "Name" not a "Key", since this was the term used in BuildBot.
   308  	Name string `json:"name,omitempty"`
   309  	// Value is a JSON string of the value.
   310  	Value string `json:"value,omitempty"`
   311  }
   312  
   313  // properties returns the values in the proto struct fields as
   314  // a json rendered slice of pairs, sorted by key.
   315  func properties(props *structpb.Struct) []property {
   316  	if props == nil {
   317  		return nil
   318  	}
   319  	// Render the fields to JSON.
   320  	m := jsonpb.Marshaler{}
   321  	buf := bytes.NewBuffer(nil)
   322  	if err := m.Marshal(buf, props); err != nil {
   323  		panic(err) // This shouldn't happen.
   324  	}
   325  	d := json.NewDecoder(buf)
   326  	jsonProps := map[string]json.RawMessage{}
   327  	if err := d.Decode(&jsonProps); err != nil {
   328  		panic(err) // This shouldn't happen.
   329  	}
   330  
   331  	// Sort the names.
   332  	names := make([]string, 0, len(jsonProps))
   333  	for n := range jsonProps {
   334  		names = append(names, n)
   335  	}
   336  	sort.Strings(names)
   337  
   338  	// Rearrange the fields into a slice.
   339  	results := make([]property, len(jsonProps))
   340  	for i, n := range names {
   341  		buf.Reset()
   342  		json.Indent(buf, jsonProps[n], "", "  ")
   343  		results[i] = property{
   344  			Name:  n,
   345  			Value: buf.String(),
   346  		}
   347  	}
   348  	return results
   349  }
   350  
   351  func (bp *BuildPage) InputProperties() []property {
   352  	return properties(bp.GetInput().GetProperties())
   353  }
   354  
   355  func (bp *BuildPage) OutputProperties() []property {
   356  	return properties(bp.GetOutput().GetProperties())
   357  }
   358  
   359  // BuilderLink returns a link to the builder in b.
   360  func (b *Build) BuilderLink() *Link {
   361  	if b.Builder == nil {
   362  		panic("Invalid build")
   363  	}
   364  	builder := b.Builder
   365  	return NewLink(
   366  		builder.Builder,
   367  		fmt.Sprintf("/p/%s/builders/%s/%s", builder.Project, builder.Bucket, builder.Builder),
   368  		fmt.Sprintf("Builder %s in bucket %s", builder.Builder, builder.Bucket))
   369  }
   370  
   371  // Link is a self link to the build.
   372  func (b *Build) Link() *Link {
   373  	if b.Builder == nil {
   374  		panic("invalid build")
   375  	}
   376  	num := b.Id
   377  	// Prefer build number below, but if using buildbucket ID
   378  	// a b prefix is needed on the buildbucket ID for it to work.
   379  	numStr := fmt.Sprintf("b%d", num)
   380  	if b.Number != 0 {
   381  		num = int64(b.Number)
   382  		numStr = strconv.FormatInt(num, 10)
   383  	}
   384  	builder := b.Builder
   385  	return NewLink(
   386  		fmt.Sprintf("%d", num),
   387  		fmt.Sprintf("/p/%s/builders/%s/%s/%s", builder.Project, builder.Bucket, builder.Builder, numStr),
   388  		fmt.Sprintf("Build %d", num))
   389  }
   390  
   391  // Banners returns names of icons to display next to the build number.
   392  // Currently displayed:
   393  // * OS, as determined by swarming dimensions.
   394  // TODO(hinoka): For device builders, display device type, and number of devices.
   395  func (b *Build) Banners() (result []Logo) {
   396  	var os, ver string
   397  	// A swarming dimension may have multiple values.  Eg.
   398  	// Linux, Ubuntu, Ubuntu-14.04.  We want the most specific one.
   399  	// The most specific one always comes last.
   400  	for _, dim := range b.GetInfra().GetSwarming().GetBotDimensions() {
   401  		if dim.Key != "os" {
   402  			continue
   403  		}
   404  		os = dim.Value
   405  		parts := strings.SplitN(os, "-", 2)
   406  		if len(parts) == 2 {
   407  			os = parts[0]
   408  			ver = parts[1]
   409  		}
   410  	}
   411  	var base LogoBase
   412  	switch os {
   413  	case "Ubuntu":
   414  		base = Ubuntu
   415  	case "Windows":
   416  		base = Windows
   417  	case "Mac":
   418  		base = OSX
   419  	case "Android":
   420  		base = Android
   421  	default:
   422  		return
   423  	}
   424  	return []Logo{{
   425  		LogoBase: base,
   426  		Subtitle: ver,
   427  		Count:    1,
   428  	}}
   429  }
   430  
   431  // StepDisplayPref is the display preference for the steps.
   432  type StepDisplayPref string
   433  
   434  const (
   435  	// StepDisplayDefault means that all steps are visible, green steps are
   436  	// collapsed.
   437  	StepDisplayDefault StepDisplayPref = "default"
   438  	// StepDisplayExpanded means that all steps are visible, nested steps are
   439  	// expanded.
   440  	StepDisplayExpanded StepDisplayPref = "expanded"
   441  	// StepDisplayNonGreen means that only non-green steps are visible, nested
   442  	// steps are expanded.
   443  	StepDisplayNonGreen StepDisplayPref = "non-green"
   444  )
   445  
   446  // Commit represents a single commit to a repository, rendered as part of a blamelist.
   447  type Commit struct {
   448  	// Who made the commit?
   449  	AuthorName string `json:"author_name,omitempty"`
   450  	// Email of the committer.
   451  	AuthorEmail string `json:"author_email,omitempty"`
   452  	// Time of the commit.
   453  	CommitTime time.Time `json:"commit_time,omitempty"`
   454  	// Full URL of the main source repository.
   455  	Repo string `json:"repo,omitempty"`
   456  	// Branch of the repo.
   457  	Branch string `json:"branch,omitempty"`
   458  	// Requested revision of the commit or base commit.
   459  	RequestRevision *Link `json:"request_revision,omitempty"`
   460  	// Revision of the commit or base commit.
   461  	Revision *Link `json:"revision,omitempty"`
   462  	// The commit message.
   463  	Description string `json:"description,omitempty"`
   464  	// Rietveld or Gerrit URL if the commit is a patch.
   465  	Changelist *Link `json:"changelist,omitempty"`
   466  	// Browsable URL of the commit.
   467  	CommitURL string `json:"commit_url,omitempty"`
   468  	// List of changed filenames.
   469  	File []string `json:"file,omitempty"`
   470  }
   471  
   472  // RevisionHTML returns a single rendered link for the revision, prioritizing
   473  // Revision over RequestRevision.
   474  func (c *Commit) RevisionHTML() template.HTML {
   475  	switch {
   476  	case c == nil:
   477  		return ""
   478  	case c.Revision != nil:
   479  		return c.Revision.HTML()
   480  	case c.RequestRevision != nil:
   481  		return c.RequestRevision.HTML()
   482  	default:
   483  		return ""
   484  	}
   485  }
   486  
   487  // Title is the first line of the commit message (Description).
   488  func (c *Commit) Title() string {
   489  	switch lines := strings.SplitN(c.Description, "\n", 2); len(lines) {
   490  	case 0:
   491  		return ""
   492  	case 1:
   493  		return c.Description
   494  	default:
   495  		return lines[0]
   496  	}
   497  }
   498  
   499  // DescLines returns the description as a slice, one line per item.
   500  func (c *Commit) DescLines() []string {
   501  	return strings.Split(c.Description, "\n")
   502  }
   503  
   504  // Timeline returns a JSON parsable string that can be fed into a viz timeline component.
   505  func (bp *BuildPage) Timeline() string {
   506  	// Return the cached version, if it exists already.
   507  	if bp.timelineData != "" {
   508  		return bp.timelineData
   509  	}
   510  
   511  	// stepData is extra data to deliver with the groups and items (see below) for the
   512  	// Javascript vis Timeline component. Note that the step data is encoded in markdown
   513  	// in the step.SummaryMarkdown field. We do not show this data on the timeline at this
   514  	// time.
   515  	type stepData struct {
   516  		Label           string `json:"label"`
   517  		Duration        string `json:"duration"`
   518  		LogURL          string `json:"logUrl"`
   519  		StatusClassName string `json:"statusClassName"`
   520  	}
   521  
   522  	// group corresponds to, and matches the shape of, a Group for the Javascript
   523  	// vis Timeline component http://visjs.org/docs/timeline/#groups. Data
   524  	// rides along as an extra property (unused by vis Timeline itself) used
   525  	// in client side rendering. Each Group is rendered as its own row in the
   526  	// timeline component on to which Items are rendered. Currently we only render
   527  	// one Item per Group, that is one thing per row.
   528  	type group struct {
   529  		ID   string   `json:"id"`
   530  		Data stepData `json:"data"`
   531  	}
   532  
   533  	// item corresponds to, and matches the shape of, an Item for the Javascript
   534  	// vis Timeline component http://visjs.org/docs/timeline/#items. Data
   535  	// rides along as an extra property (unused by vis Timeline itself) used
   536  	// in client side rendering. Each Item is rendered to a Group which corresponds
   537  	// to a row. Currently we only render one Item per Group, that is one thing per
   538  	// row.
   539  	type item struct {
   540  		ID        string   `json:"id"`
   541  		Group     string   `json:"group"`
   542  		Start     int64    `json:"start"`
   543  		End       int64    `json:"end"`
   544  		Type      string   `json:"type"`
   545  		ClassName string   `json:"className"`
   546  		Data      stepData `json:"data"`
   547  	}
   548  
   549  	now := bp.Now.AsTime()
   550  
   551  	groups := make([]group, len(bp.Build.Steps))
   552  	items := make([]item, len(bp.Build.Steps))
   553  	for i, step := range bp.Build.Steps {
   554  		groupID := strconv.Itoa(i)
   555  		logURL := ""
   556  		if len(step.Logs) > 0 {
   557  			logURL = html.EscapeString(step.Logs[0].ViewUrl)
   558  		}
   559  		statusClassName := fmt.Sprintf("status-%s", step.Status)
   560  		data := stepData{
   561  			Label:           html.EscapeString(step.Name),
   562  			Duration:        utils.Duration(step.StartTime, step.EndTime, bp.Now),
   563  			LogURL:          logURL,
   564  			StatusClassName: statusClassName,
   565  		}
   566  		groups[i] = group{groupID, data}
   567  		start := step.StartTime.AsTime()
   568  		end := step.EndTime.AsTime()
   569  		if end.IsZero() || end.Before(start) {
   570  			end = now
   571  		}
   572  		items[i] = item{
   573  			ID:        groupID,
   574  			Group:     groupID,
   575  			Start:     milliseconds(start),
   576  			End:       milliseconds(end),
   577  			Type:      "range",
   578  			ClassName: statusClassName,
   579  			Data:      data,
   580  		}
   581  	}
   582  
   583  	timeline, err := json.Marshal(map[string]any{
   584  		"groups": groups,
   585  		"items":  items,
   586  	})
   587  	if err != nil {
   588  		bp.Errors = append(bp.Errors, err)
   589  		return "error"
   590  	}
   591  	return string(timeline)
   592  }
   593  
   594  // milliseconds returns the given time in number of milliseconds elapsed since epoch.
   595  func milliseconds(time time.Time) int64 {
   596  	return time.UnixNano() / 1e6
   597  }
   598  
   599  /// HTML methods.
   600  
   601  var (
   602  	linkifyTemplate = template.Must(
   603  		template.New("linkify").
   604  			Parse(
   605  				`<a{{if .URL}} href="{{.URL}}"{{end}}` +
   606  					`{{if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}` +
   607  					`{{if .Alt}}{{if not .Img}} title="{{.Alt}}"{{end}}{{end}}>` +
   608  					`{{if .Img}}<img src="{{.Img}}"{{if .Alt}} alt="{{.Alt}}"{{end}}>` +
   609  					`{{else}}{{.Label}}{{end}}` +
   610  					`</a>`))
   611  
   612  	linkifySetTemplate = template.Must(
   613  		template.New("linkifySet").
   614  			Parse(
   615  				`{{ range $i, $link := . }}` +
   616  					`{{ if gt $i 0 }} {{ end }}` +
   617  					`{{ $link.HTML }}` +
   618  					`{{ end }}`))
   619  	newBuildPageOptInTemplate = template.Must(
   620  		template.New("buildOptIn").
   621  			Parse(`
   622  				<div id="opt-in-banner">
   623  					<div id="opt-in-link">
   624  						Switch to
   625  						<a
   626  							id="new-build-page-link"
   627  							{{if .Number}}
   628  								href="/ui/p/{{.Builder.Project}}/builders/{{.Builder.Bucket}}/{{.Builder.Builder}}/{{.Number}}"
   629  							{{else}}
   630  								href="/ui/p/{{.Builder.Project}}/builders/{{.Builder.Bucket}}/{{.Builder.Builder}}/b{{.Id}}"
   631  							{{end}}
   632  						>the new build page!</a>
   633  					</div>
   634  					<div id="feedback-bar">
   635  						Or <a id="feedback-link" href="/">tell us what's missing</a>.
   636  						[<a id="dismiss-feedback-bar" href="/">dismiss</a>]
   637  					</div>
   638  				</div>`))
   639  )
   640  
   641  // HTML renders this Link as HTML.
   642  func (l *Link) HTML() template.HTML {
   643  	if l == nil {
   644  		return ""
   645  	}
   646  	buf := bytes.Buffer{}
   647  	if err := linkifyTemplate.Execute(&buf, l); err != nil {
   648  		panic(err)
   649  	}
   650  	return template.HTML(buf.Bytes())
   651  }
   652  
   653  // String renders this Link's Label as a string.
   654  func (l *Link) String() string {
   655  	if l == nil {
   656  		return ""
   657  	}
   658  	return l.Label
   659  }
   660  
   661  // HTML renders this LinkSet as HTML.
   662  func (l LinkSet) HTML() template.HTML {
   663  	if len(l) == 0 {
   664  		return ""
   665  	}
   666  	buf := bytes.Buffer{}
   667  	if err := linkifySetTemplate.Execute(&buf, l); err != nil {
   668  		panic(err)
   669  	}
   670  	return template.HTML(buf.Bytes())
   671  }
   672  
   673  // NewBuildPageOptInHTML returns a link to the new build page of the build.
   674  func (b *Build) NewBuildPageOptInHTML() template.HTML {
   675  	buf := bytes.Buffer{}
   676  	if err := newBuildPageOptInTemplate.Execute(&buf, b); err != nil {
   677  		panic(err)
   678  	}
   679  	return template.HTML(buf.Bytes())
   680  }
   681  
   682  // Link denotes a single labeled link.
   683  //
   684  // JSON tags here are for test expectations.
   685  type Link struct {
   686  	model.Link
   687  
   688  	// AriaLabel is a spoken label for the link.  Used as aria-label under the anchor tag.
   689  	AriaLabel string `json:"aria_label,omitempty"`
   690  
   691  	// Img is an icon for the link.  Not compatible with label.  Rendered as <img>
   692  	Img string `json:"img,omitempty"`
   693  
   694  	// Alt text for the image, or title text with text link.
   695  	Alt string `json:"alt,omitempty"`
   696  }
   697  
   698  // NewLink does just about what you'd expect.
   699  func NewLink(label, url, ariaLabel string) *Link {
   700  	return &Link{Link: model.Link{Label: label, URL: url}, AriaLabel: ariaLabel}
   701  }
   702  
   703  // NewPatchLink generates a URL to a Gerrit CL.
   704  func NewPatchLink(cl *buildbucketpb.GerritChange) *Link {
   705  	return NewLink(
   706  		fmt.Sprintf("CL %d (ps#%d)", cl.Change, cl.Patchset),
   707  		protoutil.GerritChangeURL(cl),
   708  		fmt.Sprintf("gerrit changelist number %d patchset %d", cl.Change, cl.Patchset))
   709  }
   710  
   711  // NewEmptyLink creates a Link struct acting as a pure text label.
   712  func NewEmptyLink(label string) *Link {
   713  	return &Link{Link: model.Link{Label: label}}
   714  }
   715  
   716  // BuildPageData represents a build page on Milo.
   717  // Comparing to BuildPage, it caches a lot of the computed properties so they
   718  // be serialised to JSON.
   719  type BuildPageData struct {
   720  	*BuildPage
   721  	CommitLinkHTML          template.HTML   `json:"commit_link_html,omitempty"`
   722  	Summary                 []string        `json:"summary,omitempty"`
   723  	RecipeLink              *Link           `json:"recipe_link,omitempty"`
   724  	BuildbucketLink         *Link           `json:"buildbucket_link,omitempty"`
   725  	BuildSets               []string        `json:"build_sets,omitempty"`
   726  	BuildSetLinks           []template.HTML `json:"build_set_links,omitempty"`
   727  	Steps                   []*Step         `json:"steps,omitempty"`
   728  	HumanStatus             string          `json:"human_status,omitempty"`
   729  	ShouldShowCanaryWarning bool            `json:"should_show_canary_warning,omitempty"`
   730  	InputProperties         []property      `json:"input_properties,omitempty"`
   731  	OutputProperties        []property      `json:"output_properties,omitempty"`
   732  	BuilderLink             *Link           `json:"builder_link,omitempty"`
   733  	Link                    *Link           `json:"link,omitempty"`
   734  	Banners                 []Logo          `json:"banners,omitempty"`
   735  	Timeline                string          `json:"timeline,omitempty"`
   736  }