blob: 1f298733d0e9a36af71cf138f0ca342501268860 [file] [log] [blame]
// Copyright 2022 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package dash reads build.golang.org's dashboards.
package dash
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"sync"
"time"
)
// A Board is a single dashboard.
type Board struct {
Repo string // repo being displayed: "go", "arch", and so on
Branch string // branch in repo
Builders []string // builder columns
Revisions []*Line // commit lines, newest to oldest
}
// A Line is a single commit line on a Board b.
type Line struct {
Repo string // same as b.Repo
Branch string // same as b.Branch
Revision string // revision of Repo
GoRevision string // for Repo != "go", revision of go repo being used
GoBranch string // for Repo != "go", branch of go repo being used
Date time.Time // date of commit
Author string // author of commit
Desc string // commit description
// // Results[i] reports b.Builders[i]'s result:
// "" (not run), "ok" (passed), or the URL of the failure log
// ("https://build.golang.org/log/...")
Results []string
}
// Read reads and returns all the dashboards on build.golang.org
// (for the main repo, the main repo release branches, and subrepos),
// including all results up to the given time limit.
// It guarantees that all the returned boards will have the same b.Builders slices,
// so that any line.Results[i] even for different boards refers to a consistent
// builder for a given i.
func Read(limit time.Time) ([]*Board, error) {
return Update(nil, limit)
}
// Update is like Read but takes a starting set of boards from
// a previous call to Read or Update and avoids redownloading
// information from those boards.
// It does not modify the boards passed in as input.
func Update(old []*Board, limit time.Time) ([]*Board, error) {
// Read the front page to derive the Go repo branches and subrepos.
_, goBranches, repos, err := readPage("", "", 0)
if err != nil {
return nil, err
}
repos = append([]string{"go"}, repos...)
// Build cache of existing boards.
type key struct{ repo, branch string }
cache := make(map[key]*Board)
for _, b := range old {
cache[key{b.Repo, b.Branch}] = b
}
// For each repo and branch, fetch that repo's list of board pages.
var boards []*Board
var errors []error
var wg sync.WaitGroup
for _, r := range repos {
r := r
branches := []string{""}
if r == "go" {
branches = goBranches
}
for _, branch := range branches {
branch := branch
if branch == "master" || branch == "main" {
branch = ""
}
// Only read up to what we already have in old, respecting limit.
old := cache[key{r, branch}]
oldLimit := limit
if old != nil && len(old.Revisions) > 0 && old.Revisions[0].Date.After(limit) {
oldLimit = old.Revisions[0].Date
}
i := len(boards)
boards = append(boards, nil)
errors = append(errors, nil)
wg.Add(1)
go func() {
defer wg.Done()
boards[i], errors[i] = readRepo(r, branch, oldLimit)
if errors[i] == nil {
boards[i] = update(boards[i], old, limit)
}
}()
}
}
wg.Wait()
for _, err := range errors {
if err != nil {
return nil, err
}
}
// Remap all the boards to have a consistent Builders array.
// It is slightly inefficient that readRepo does this remap as well,
// but all the downloads take more time.
remap(boards)
return boards, nil
}
// update returns the result of merging b and old,
// discarding revisions older than limit and removing duplicates.
// It modifies b but not old.
func update(b, old *Board, limit time.Time) *Board {
if old == nil || !same(b.Builders, old.Builders) {
if old == nil {
old = new(Board)
} else {
old = old.clone()
}
remap([]*Board{b, old})
}
type key struct {
rev string
gorev string
}
have := make(map[key]bool)
keep := b.Revisions[:0]
for _, list := range [][]*Line{b.Revisions, old.Revisions} {
for _, r := range list {
if !r.Date.Before(limit) && !have[key{r.Revision, r.GoRevision}] {
have[key{r.Revision, r.GoRevision}] = true
keep = append(keep, r)
}
}
}
b.Revisions = keep
return b
}
// clone returns a deep copy of b.
func (b *Board) clone() *Board {
b1 := &Board{
Repo: b.Repo,
Branch: b.Branch,
Builders: make([]string, len(b.Builders)),
Revisions: make([]*Line, len(b.Revisions)),
}
copy(b1.Builders, b.Builders)
for i := range b1.Revisions {
r := new(Line)
*r = *b.Revisions[i]
results := make([]string, len(r.Results))
copy(results, r.Results)
r.Results = results
b1.Revisions[i] = r
}
return b1
}
// readRepo reads and returns the pages for the given repo and branch,
// stopping when it finds a page that contains no results newer than limit.
func readRepo(repo, branch string, limit time.Time) (*Board, error) {
path := ""
if repo != "go" {
path = "golang.org/x/" + repo
}
var pages []*Board
for page := 0; ; page++ {
b, _, _, err := readPage(path, branch, page)
if err != nil {
return merge(pages), err
}
// If there's nothing new enough on the whole page, stop.
keep := b.Revisions[:0]
for _, r := range b.Revisions {
if !r.Date.Before(limit) {
keep = append(keep, r)
}
}
if len(keep) == 0 {
break
}
b.Revisions = keep
b.Repo = repo
b.Branch = branch
pages = append(pages, b)
}
return merge(pages), nil
}
// merge merges all the pages into a single board.
func merge(pages []*Board) *Board {
if len(pages) == 0 {
return new(Board)
}
remap(pages)
for _, b := range pages {
if !same(b.Builders, pages[0].Builders) || b.Repo != pages[0].Repo || b.Branch != pages[0].Branch {
panic("misuse of merge")
}
}
merged := &Board{Repo: pages[0].Repo, Branch: pages[0].Branch, Builders: pages[0].Builders}
for _, b := range pages {
merged.Revisions = append(merged.Revisions, b.Revisions...)
}
return merged
}
// remap remaps all the results in all the boards
// to use a consistent set of Builders.
func remap(boards []*Board) {
// Collect list of all builders across all boards.
var builders []string
index := make(map[string]int)
for _, b := range boards {
for _, builder := range b.Builders {
if index[builder] == 0 {
index[builder] = 1
builders = append(builders, builder)
}
}
}
sort.Strings(builders)
for i, builder := range builders {
index[builder] = i
}
// Remap.
for _, b := range boards {
for _, r := range b.Revisions {
results := make([]string, len(builders))
for i, ok := range r.Results {
results[index[b.Builders[i]]] = ok
}
r.Results = results
}
b.Builders = builders
}
}
// readPage reads the build.golang.org page for repo, branch.
// It returns the board on that page.
// When repo == "go" and branch == "" and page == 0,
// build.golang.org also sends back information about the
// other go repo branches and the subrepos.
// readPage("go", "", 0) returns those lists of go branches
// and subrepos as extra results.
func readPage(repo, branch string, page int) (b *Board, branches, repos []string, err error) {
if repo == "" {
repo = "go"
}
u := "https://build.golang.org/?mode=json&repo=" + url.QueryEscape(repo) + "&branch=" + url.QueryEscape(branch) + "&page=" + fmt.Sprint(page)
log.Printf("read %v", u)
resp, err := http.Get(u)
if err != nil {
return nil, nil, nil, fmt.Errorf("%s page %d: %v", repo, page, err)
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, nil, nil, fmt.Errorf("%s page %d: %v", repo, page, err)
}
if resp.StatusCode != 200 {
return nil, nil, nil, fmt.Errorf("%s page %d: %s\n%s", repo, page, resp.Status, data)
}
b = new(Board)
if err := json.Unmarshal(data, b); err != nil {
return nil, nil, nil, fmt.Errorf("%s page %d: %v", repo, page, err)
}
// Use empty string consistently to denote master/main branch.
for _, r := range b.Revisions {
if r.Branch == "master" || r.Branch == "main" {
r.Branch = ""
}
if r.GoBranch == "master" || r.GoBranch == "main" {
r.GoBranch = ""
}
}
// https://build.golang.org/?mode=json (main repo, no branch, page 0)
// sends back a bit about the subrepos too. Filter that out.
if repo == "go" {
var save []*Line
for _, r := range b.Revisions {
if r.Repo == "go" {
save = append(save, r)
} else {
branches = append(branches, r.GoBranch)
repos = append(repos, r.Repo)
}
}
b.Revisions = save
branches = uniq(branches)
repos = uniq(repos)
}
return b, branches, repos, nil
}
// same reports whether x and y are the same slice.
func same(x, y []string) bool {
if len(x) != len(y) {
return false
}
for i, s := range x {
if y[i] != s {
return false
}
}
return true
}
// uniq sorts and removes duplicates from list, returning the result.
// uniq reuses list's storage for its result.
func uniq(list []string) []string {
sort.Strings(list)
keep := list[:0]
for _, s := range list {
if len(keep) == 0 || s != keep[len(keep)-1] {
keep = append(keep, s)
}
}
return keep
}