golang.org/x/build@v0.0.0-20240506185731-218518f32b70/maintner/maintnerd/maintapi/api.go (about)

     1  // Copyright 2017 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  // Package maintapi exposes a gRPC maintner service for a given corpus.
     6  package maintapi
     7  
     8  import (
     9  	"context"
    10  	"errors"
    11  	"fmt"
    12  	"log"
    13  	"net/url"
    14  	"regexp"
    15  	"sort"
    16  	"strings"
    17  	"sync"
    18  	"time"
    19  
    20  	"golang.org/x/build/gerrit"
    21  	"golang.org/x/build/maintner"
    22  	"golang.org/x/build/maintner/maintnerd/apipb"
    23  	"golang.org/x/build/maintner/maintnerd/maintapi/version"
    24  	"golang.org/x/build/repos"
    25  	"google.golang.org/grpc"
    26  	"google.golang.org/grpc/codes"
    27  )
    28  
    29  // NewAPIService creates a gRPC Server that serves the Maintner API for the given corpus.
    30  func NewAPIService(corpus *maintner.Corpus) apipb.MaintnerServiceServer {
    31  	return apiService{c: corpus}
    32  }
    33  
    34  // apiService implements apipb.MaintnerServiceServer using the Corpus c.
    35  type apiService struct {
    36  	// embed the unimplemented server.
    37  	apipb.UnsafeMaintnerServiceServer
    38  
    39  	c *maintner.Corpus
    40  	// There really shouldn't be any more fields here.
    41  	// All state should be in c.
    42  	// A bool like "in staging" should just be a global flag.
    43  }
    44  
    45  func (s apiService) HasAncestor(ctx context.Context, req *apipb.HasAncestorRequest) (*apipb.HasAncestorResponse, error) {
    46  	if len(req.Commit) != 40 {
    47  		return nil, errors.New("invalid Commit")
    48  	}
    49  	if len(req.Ancestor) != 40 {
    50  		return nil, errors.New("invalid Ancestor")
    51  	}
    52  	s.c.RLock()
    53  	defer s.c.RUnlock()
    54  
    55  	commit := s.c.GitCommit(req.Commit)
    56  	res := new(apipb.HasAncestorResponse)
    57  	if commit == nil {
    58  		// TODO: wait for it? kick off a fetch of it and then answer?
    59  		// optional?
    60  		res.UnknownCommit = true
    61  		return res, nil
    62  	}
    63  	if a := s.c.GitCommit(req.Ancestor); a != nil {
    64  		res.HasAncestor = commit.HasAncestor(a)
    65  	}
    66  	return res, nil
    67  }
    68  
    69  func isStagingCommit(cl *maintner.GerritCL) bool {
    70  	return cl.Commit != nil &&
    71  		strings.Contains(cl.Commit.Msg, "DO NOT SUBMIT") &&
    72  		strings.Contains(cl.Commit.Msg, "STAGING")
    73  }
    74  
    75  func tryBotStatus(cl *maintner.GerritCL, forStaging bool) (try, done bool) {
    76  	if cl.Commit == nil {
    77  		return // shouldn't happen
    78  	}
    79  	if forStaging != isStagingCommit(cl) {
    80  		return
    81  	}
    82  	for _, msg := range cl.Messages {
    83  		if msg.Version != cl.Version {
    84  			continue
    85  		}
    86  		firstLine := msg.Message
    87  		if nl := strings.IndexByte(firstLine, '\n'); nl != -1 {
    88  			firstLine = firstLine[:nl]
    89  		}
    90  		if !strings.Contains(firstLine, "TryBot") {
    91  			continue
    92  		}
    93  		if strings.Contains(firstLine, "Run-TryBot+1") {
    94  			try = true
    95  		}
    96  		if strings.Contains(firstLine, "-Run-TryBot") {
    97  			try = false
    98  		}
    99  		if strings.Contains(firstLine, "TryBot-Result") {
   100  			done = true
   101  		}
   102  	}
   103  	return
   104  }
   105  
   106  var tryCommentRx = regexp.MustCompile(`(?m)^TRY=(.*)$`)
   107  
   108  // tryWorkItem creates a GerritTryWorkItem for
   109  // the Gerrit CL specified by cl, ci, comments.
   110  //
   111  // goProj is the state of the main Go repository.
   112  // develVersion is the version of Go in development at HEAD of master branch.
   113  // supportedReleases are the supported Go releases per https://go.dev/doc/devel/release#policy.
   114  func tryWorkItem(
   115  	cl *maintner.GerritCL, ci *gerrit.ChangeInfo, comments map[string][]gerrit.CommentInfo,
   116  	goProj refer, develVersion apipb.MajorMinor, supportedReleases []*apipb.GoRelease,
   117  ) (*apipb.GerritTryWorkItem, error) {
   118  	w := &apipb.GerritTryWorkItem{
   119  		Project:     cl.Project.Project(),
   120  		Branch:      strings.TrimPrefix(cl.Branch(), "refs/heads/"),
   121  		ChangeId:    cl.ChangeID(),
   122  		Commit:      cl.Commit.Hash.String(),
   123  		AuthorEmail: cl.Owner().Email(),
   124  	}
   125  	if ci.CurrentRevision != "" {
   126  		// In case maintner is behind.
   127  		w.Commit = ci.CurrentRevision
   128  		w.Version = int32(ci.Revisions[ci.CurrentRevision].PatchSetNumber)
   129  	}
   130  
   131  	// Look for "TRY=" comments. Only consider messages that are accompanied
   132  	// by a Run-TryBot+1 vote, as a way of confirming the comment author has
   133  	// Trybot Access (see https://go.dev/wiki/GerritAccess#running-trybots-may-start-trybots).
   134  	for _, m := range ci.Messages {
   135  		// msg is like:
   136  		//   "Patch Set 2: Run-TryBot+1\n\n(1 comment)"
   137  		//   "Patch Set 2: Run-TryBot+1 Code-Review-2"
   138  		//   "Uploaded patch set 2."
   139  		//   "Removed Run-TryBot+1 by Brad Fitzpatrick <bradfitz@golang.org>\n"
   140  		//   "Patch Set 1: Run-TryBot+1\n\n(2 comments)"
   141  		if msg := m.Message; !strings.HasPrefix(msg, "Patch Set ") ||
   142  			!strings.Contains(firstLine(msg), "Run-TryBot+1") {
   143  			continue
   144  		}
   145  		// Get "TRY=foo" comments (just the "foo" part)
   146  		// from matching patchset-level comments. They
   147  		// are posted on the magic "/PATCHSET_LEVEL" path, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-id.
   148  		for _, c := range comments["/PATCHSET_LEVEL"] {
   149  			if !c.Updated.Equal(m.Time) || c.Author.NumericID != m.Author.NumericID {
   150  				// Not a matching time or author ID.
   151  				continue
   152  			}
   153  			if len(w.TryMessage) > 0 && m.RevisionNumber < int(w.TryMessage[len(w.TryMessage)-1].Version) {
   154  				// Don't include try messages older than the latest we've seen. They're obsolete.
   155  				continue
   156  			}
   157  			tm := tryCommentRx.FindStringSubmatch(c.Message)
   158  			if tm == nil {
   159  				continue
   160  			}
   161  			w.TryMessage = append(w.TryMessage, &apipb.TryVoteMessage{
   162  				Message:  tm[1],
   163  				AuthorId: c.Author.NumericID,
   164  				Version:  int32(m.RevisionNumber),
   165  			})
   166  		}
   167  	}
   168  
   169  	// Populate GoCommit, GoBranch, GoVersion fields
   170  	// according to what's being tested. Coordinator
   171  	// will use these to run corresponding tests.
   172  	if w.Project == "go" {
   173  		// TryBot on Go repo. Set the GoVersion field based on branch name.
   174  		if major, minor, ok := parseReleaseBranchVersion(w.Branch); ok {
   175  			// A release branch like release-branch.goX.Y.
   176  			// Use the major-minor Go version determined from the branch name.
   177  			w.GoVersion = []*apipb.MajorMinor{{Major: major, Minor: minor}}
   178  		} else {
   179  			// A branch that is not release-branch.goX.Y: maybe
   180  			// "master" or a development branch like "dev.link".
   181  			// There isn't a way to determine the version from its name,
   182  			// so use the development Go version until we need to do more.
   183  			// TODO(go.dev/issue/42376): This can be made more precise.
   184  			w.GoVersion = []*apipb.MajorMinor{&develVersion}
   185  		}
   186  	} else {
   187  		// TryBot on a subrepo.
   188  		if major, minor, ok := parseInternalBranchVersion(w.Branch); ok {
   189  			// An internal-branch.goX.Y-suffix branch is used for internal needs
   190  			// of goX.Y only, so no reason to test it on other Go versions.
   191  			goBranch := fmt.Sprintf("release-branch.go%d.%d", major, minor)
   192  			goCommit := goProj.Ref("refs/heads/" + goBranch)
   193  			if goCommit == "" {
   194  				return nil, fmt.Errorf("branch %q doesn't exist", goBranch)
   195  			}
   196  			w.GoCommit = []string{goCommit.String()}
   197  			w.GoBranch = []string{goBranch}
   198  			w.GoVersion = []*apipb.MajorMinor{{Major: major, Minor: minor}}
   199  		} else if w.Branch == "master" ||
   200  			w.Project == "tools" && strings.HasPrefix(w.Branch, "gopls-release-branch.") { // Issue 46156.
   201  
   202  			// For subrepos on the "master" branch and select branches that have opted in,
   203  			// use the default policy of testing it with Go tip and the supported releases.
   204  			w.GoCommit = []string{goProj.Ref("refs/heads/master").String()}
   205  			w.GoBranch = []string{"master"}
   206  			w.GoVersion = []*apipb.MajorMinor{&develVersion}
   207  			for _, r := range supportedReleases {
   208  				w.GoCommit = append(w.GoCommit, r.BranchCommit)
   209  				w.GoBranch = append(w.GoBranch, r.BranchName)
   210  				w.GoVersion = append(w.GoVersion, &apipb.MajorMinor{Major: r.Major, Minor: r.Minor})
   211  			}
   212  		} else {
   213  			// A branch that is neither internal-branch.goX.Y-suffix nor "master":
   214  			// maybe some custom branch like "dev.go2go".
   215  			// Test it against Go tip only until we want to do more.
   216  			w.GoCommit = []string{goProj.Ref("refs/heads/master").String()}
   217  			w.GoBranch = []string{"master"}
   218  			w.GoVersion = []*apipb.MajorMinor{&develVersion}
   219  		}
   220  	}
   221  
   222  	return w, nil
   223  }
   224  
   225  func firstLine(s string) string {
   226  	if nl := strings.Index(s, "\n"); nl < 0 {
   227  		return s
   228  	} else {
   229  		return s[:nl]
   230  	}
   231  }
   232  
   233  func (s apiService) GetRef(ctx context.Context, req *apipb.GetRefRequest) (*apipb.GetRefResponse, error) {
   234  	s.c.RLock()
   235  	defer s.c.RUnlock()
   236  	gp := s.c.Gerrit().Project(req.GerritServer, req.GerritProject)
   237  	if gp == nil {
   238  		return nil, errors.New("unknown gerrit project")
   239  	}
   240  	res := new(apipb.GetRefResponse)
   241  	hash := gp.Ref(req.Ref)
   242  	if hash != "" {
   243  		res.Value = hash.String()
   244  	}
   245  	return res, nil
   246  }
   247  
   248  var tryCache struct {
   249  	sync.Mutex
   250  	forNumChanges int       // number of label changes in project val is valid for
   251  	lastPoll      time.Time // of gerrit
   252  	val           *apipb.GoFindTryWorkResponse
   253  }
   254  
   255  var tryBotGerrit = gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
   256  
   257  func (s apiService) GoFindTryWork(ctx context.Context, req *apipb.GoFindTryWorkRequest) (*apipb.GoFindTryWorkResponse, error) {
   258  	tryCache.Lock()
   259  	defer tryCache.Unlock()
   260  
   261  	s.c.RLock()
   262  	defer s.c.RUnlock()
   263  
   264  	// Count the number of vote label changes over time. If it's
   265  	// the same as the last query, return a cached result without
   266  	// hitting Gerrit.
   267  	var sumChanges int
   268  	s.c.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
   269  		if gp.Server() != "go.googlesource.com" {
   270  			return nil
   271  		}
   272  		sumChanges += gp.NumLabelChanges()
   273  		return nil
   274  	})
   275  
   276  	now := time.Now()
   277  	const maxPollInterval = 15 * time.Second
   278  
   279  	if tryCache.val != nil &&
   280  		(tryCache.forNumChanges == sumChanges ||
   281  			tryCache.lastPoll.After(now.Add(-maxPollInterval))) {
   282  		return tryCache.val, nil
   283  	}
   284  
   285  	tryCache.lastPoll = now
   286  
   287  	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
   288  	defer cancel()
   289  
   290  	res, err := goFindTryWork(ctx, tryBotGerrit, s.c)
   291  	if err != nil {
   292  		log.Printf("maintnerd: goFindTryWork: %v", err)
   293  		return nil, err
   294  	}
   295  
   296  	tryCache.val = res
   297  	tryCache.forNumChanges = sumChanges
   298  
   299  	log.Printf("maintnerd: GetTryWork: for label changes of %d, cached %d trywork items.",
   300  		sumChanges, len(res.Waiting))
   301  
   302  	return res, nil
   303  }
   304  
   305  func goFindTryWork(ctx context.Context, gerritc *gerrit.Client, maintc *maintner.Corpus) (*apipb.GoFindTryWorkResponse, error) {
   306  	const query = "label:Run-TryBot=1 label:TryBot-Result=0 status:open"
   307  	cis, err := gerritc.QueryChanges(ctx, query, gerrit.QueryChangesOpt{
   308  		Fields: []string{"CURRENT_REVISION", "CURRENT_COMMIT", "MESSAGES", "DETAILED_ACCOUNTS"},
   309  	})
   310  	if err != nil {
   311  		return nil, err
   312  	}
   313  
   314  	goProj := maintc.Gerrit().Project("go.googlesource.com", "go")
   315  	supportedReleases, err := supportedGoReleases(goProj)
   316  	if err != nil {
   317  		return nil, err
   318  	}
   319  	// If Go X.Y is the latest supported release, the version in development is likely Go X.(Y+1).
   320  	// TODO(go.dev/issue/42376): This can be made more precise.
   321  	develVersion := apipb.MajorMinor{
   322  		Major: supportedReleases[0].Major,
   323  		Minor: supportedReleases[0].Minor + 1,
   324  	}
   325  
   326  	res := new(apipb.GoFindTryWorkResponse)
   327  	for _, ci := range cis {
   328  		proj := maintc.Gerrit().Project("go.googlesource.com", ci.Project)
   329  		if proj == nil {
   330  			log.Printf("nil Gerrit project %q", ci.Project)
   331  			continue
   332  		}
   333  		cl := proj.CL(int32(ci.ChangeNumber))
   334  		if cl == nil {
   335  			log.Printf("nil Gerrit CL %v", ci.ChangeNumber)
   336  			continue
   337  		}
   338  		// There are rare cases when the project~branch~Change-Id triplet doesn't
   339  		// uniquely identify a change, but project~numericId does. It's important
   340  		// we select the right and only one change in this context, so prefer the
   341  		// project~numericId identifier type. See go.dev/issue/43312 and
   342  		// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id.
   343  		changeID := fmt.Sprintf("%s~%d", url.PathEscape(ci.Project), ci.ChangeNumber)
   344  		comments, err := gerritc.ListChangeComments(ctx, changeID)
   345  		if err != nil {
   346  			return nil, fmt.Errorf("gerritc.ListChangeComments(ctx, %q): %v", changeID, err)
   347  		}
   348  		work, err := tryWorkItem(cl, ci, comments, goProj, develVersion, supportedReleases)
   349  		if err != nil {
   350  			log.Printf("goFindTryWork: skipping CL %v because %v\n", ci.ChangeNumber, err)
   351  			continue
   352  		}
   353  		res.Waiting = append(res.Waiting, work)
   354  	}
   355  
   356  	// Sort in some stable order. The coordinator's scheduler
   357  	// currently only uses the time the trybot run was requested,
   358  	// and not the commit time yet, but if two trybot runs are
   359  	// requested within the coordinator's poll interval, the
   360  	// earlier commit being first seems fair enough. Plus it's
   361  	// nice for interactive maintq queries to not have random
   362  	// orders.
   363  	sort.Slice(res.Waiting, func(i, j int) bool {
   364  		return res.Waiting[i].Commit < res.Waiting[j].Commit
   365  	})
   366  	return res, nil
   367  }
   368  
   369  // parseTagVersion parses the major-minor-patch version triplet
   370  // from goX, goX.Y, or goX.Y.Z tag names,
   371  // and reports whether the tag name is valid.
   372  //
   373  // Tags with suffixes like "go1.2beta3" or "go1.2rc1" are rejected.
   374  //
   375  // For example, "go1" is parsed as version 1.0.0,
   376  // "go1.2" is parsed as version 1.2.0,
   377  // and "go1.2.3" is parsed as version 1.2.3.
   378  func parseTagVersion(tagName string) (major, minor, patch int32, ok bool) {
   379  	maj, min, pat, ok := version.ParseTag(tagName)
   380  	return int32(maj), int32(min), int32(pat), ok
   381  }
   382  
   383  // parseReleaseBranchVersion parses the major-minor version pair
   384  // from release-branch.goX or release-branch.goX.Y release branch names,
   385  // and reports whether the release branch name is valid.
   386  //
   387  // For example, "release-branch.go1" is parsed as version 1.0,
   388  // and "release-branch.go1.2" is parsed as version 1.2.
   389  func parseReleaseBranchVersion(branchName string) (major, minor int32, ok bool) {
   390  	maj, min, ok := version.ParseReleaseBranch(branchName)
   391  	return int32(maj), int32(min), ok
   392  }
   393  
   394  // parseInternalBranchVersion parses the major-minor version pair
   395  // from internal-branch.goX-suffix or internal-branch.goX.Y-suffix internal branch names,
   396  // and reports whether the internal branch name is valid.
   397  //
   398  // For example, "internal-branch.go1-vendor" is parsed as version 1.0,
   399  // and "internal-branch.go1.2-vendor" is parsed as version 1.2.
   400  func parseInternalBranchVersion(branchName string) (major, minor int32, ok bool) {
   401  	const prefix = "internal-branch."
   402  	if !strings.HasPrefix(branchName, prefix) {
   403  		return 0, 0, false
   404  	}
   405  	tagAndSuffix := branchName[len(prefix):] // "go1.16-vendor".
   406  	i := strings.Index(tagAndSuffix, "-")
   407  	if i == -1 || i == len(tagAndSuffix)-1 {
   408  		// No "-suffix" at all, or empty suffix. Reject.
   409  		return 0, 0, false
   410  	}
   411  	tag := tagAndSuffix[:i] // "go1.16".
   412  	maj, min, pat, ok := version.ParseTag(tag)
   413  	if !ok || pat != 0 {
   414  		// Not a major Go release tag. Reject.
   415  		return 0, 0, false
   416  	}
   417  	return int32(maj), int32(min), true
   418  }
   419  
   420  // ListGoReleases lists Go releases. A release is considered to exist
   421  // if a tag for it exists.
   422  func (s apiService) ListGoReleases(ctx context.Context, req *apipb.ListGoReleasesRequest) (*apipb.ListGoReleasesResponse, error) {
   423  	s.c.RLock()
   424  	defer s.c.RUnlock()
   425  	goProj := s.c.Gerrit().Project("go.googlesource.com", "go")
   426  	releases, err := supportedGoReleases(goProj)
   427  	if err != nil {
   428  		return nil, err
   429  	}
   430  	return &apipb.ListGoReleasesResponse{
   431  		Releases: releases,
   432  	}, nil
   433  }
   434  
   435  // refer is implemented by *maintner.GerritProject,
   436  // or something that acts like it for testing.
   437  type refer interface {
   438  	// Ref returns a non-change ref, such as "HEAD", "refs/heads/master",
   439  	// or "refs/tags/v0.8.0",
   440  	// Change refs of the form "refs/changes/*" are not supported.
   441  	// The returned hash is the zero value (an empty string) if the ref
   442  	// does not exist.
   443  	Ref(ref string) maintner.GitHash
   444  }
   445  
   446  // nonChangeRefLister is implemented by *maintner.GerritProject,
   447  // or something that acts like it for testing.
   448  type nonChangeRefLister interface {
   449  	// ForeachNonChangeRef calls fn for each git ref on the server that is
   450  	// not a change (code review) ref. In general, these correspond to
   451  	// submitted changes. fn is called serially with sorted ref names.
   452  	// Iteration stops with the first non-nil error returned by fn.
   453  	ForeachNonChangeRef(fn func(ref string, hash maintner.GitHash) error) error
   454  }
   455  
   456  // supportedGoReleases returns the latest patches of releases that are
   457  // considered supported per policy. Sorted by version with latest first.
   458  // The returned list will be empty if and only if the error is non-nil.
   459  func supportedGoReleases(goProj nonChangeRefLister) ([]*apipb.GoRelease, error) {
   460  	type majorMinor struct {
   461  		Major, Minor int32
   462  	}
   463  	type tag struct {
   464  		Patch  int32
   465  		Name   string
   466  		Commit maintner.GitHash
   467  	}
   468  	type branch struct {
   469  		Name   string
   470  		Commit maintner.GitHash
   471  	}
   472  	tags := make(map[majorMinor]tag)
   473  	branches := make(map[majorMinor]branch)
   474  
   475  	// Iterate over Go tags and release branches. Find the latest patch
   476  	// for each major-minor pair, and fill in the appropriate fields.
   477  	err := goProj.ForeachNonChangeRef(func(ref string, hash maintner.GitHash) error {
   478  		switch {
   479  		case strings.HasPrefix(ref, "refs/tags/go"):
   480  			// Tag.
   481  			tagName := ref[len("refs/tags/"):]
   482  			major, minor, patch, ok := parseTagVersion(tagName)
   483  			if !ok {
   484  				return nil
   485  			}
   486  			if t, ok := tags[majorMinor{major, minor}]; ok && patch <= t.Patch {
   487  				// This patch version is not newer than what we've already seen, skip it.
   488  				return nil
   489  			}
   490  			tags[majorMinor{major, minor}] = tag{
   491  				Patch:  patch,
   492  				Name:   tagName,
   493  				Commit: hash,
   494  			}
   495  
   496  		case strings.HasPrefix(ref, "refs/heads/release-branch.go"):
   497  			// Release branch.
   498  			branchName := ref[len("refs/heads/"):]
   499  			major, minor, ok := parseReleaseBranchVersion(branchName)
   500  			if !ok {
   501  				return nil
   502  			}
   503  			branches[majorMinor{major, minor}] = branch{
   504  				Name:   branchName,
   505  				Commit: hash,
   506  			}
   507  		}
   508  		return nil
   509  	})
   510  	if err != nil {
   511  		return nil, err
   512  	}
   513  
   514  	// A release is considered to exist for each git tag named "goX", "goX.Y", or "goX.Y.Z",
   515  	// as long as it has a corresponding "release-branch.goX" or "release-branch.goX.Y" release branch.
   516  	var rs []*apipb.GoRelease
   517  	for v, t := range tags {
   518  		b, ok := branches[v]
   519  		if !ok {
   520  			// In the unlikely case a tag exists but there's no release branch for it,
   521  			// don't consider it a release. This way, callers won't have to do this work.
   522  			continue
   523  		}
   524  		rs = append(rs, &apipb.GoRelease{
   525  			Major:        v.Major,
   526  			Minor:        v.Minor,
   527  			Patch:        t.Patch,
   528  			TagName:      t.Name,
   529  			TagCommit:    t.Commit.String(),
   530  			BranchName:   b.Name,
   531  			BranchCommit: b.Commit.String(),
   532  		})
   533  	}
   534  
   535  	// Sort by version. Latest first.
   536  	sort.Slice(rs, func(i, j int) bool {
   537  		x1, y1, z1 := rs[i].Major, rs[i].Minor, rs[i].Patch
   538  		x2, y2, z2 := rs[j].Major, rs[j].Minor, rs[j].Patch
   539  		if x1 != x2 {
   540  			return x1 > x2
   541  		}
   542  		if y1 != y2 {
   543  			return y1 > y2
   544  		}
   545  		return z1 > z2
   546  	})
   547  
   548  	// Per policy, only the latest two releases are considered supported.
   549  	// Return an error if there aren't at least two releases, so callers
   550  	// don't have to check for empty list.
   551  	if len(rs) < 2 {
   552  		return nil, fmt.Errorf("there was a problem finding supported Go releases")
   553  	}
   554  	return rs[:2], nil
   555  }
   556  
   557  func (s apiService) GetDashboard(ctx context.Context, req *apipb.DashboardRequest) (*apipb.DashboardResponse, error) {
   558  	s.c.RLock()
   559  	defer s.c.RUnlock()
   560  
   561  	res := new(apipb.DashboardResponse)
   562  	goProj := s.c.Gerrit().Project("go.googlesource.com", "go")
   563  	if goProj == nil {
   564  		// Return a normal error here, without grpc code
   565  		// NotFound, because we expect to find this.
   566  		return nil, errors.New("go gerrit project not found")
   567  	}
   568  	if req.Repo == "" {
   569  		req.Repo = "go"
   570  	}
   571  	projName, err := dashRepoToGerritProj(req.Repo)
   572  	if err != nil {
   573  		return nil, err
   574  	}
   575  	proj := s.c.Gerrit().Project("go.googlesource.com", projName)
   576  	if proj == nil {
   577  		return nil, grpc.Errorf(codes.NotFound, "repo project %q not found", projName)
   578  	}
   579  
   580  	// Populate res.Branches.
   581  	const headPrefix = "refs/heads/"
   582  	refHash := map[string]string{} // "master" -> git commit hash
   583  	goProj.ForeachNonChangeRef(func(ref string, hash maintner.GitHash) error {
   584  		if !strings.HasPrefix(ref, headPrefix) {
   585  			return nil
   586  		}
   587  		branch := strings.TrimPrefix(ref, headPrefix)
   588  		refHash[branch] = hash.String()
   589  		res.Branches = append(res.Branches, branch)
   590  		return nil
   591  	})
   592  
   593  	if req.Branch == "" {
   594  		req.Branch = "master"
   595  	}
   596  	branch := req.Branch
   597  	mixBranches := branch == "mixed" // mix all branches together, by commit time
   598  	if !mixBranches && refHash[branch] == "" {
   599  		return nil, grpc.Errorf(codes.NotFound, "unknown branch %q", branch)
   600  	}
   601  
   602  	commitsPerPage := int(req.MaxCommits)
   603  	if commitsPerPage < 0 {
   604  		return nil, grpc.Errorf(codes.InvalidArgument, "negative max commits")
   605  	}
   606  	if commitsPerPage > 1000 {
   607  		commitsPerPage = 1000
   608  	}
   609  	if commitsPerPage == 0 {
   610  		if mixBranches {
   611  			commitsPerPage = 500
   612  		} else {
   613  			commitsPerPage = 30 // what build.golang.org historically used
   614  		}
   615  	}
   616  	if mixBranches && commitsPerPage < len(res.Branches) {
   617  		return nil, grpc.Errorf(codes.InvalidArgument, "page size too small for `mixed`: %v < %v", commitsPerPage, len(res.Branches))
   618  	}
   619  
   620  	if req.Page < 0 {
   621  		return nil, grpc.Errorf(codes.InvalidArgument, "invalid page")
   622  	}
   623  	if req.Page != 0 && mixBranches {
   624  		return nil, grpc.Errorf(codes.InvalidArgument, "branch=mixed does not support pagination")
   625  	}
   626  	skip := int(req.Page) * commitsPerPage
   627  	if skip >= 10000 {
   628  		return nil, grpc.Errorf(codes.InvalidArgument, "too far back") // arbitrary
   629  	}
   630  
   631  	// Find branches to merge together.
   632  	//
   633  	// By default we only have one branch (the one the user
   634  	// specified). But in mixed mode, as used by the coordinator
   635  	// when trying to find work to do, we merge all the branches
   636  	// together into one timeline.
   637  	branches := []string{branch}
   638  	if mixBranches {
   639  		branches = res.Branches
   640  	}
   641  	var oldestSkipped time.Time
   642  	res.Commits, res.CommitsTruncated, oldestSkipped = s.listDashCommits(proj, branches, commitsPerPage, skip)
   643  
   644  	// For non-go repos, populate the Go commits that corresponding to each commit.
   645  	if projName != "go" {
   646  		s.addGoCommits(oldestSkipped, res.Commits)
   647  	}
   648  
   649  	// Populate res.RepoHeads: each Gerrit repo with what its
   650  	// current master ref is at.
   651  	res.RepoHeads = s.dashRepoHeads()
   652  
   653  	// Populate res.Releases (the currently supported releases)
   654  	// with "master" followed by the past two release branches.
   655  	res.Releases = append(res.Releases, &apipb.GoRelease{
   656  		BranchName:   "master",
   657  		BranchCommit: refHash["master"],
   658  	})
   659  	releases, err := supportedGoReleases(goProj)
   660  	if err != nil {
   661  		return nil, err
   662  	}
   663  	res.Releases = append(res.Releases, releases...)
   664  
   665  	return res, nil
   666  }
   667  
   668  // listDashCommits merges together the commits in the provided
   669  // branches, sorted by commit time (newest first), skipping skip
   670  // items, and stopping after commitsPerPage items.
   671  // If len(branches) > 1, then skip must be zero.
   672  //
   673  // It returns the commits, whether more would follow on a later page,
   674  // and the oldest skipped commit, if any.
   675  func (s apiService) listDashCommits(proj *maintner.GerritProject, branches []string, commitsPerPage, skip int) (commits []*apipb.DashCommit, truncated bool, oldestSkipped time.Time) {
   676  	mixBranches := len(branches) > 1
   677  	if mixBranches && skip > 0 {
   678  		panic("unsupported skip in mixed mode")
   679  	}
   680  	// oldestItem is the oldest item on the page. It's used to
   681  	// stop iteration early on the 2nd and later branches when
   682  	// len(branches) > 1.
   683  	var oldestItem time.Time
   684  	for _, branch := range branches {
   685  		gh := proj.Ref("refs/heads/" + branch)
   686  		if gh == "" {
   687  			continue
   688  		}
   689  		skipped := 0
   690  		var add []*apipb.DashCommit
   691  		iter := s.gitLogIter(gh)
   692  		for len(add) < commitsPerPage && iter.HasNext() {
   693  			c := iter.Take()
   694  			if c.CommitTime.Before(oldestItem) {
   695  				break
   696  			}
   697  			if skipped >= skip {
   698  				dc := dashCommit(c)
   699  				dc.Branch = branch
   700  				add = append(add, dc)
   701  			} else {
   702  				skipped++
   703  				oldestSkipped = c.CommitTime
   704  			}
   705  		}
   706  		commits = append(commits, add...)
   707  		if !mixBranches {
   708  			truncated = iter.HasNext()
   709  			break
   710  		}
   711  
   712  		sort.Slice(commits, func(i, j int) bool {
   713  			return commits[i].CommitTimeSec > commits[j].CommitTimeSec
   714  		})
   715  		if len(commits) > commitsPerPage {
   716  			commits = commits[:commitsPerPage]
   717  			truncated = true
   718  		}
   719  		if len(commits) > 0 {
   720  			oldestItem = time.Unix(commits[len(commits)-1].CommitTimeSec, 0)
   721  		}
   722  	}
   723  	return commits, truncated, oldestSkipped
   724  }
   725  
   726  // addGoCommits populates each commit's GoCommitAtTime and
   727  // GoCommitLatest values. for the oldest and newest corresponding "go"
   728  // repo commits, respectively. That way there's at least one
   729  // associated Go commit (even if empty) on the dashboard when viewing
   730  // https://build.golang.org/?repo=golang.org/x/net.
   731  //
   732  // The provided commits must be from most recent to oldest. The
   733  // oldestSkipped should be the oldest commit time that's on the page
   734  // prior to commits, or the zero value for the first (newest) page.
   735  //
   736  // The maintner corpus must be read-locked.
   737  func (s apiService) addGoCommits(oldestSkipped time.Time, commits []*apipb.DashCommit) {
   738  	if len(commits) == 0 {
   739  		return
   740  	}
   741  	goProj := s.c.Gerrit().Project("go.googlesource.com", "go")
   742  	if goProj == nil {
   743  		// Shouldn't happen, except in tests with
   744  		// an empty maintner corpus.
   745  		return
   746  	}
   747  	// Find the oldest (last) commit.
   748  	oldestX := time.Unix(commits[len(commits)-1].CommitTimeSec, 0)
   749  
   750  	// Collect enough goCommits going back far enough such that we have one that's older
   751  	// than the oldest repo item on the page.
   752  	var goCommits []*maintner.GitCommit // newest to oldest
   753  	lastGoHash := func() string {
   754  		if len(goCommits) == 0 {
   755  			return ""
   756  		}
   757  		return goCommits[len(goCommits)-1].Hash.String()
   758  	}
   759  
   760  	goIter := s.gitLogIter(goProj.Ref("refs/heads/master"))
   761  	for goIter.HasNext() {
   762  		c := goIter.Take()
   763  		goCommits = append(goCommits, c)
   764  		if c.CommitTime.Before(oldestX) {
   765  			break
   766  		}
   767  	}
   768  
   769  	for i := len(commits) - 1; i >= 0; i-- { // walk from oldest to newest
   770  		dc := commits[i]
   771  		var maxGoAge time.Time
   772  		if i == 0 {
   773  			maxGoAge = oldestSkipped
   774  		} else {
   775  			maxGoAge = time.Unix(commits[i-1].CommitTimeSec, 0)
   776  		}
   777  		dc.GoCommitAtTime = lastGoHash()
   778  		for len(goCommits) >= 2 && goCommits[len(goCommits)-2].CommitTime.Before(maxGoAge) {
   779  			goCommits = goCommits[:len(goCommits)-1]
   780  		}
   781  		dc.GoCommitLatest = lastGoHash()
   782  	}
   783  }
   784  
   785  // dashRepoHeads returns the DashRepoHead for each Gerrit project on
   786  // the go.googlesource.com server.
   787  func (s apiService) dashRepoHeads() (heads []*apipb.DashRepoHead) {
   788  	s.c.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
   789  		if gp.Server() != "go.googlesource.com" {
   790  			return nil
   791  		}
   792  		gh := gp.Ref("refs/heads/master")
   793  		if gh == "" {
   794  			return nil
   795  		}
   796  		c, err := gp.GitCommit(gh.String())
   797  		if err != nil {
   798  			// In theory we could ignore this error to produce best-effort results for
   799  			// the remaining projects. However, we expect as an invariant that the
   800  			// head commit for each project always exists. If it ever doesn't,
   801  			// something is deeply wrong with the project state and should be
   802  			// investigated, and surfacing the error makes it more likely to be
   803  			// investigated and fixed soon after a regression or corruption occurs
   804  			// (instead of at an arbitrarily later date).
   805  			return err
   806  		}
   807  		heads = append(heads, &apipb.DashRepoHead{
   808  			GerritProject: gp.Project(),
   809  			Commit:        dashCommit(c),
   810  		})
   811  		return nil
   812  	})
   813  	sort.Slice(heads, func(i, j int) bool {
   814  		return heads[i].GerritProject < heads[j].GerritProject
   815  	})
   816  	return
   817  }
   818  
   819  // gitLogIter is a git log iterator.
   820  type gitLogIter struct {
   821  	corpus *maintner.Corpus
   822  	nexth  maintner.GitHash
   823  	nextc  *maintner.GitCommit // lazily looked up
   824  }
   825  
   826  // HasNext reports whether there's another commit to be seen.
   827  func (i *gitLogIter) HasNext() bool {
   828  	if i.nextc == nil {
   829  		if i.nexth == "" {
   830  			return false
   831  		}
   832  		i.nextc = i.corpus.GitCommit(i.nexth.String())
   833  	}
   834  	return i.nextc != nil
   835  }
   836  
   837  // Take returns the next commit (or nil if none remains) and advances past it.
   838  func (i *gitLogIter) Take() *maintner.GitCommit {
   839  	if !i.HasNext() {
   840  		return nil
   841  	}
   842  	ret := i.nextc
   843  	i.nextc = nil
   844  	if len(ret.Parents) == 0 {
   845  		i.nexth = ""
   846  	} else {
   847  		// TODO: care about returning the history from both
   848  		// sides of merge commits? Go has a linear history for
   849  		// the most part so punting for now. I think the old
   850  		// build.golang.org datastore model got confused by
   851  		// this too. In any case, this is like:
   852  		//    git log --first-parent.
   853  		i.nexth = ret.Parents[0].Hash
   854  	}
   855  	return ret
   856  }
   857  
   858  // Peek returns the next commit (or nil if none remains) without advancing past it.
   859  // The next call to Peek or Take will return it again.
   860  func (i *gitLogIter) Peek() *maintner.GitCommit {
   861  	if i.HasNext() {
   862  		// HasNext guarantees that it populates i.nextc.
   863  		return i.nextc
   864  	}
   865  	return nil
   866  }
   867  
   868  func (s apiService) gitLogIter(start maintner.GitHash) *gitLogIter {
   869  	return &gitLogIter{
   870  		corpus: s.c,
   871  		nexth:  start,
   872  	}
   873  }
   874  
   875  func dashCommit(c *maintner.GitCommit) *apipb.DashCommit {
   876  	return &apipb.DashCommit{
   877  		Commit:        c.Hash.String(),
   878  		CommitTimeSec: c.CommitTime.Unix(),
   879  		AuthorName:    c.Author.Name(),
   880  		AuthorEmail:   c.Author.Email(),
   881  		Title:         c.Summary(),
   882  	}
   883  }
   884  
   885  // dashRepoToGerritProj maps a DashboardRequest.repo value to
   886  // a go.googlesource.com Gerrit project name.
   887  func dashRepoToGerritProj(repo string) (proj string, err error) {
   888  	if repo == "go" || repo == "" {
   889  		return "go", nil
   890  	}
   891  	ri, ok := repos.ByImportPath[repo]
   892  	if !ok || ri.GoGerritProject == "" {
   893  		return "", grpc.Errorf(codes.NotFound, `unknown repo %q; must be empty, "go", or "golang.org/*"`, repo)
   894  	}
   895  	return ri.GoGerritProject, nil
   896  }