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 }