| // Copyright 2017 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 maintapi exposes a gRPC maintner service for a given corpus. |
| package maintapi |
| |
| import ( |
| "context" |
| "errors" |
| "fmt" |
| "log" |
| "net/url" |
| "regexp" |
| "sort" |
| "strings" |
| "sync" |
| "time" |
| |
| "golang.org/x/build/gerrit" |
| "golang.org/x/build/maintner" |
| "golang.org/x/build/maintner/maintnerd/apipb" |
| "golang.org/x/build/maintner/maintnerd/maintapi/version" |
| "golang.org/x/build/repos" |
| "google.golang.org/grpc" |
| "google.golang.org/grpc/codes" |
| ) |
| |
| // NewAPIService creates a gRPC Server that serves the Maintner API for the given corpus. |
| func NewAPIService(corpus *maintner.Corpus) apipb.MaintnerServiceServer { |
| return apiService{c: corpus} |
| } |
| |
| // apiService implements apipb.MaintnerServiceServer using the Corpus c. |
| type apiService struct { |
| // embed the unimplemented server. |
| apipb.UnsafeMaintnerServiceServer |
| |
| c *maintner.Corpus |
| // There really shouldn't be any more fields here. |
| // All state should be in c. |
| // A bool like "in staging" should just be a global flag. |
| } |
| |
| func (s apiService) HasAncestor(ctx context.Context, req *apipb.HasAncestorRequest) (*apipb.HasAncestorResponse, error) { |
| if len(req.Commit) != 40 { |
| return nil, errors.New("invalid Commit") |
| } |
| if len(req.Ancestor) != 40 { |
| return nil, errors.New("invalid Ancestor") |
| } |
| s.c.RLock() |
| defer s.c.RUnlock() |
| |
| commit := s.c.GitCommit(req.Commit) |
| res := new(apipb.HasAncestorResponse) |
| if commit == nil { |
| // TODO: wait for it? kick off a fetch of it and then answer? |
| // optional? |
| res.UnknownCommit = true |
| return res, nil |
| } |
| if a := s.c.GitCommit(req.Ancestor); a != nil { |
| res.HasAncestor = commit.HasAncestor(a) |
| } |
| return res, nil |
| } |
| |
| func isStagingCommit(cl *maintner.GerritCL) bool { |
| return cl.Commit != nil && |
| strings.Contains(cl.Commit.Msg, "DO NOT SUBMIT") && |
| strings.Contains(cl.Commit.Msg, "STAGING") |
| } |
| |
| func tryBotStatus(cl *maintner.GerritCL, forStaging bool) (try, done bool) { |
| if cl.Commit == nil { |
| return // shouldn't happen |
| } |
| if forStaging != isStagingCommit(cl) { |
| return |
| } |
| for _, msg := range cl.Messages { |
| if msg.Version != cl.Version { |
| continue |
| } |
| firstLine := msg.Message |
| if nl := strings.IndexByte(firstLine, '\n'); nl != -1 { |
| firstLine = firstLine[:nl] |
| } |
| if !strings.Contains(firstLine, "TryBot") { |
| continue |
| } |
| if strings.Contains(firstLine, "Run-TryBot+1") { |
| try = true |
| } |
| if strings.Contains(firstLine, "-Run-TryBot") { |
| try = false |
| } |
| if strings.Contains(firstLine, "TryBot-Result") { |
| done = true |
| } |
| } |
| return |
| } |
| |
| var tryCommentRx = regexp.MustCompile(`(?m)^TRY=(.*)$`) |
| |
| // tryWorkItem creates a GerritTryWorkItem for |
| // the Gerrit CL specified by cl, ci, comments. |
| // |
| // goProj is the state of the main Go repository. |
| // develVersion is the version of Go in development at HEAD of master branch. |
| // supportedReleases are the supported Go releases per https://go.dev/doc/devel/release#policy. |
| func tryWorkItem( |
| cl *maintner.GerritCL, ci *gerrit.ChangeInfo, comments map[string][]gerrit.CommentInfo, |
| goProj refer, develVersion apipb.MajorMinor, supportedReleases []*apipb.GoRelease, |
| ) (*apipb.GerritTryWorkItem, error) { |
| w := &apipb.GerritTryWorkItem{ |
| Project: cl.Project.Project(), |
| Branch: strings.TrimPrefix(cl.Branch(), "refs/heads/"), |
| ChangeId: cl.ChangeID(), |
| Commit: cl.Commit.Hash.String(), |
| AuthorEmail: cl.Owner().Email(), |
| } |
| if ci.CurrentRevision != "" { |
| // In case maintner is behind. |
| w.Commit = ci.CurrentRevision |
| w.Version = int32(ci.Revisions[ci.CurrentRevision].PatchSetNumber) |
| } |
| |
| // Look for "TRY=" comments. Only consider messages that are accompanied |
| // by a Run-TryBot+1 vote, as a way of confirming the comment author has |
| // Trybot Access (see https://go.dev/wiki/GerritAccess#running-trybots-may-start-trybots). |
| for _, m := range ci.Messages { |
| // msg is like: |
| // "Patch Set 2: Run-TryBot+1\n\n(1 comment)" |
| // "Patch Set 2: Run-TryBot+1 Code-Review-2" |
| // "Uploaded patch set 2." |
| // "Removed Run-TryBot+1 by Brad Fitzpatrick <bradfitz@golang.org>\n" |
| // "Patch Set 1: Run-TryBot+1\n\n(2 comments)" |
| if msg := m.Message; !strings.HasPrefix(msg, "Patch Set ") || |
| !strings.Contains(firstLine(msg), "Run-TryBot+1") { |
| continue |
| } |
| // Get "TRY=foo" comments (just the "foo" part) |
| // from matching patchset-level comments. They |
| // are posted on the magic "/PATCHSET_LEVEL" path, see https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-id. |
| for _, c := range comments["/PATCHSET_LEVEL"] { |
| // It should be sufficient to match by equal time only. |
| // But check that author and patch set match too in order to be more strict. |
| if !c.Updated.Equal(m.Time) || c.Author.NumericID != m.Author.NumericID || c.PatchSet != m.RevisionNumber { |
| continue |
| } |
| if len(w.TryMessage) > 0 && c.PatchSet < int(w.TryMessage[len(w.TryMessage)-1].Version) { |
| // Don't include try messages older than the latest we've seen. They're obsolete. |
| continue |
| } |
| tm := tryCommentRx.FindStringSubmatch(c.Message) |
| if tm == nil { |
| continue |
| } |
| w.TryMessage = append(w.TryMessage, &apipb.TryVoteMessage{ |
| Message: tm[1], |
| AuthorId: c.Author.NumericID, |
| Version: int32(c.PatchSet), |
| }) |
| } |
| } |
| |
| // Populate GoCommit, GoBranch, GoVersion fields |
| // according to what's being tested. Coordinator |
| // will use these to run corresponding tests. |
| if w.Project == "go" { |
| // TryBot on Go repo. Set the GoVersion field based on branch name. |
| if major, minor, ok := parseReleaseBranchVersion(w.Branch); ok { |
| // A release branch like release-branch.goX.Y. |
| // Use the major-minor Go version determined from the branch name. |
| w.GoVersion = []*apipb.MajorMinor{{Major: major, Minor: minor}} |
| } else { |
| // A branch that is not release-branch.goX.Y: maybe |
| // "master" or a development branch like "dev.link". |
| // There isn't a way to determine the version from its name, |
| // so use the development Go version until we need to do more. |
| // TODO(go.dev/issue/42376): This can be made more precise. |
| w.GoVersion = []*apipb.MajorMinor{&develVersion} |
| } |
| } else { |
| // TryBot on a subrepo. |
| if major, minor, ok := parseInternalBranchVersion(w.Branch); ok { |
| // An internal-branch.goX.Y-suffix branch is used for internal needs |
| // of goX.Y only, so no reason to test it on other Go versions. |
| goBranch := fmt.Sprintf("release-branch.go%d.%d", major, minor) |
| goCommit := goProj.Ref("refs/heads/" + goBranch) |
| if goCommit == "" { |
| return nil, fmt.Errorf("branch %q doesn't exist", goBranch) |
| } |
| w.GoCommit = []string{goCommit.String()} |
| w.GoBranch = []string{goBranch} |
| w.GoVersion = []*apipb.MajorMinor{{Major: major, Minor: minor}} |
| } else if w.Branch == "master" || |
| w.Project == "tools" && strings.HasPrefix(w.Branch, "gopls-release-branch.") { // Issue 46156. |
| |
| // For subrepos on the "master" branch and select branches that have opted in, |
| // use the default policy of testing it with Go tip and the supported releases. |
| w.GoCommit = []string{goProj.Ref("refs/heads/master").String()} |
| w.GoBranch = []string{"master"} |
| w.GoVersion = []*apipb.MajorMinor{&develVersion} |
| for _, r := range supportedReleases { |
| w.GoCommit = append(w.GoCommit, r.BranchCommit) |
| w.GoBranch = append(w.GoBranch, r.BranchName) |
| w.GoVersion = append(w.GoVersion, &apipb.MajorMinor{Major: r.Major, Minor: r.Minor}) |
| } |
| } else { |
| // A branch that is neither internal-branch.goX.Y-suffix nor "master": |
| // maybe some custom branch like "dev.go2go". |
| // Test it against Go tip only until we want to do more. |
| w.GoCommit = []string{goProj.Ref("refs/heads/master").String()} |
| w.GoBranch = []string{"master"} |
| w.GoVersion = []*apipb.MajorMinor{&develVersion} |
| } |
| } |
| |
| return w, nil |
| } |
| |
| func firstLine(s string) string { |
| if nl := strings.Index(s, "\n"); nl < 0 { |
| return s |
| } else { |
| return s[:nl] |
| } |
| } |
| |
| func (s apiService) GetRef(ctx context.Context, req *apipb.GetRefRequest) (*apipb.GetRefResponse, error) { |
| s.c.RLock() |
| defer s.c.RUnlock() |
| gp := s.c.Gerrit().Project(req.GerritServer, req.GerritProject) |
| if gp == nil { |
| return nil, errors.New("unknown gerrit project") |
| } |
| res := new(apipb.GetRefResponse) |
| hash := gp.Ref(req.Ref) |
| if hash != "" { |
| res.Value = hash.String() |
| } |
| return res, nil |
| } |
| |
| var tryCache struct { |
| sync.Mutex |
| forNumChanges int // number of label changes in project val is valid for |
| lastPoll time.Time // of gerrit |
| val *apipb.GoFindTryWorkResponse |
| } |
| |
| var tryBotGerrit = gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth) |
| |
| func (s apiService) GoFindTryWork(ctx context.Context, req *apipb.GoFindTryWorkRequest) (*apipb.GoFindTryWorkResponse, error) { |
| tryCache.Lock() |
| defer tryCache.Unlock() |
| |
| s.c.RLock() |
| defer s.c.RUnlock() |
| |
| // Count the number of vote label changes over time. If it's |
| // the same as the last query, return a cached result without |
| // hitting Gerrit. |
| var sumChanges int |
| s.c.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| sumChanges += gp.NumLabelChanges() |
| return nil |
| }) |
| |
| now := time.Now() |
| const maxPollInterval = 15 * time.Second |
| |
| if tryCache.val != nil && |
| (tryCache.forNumChanges == sumChanges || |
| tryCache.lastPoll.After(now.Add(-maxPollInterval))) { |
| return tryCache.val, nil |
| } |
| |
| tryCache.lastPoll = now |
| |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| |
| res, err := goFindTryWork(ctx, tryBotGerrit, s.c) |
| if err != nil { |
| log.Printf("maintnerd: goFindTryWork: %v", err) |
| return nil, err |
| } |
| |
| tryCache.val = res |
| tryCache.forNumChanges = sumChanges |
| |
| log.Printf("maintnerd: GetTryWork: for label changes of %d, cached %d trywork items.", |
| sumChanges, len(res.Waiting)) |
| |
| return res, nil |
| } |
| |
| func goFindTryWork(ctx context.Context, gerritc *gerrit.Client, maintc *maintner.Corpus) (*apipb.GoFindTryWorkResponse, error) { |
| const query = "label:Run-TryBot=1 label:TryBot-Result=0 status:open" |
| cis, err := gerritc.QueryChanges(ctx, query, gerrit.QueryChangesOpt{ |
| Fields: []string{"CURRENT_REVISION", "CURRENT_COMMIT", "MESSAGES", "DETAILED_ACCOUNTS"}, |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| goProj := maintc.Gerrit().Project("go.googlesource.com", "go") |
| supportedReleases, err := supportedGoReleases(goProj) |
| if err != nil { |
| return nil, err |
| } |
| // If Go X.Y is the latest supported release, the version in development is likely Go X.(Y+1). |
| // TODO(go.dev/issue/42376): This can be made more precise. |
| develVersion := apipb.MajorMinor{ |
| Major: supportedReleases[0].Major, |
| Minor: supportedReleases[0].Minor + 1, |
| } |
| |
| res := new(apipb.GoFindTryWorkResponse) |
| for _, ci := range cis { |
| proj := maintc.Gerrit().Project("go.googlesource.com", ci.Project) |
| if proj == nil { |
| log.Printf("nil Gerrit project %q", ci.Project) |
| continue |
| } |
| cl := proj.CL(int32(ci.ChangeNumber)) |
| if cl == nil { |
| log.Printf("nil Gerrit CL %v", ci.ChangeNumber) |
| continue |
| } |
| // There are rare cases when the project~branch~Change-Id triplet doesn't |
| // uniquely identify a change, but project~numericId does. It's important |
| // we select the right and only one change in this context, so prefer the |
| // project~numericId identifier type. See go.dev/issue/43312 and |
| // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id. |
| changeID := fmt.Sprintf("%s~%d", url.PathEscape(ci.Project), ci.ChangeNumber) |
| comments, err := gerritc.ListChangeComments(ctx, changeID) |
| if err != nil { |
| return nil, fmt.Errorf("gerritc.ListChangeComments(ctx, %q): %v", changeID, err) |
| } |
| work, err := tryWorkItem(cl, ci, comments, goProj, develVersion, supportedReleases) |
| if err != nil { |
| log.Printf("goFindTryWork: skipping CL %v because %v\n", ci.ChangeNumber, err) |
| continue |
| } |
| res.Waiting = append(res.Waiting, work) |
| } |
| |
| // Sort in some stable order. The coordinator's scheduler |
| // currently only uses the time the trybot run was requested, |
| // and not the commit time yet, but if two trybot runs are |
| // requested within the coordinator's poll interval, the |
| // earlier commit being first seems fair enough. Plus it's |
| // nice for interactive maintq queries to not have random |
| // orders. |
| sort.Slice(res.Waiting, func(i, j int) bool { |
| return res.Waiting[i].Commit < res.Waiting[j].Commit |
| }) |
| return res, nil |
| } |
| |
| // parseTagVersion parses the major-minor-patch version triplet |
| // from goX, goX.Y, or goX.Y.Z tag names, |
| // and reports whether the tag name is valid. |
| // |
| // Tags with suffixes like "go1.2beta3" or "go1.2rc1" are rejected. |
| // |
| // For example, "go1" is parsed as version 1.0.0, |
| // "go1.2" is parsed as version 1.2.0, |
| // and "go1.2.3" is parsed as version 1.2.3. |
| func parseTagVersion(tagName string) (major, minor, patch int32, ok bool) { |
| maj, min, pat, ok := version.ParseTag(tagName) |
| return int32(maj), int32(min), int32(pat), ok |
| } |
| |
| // parseReleaseBranchVersion parses the major-minor version pair |
| // from release-branch.goX or release-branch.goX.Y release branch names, |
| // and reports whether the release branch name is valid. |
| // |
| // For example, "release-branch.go1" is parsed as version 1.0, |
| // and "release-branch.go1.2" is parsed as version 1.2. |
| func parseReleaseBranchVersion(branchName string) (major, minor int32, ok bool) { |
| maj, min, ok := version.ParseReleaseBranch(branchName) |
| return int32(maj), int32(min), ok |
| } |
| |
| // parseInternalBranchVersion parses the major-minor version pair |
| // from internal-branch.goX-suffix or internal-branch.goX.Y-suffix internal branch names, |
| // and reports whether the internal branch name is valid. |
| // |
| // Before Go 1.16, golang.org/x repositories used release-branch.go1.n as internal |
| // branch names, so this function also accepts those branch names. See issue 36882. |
| // |
| // For example, "internal-branch.go1-vendor" is parsed as version 1.0, |
| // and "internal-branch.go1.2-vendor" is parsed as version 1.2. |
| func parseInternalBranchVersion(branchName string) (major, minor int32, ok bool) { |
| // Accept release branches as internal branches, since Go 1.15 still uses them. |
| // There was only one branch with a suffix, release-branch.go1.15-bundle in x/net, so just hardcode it. |
| // TODO: This special case can be removed when Go 1.17 is out and 1.15 is no longer supported. |
| if maj, min, ok := version.ParseReleaseBranch(strings.TrimSuffix(branchName, "-bundle")); ok { |
| return int32(maj), int32(min), ok |
| } |
| |
| const prefix = "internal-branch." |
| if !strings.HasPrefix(branchName, prefix) { |
| return 0, 0, false |
| } |
| tagAndSuffix := branchName[len(prefix):] // "go1.16-vendor". |
| i := strings.Index(tagAndSuffix, "-") |
| if i == -1 || i == len(tagAndSuffix)-1 { |
| // No "-suffix" at all, or empty suffix. Reject. |
| return 0, 0, false |
| } |
| tag := tagAndSuffix[:i] // "go1.16". |
| maj, min, pat, ok := version.ParseTag(tag) |
| if !ok || pat != 0 { |
| // Not a major Go release tag. Reject. |
| return 0, 0, false |
| } |
| return int32(maj), int32(min), true |
| } |
| |
| // ListGoReleases lists Go releases. A release is considered to exist |
| // if a tag for it exists. |
| func (s apiService) ListGoReleases(ctx context.Context, req *apipb.ListGoReleasesRequest) (*apipb.ListGoReleasesResponse, error) { |
| s.c.RLock() |
| defer s.c.RUnlock() |
| goProj := s.c.Gerrit().Project("go.googlesource.com", "go") |
| releases, err := supportedGoReleases(goProj) |
| if err != nil { |
| return nil, err |
| } |
| return &apipb.ListGoReleasesResponse{ |
| Releases: releases, |
| }, nil |
| } |
| |
| // refer is implemented by *maintner.GerritProject, |
| // or something that acts like it for testing. |
| type refer interface { |
| // Ref returns a non-change ref, such as "HEAD", "refs/heads/master", |
| // or "refs/tags/v0.8.0", |
| // Change refs of the form "refs/changes/*" are not supported. |
| // The returned hash is the zero value (an empty string) if the ref |
| // does not exist. |
| Ref(ref string) maintner.GitHash |
| } |
| |
| // nonChangeRefLister is implemented by *maintner.GerritProject, |
| // or something that acts like it for testing. |
| type nonChangeRefLister interface { |
| // ForeachNonChangeRef calls fn for each git ref on the server that is |
| // not a change (code review) ref. In general, these correspond to |
| // submitted changes. fn is called serially with sorted ref names. |
| // Iteration stops with the first non-nil error returned by fn. |
| ForeachNonChangeRef(fn func(ref string, hash maintner.GitHash) error) error |
| } |
| |
| // supportedGoReleases returns the latest patches of releases that are |
| // considered supported per policy. Sorted by version with latest first. |
| // The returned list will be empty if and only if the error is non-nil. |
| func supportedGoReleases(goProj nonChangeRefLister) ([]*apipb.GoRelease, error) { |
| type majorMinor struct { |
| Major, Minor int32 |
| } |
| type tag struct { |
| Patch int32 |
| Name string |
| Commit maintner.GitHash |
| } |
| type branch struct { |
| Name string |
| Commit maintner.GitHash |
| } |
| tags := make(map[majorMinor]tag) |
| branches := make(map[majorMinor]branch) |
| |
| // Iterate over Go tags and release branches. Find the latest patch |
| // for each major-minor pair, and fill in the appropriate fields. |
| err := goProj.ForeachNonChangeRef(func(ref string, hash maintner.GitHash) error { |
| switch { |
| case strings.HasPrefix(ref, "refs/tags/go"): |
| // Tag. |
| tagName := ref[len("refs/tags/"):] |
| major, minor, patch, ok := parseTagVersion(tagName) |
| if !ok { |
| return nil |
| } |
| if t, ok := tags[majorMinor{major, minor}]; ok && patch <= t.Patch { |
| // This patch version is not newer than what we've already seen, skip it. |
| return nil |
| } |
| tags[majorMinor{major, minor}] = tag{ |
| Patch: patch, |
| Name: tagName, |
| Commit: hash, |
| } |
| |
| case strings.HasPrefix(ref, "refs/heads/release-branch.go"): |
| // Release branch. |
| branchName := ref[len("refs/heads/"):] |
| major, minor, ok := parseReleaseBranchVersion(branchName) |
| if !ok { |
| return nil |
| } |
| branches[majorMinor{major, minor}] = branch{ |
| Name: branchName, |
| Commit: hash, |
| } |
| } |
| return nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
| |
| // A release is considered to exist for each git tag named "goX", "goX.Y", or "goX.Y.Z", |
| // as long as it has a corresponding "release-branch.goX" or "release-branch.goX.Y" release branch. |
| var rs []*apipb.GoRelease |
| for v, t := range tags { |
| b, ok := branches[v] |
| if !ok { |
| // In the unlikely case a tag exists but there's no release branch for it, |
| // don't consider it a release. This way, callers won't have to do this work. |
| continue |
| } |
| rs = append(rs, &apipb.GoRelease{ |
| Major: v.Major, |
| Minor: v.Minor, |
| Patch: t.Patch, |
| TagName: t.Name, |
| TagCommit: t.Commit.String(), |
| BranchName: b.Name, |
| BranchCommit: b.Commit.String(), |
| }) |
| } |
| |
| // Sort by version. Latest first. |
| sort.Slice(rs, func(i, j int) bool { |
| x1, y1, z1 := rs[i].Major, rs[i].Minor, rs[i].Patch |
| x2, y2, z2 := rs[j].Major, rs[j].Minor, rs[j].Patch |
| if x1 != x2 { |
| return x1 > x2 |
| } |
| if y1 != y2 { |
| return y1 > y2 |
| } |
| return z1 > z2 |
| }) |
| |
| // Per policy, only the latest two releases are considered supported. |
| // Return an error if there aren't at least two releases, so callers |
| // don't have to check for empty list. |
| if len(rs) < 2 { |
| return nil, fmt.Errorf("there was a problem finding supported Go releases") |
| } |
| return rs[:2], nil |
| } |
| |
| func (s apiService) GetDashboard(ctx context.Context, req *apipb.DashboardRequest) (*apipb.DashboardResponse, error) { |
| s.c.RLock() |
| defer s.c.RUnlock() |
| |
| res := new(apipb.DashboardResponse) |
| goProj := s.c.Gerrit().Project("go.googlesource.com", "go") |
| if goProj == nil { |
| // Return a normal error here, without grpc code |
| // NotFound, because we expect to find this. |
| return nil, errors.New("go gerrit project not found") |
| } |
| if req.Repo == "" { |
| req.Repo = "go" |
| } |
| projName, err := dashRepoToGerritProj(req.Repo) |
| if err != nil { |
| return nil, err |
| } |
| proj := s.c.Gerrit().Project("go.googlesource.com", projName) |
| if proj == nil { |
| return nil, grpc.Errorf(codes.NotFound, "repo project %q not found", projName) |
| } |
| |
| // Populate res.Branches. |
| const headPrefix = "refs/heads/" |
| refHash := map[string]string{} // "master" -> git commit hash |
| goProj.ForeachNonChangeRef(func(ref string, hash maintner.GitHash) error { |
| if !strings.HasPrefix(ref, headPrefix) { |
| return nil |
| } |
| branch := strings.TrimPrefix(ref, headPrefix) |
| refHash[branch] = hash.String() |
| res.Branches = append(res.Branches, branch) |
| return nil |
| }) |
| |
| if req.Branch == "" { |
| req.Branch = "master" |
| } |
| branch := req.Branch |
| mixBranches := branch == "mixed" // mix all branches together, by commit time |
| if !mixBranches && refHash[branch] == "" { |
| return nil, grpc.Errorf(codes.NotFound, "unknown branch %q", branch) |
| } |
| |
| commitsPerPage := int(req.MaxCommits) |
| if commitsPerPage < 0 { |
| return nil, grpc.Errorf(codes.InvalidArgument, "negative max commits") |
| } |
| if commitsPerPage > 1000 { |
| commitsPerPage = 1000 |
| } |
| if commitsPerPage == 0 { |
| if mixBranches { |
| commitsPerPage = 500 |
| } else { |
| commitsPerPage = 30 // what build.golang.org historically used |
| } |
| } |
| if mixBranches && commitsPerPage < len(res.Branches) { |
| return nil, grpc.Errorf(codes.InvalidArgument, "page size too small for `mixed`: %v < %v", commitsPerPage, len(res.Branches)) |
| } |
| |
| if req.Page < 0 { |
| return nil, grpc.Errorf(codes.InvalidArgument, "invalid page") |
| } |
| if req.Page != 0 && mixBranches { |
| return nil, grpc.Errorf(codes.InvalidArgument, "branch=mixed does not support pagination") |
| } |
| skip := int(req.Page) * commitsPerPage |
| if skip >= 10000 { |
| return nil, grpc.Errorf(codes.InvalidArgument, "too far back") // arbitrary |
| } |
| |
| // Find branches to merge together. |
| // |
| // By default we only have one branch (the one the user |
| // specified). But in mixed mode, as used by the coordinator |
| // when trying to find work to do, we merge all the branches |
| // together into one timeline. |
| branches := []string{branch} |
| if mixBranches { |
| branches = res.Branches |
| } |
| var oldestSkipped time.Time |
| res.Commits, res.CommitsTruncated, oldestSkipped = s.listDashCommits(proj, branches, commitsPerPage, skip) |
| |
| // For non-go repos, populate the Go commits that corresponding to each commit. |
| if projName != "go" { |
| s.addGoCommits(oldestSkipped, res.Commits) |
| } |
| |
| // Populate res.RepoHeads: each Gerrit repo with what its |
| // current master ref is at. |
| res.RepoHeads = s.dashRepoHeads() |
| |
| // Populate res.Releases (the currently supported releases) |
| // with "master" followed by the past two release branches. |
| res.Releases = append(res.Releases, &apipb.GoRelease{ |
| BranchName: "master", |
| BranchCommit: refHash["master"], |
| }) |
| releases, err := supportedGoReleases(goProj) |
| if err != nil { |
| return nil, err |
| } |
| res.Releases = append(res.Releases, releases...) |
| |
| return res, nil |
| } |
| |
| // listDashCommits merges together the commits in the provided |
| // branches, sorted by commit time (newest first), skipping skip |
| // items, and stopping after commitsPerPage items. |
| // If len(branches) > 1, then skip must be zero. |
| // |
| // It returns the commits, whether more would follow on a later page, |
| // and the oldest skipped commit, if any. |
| func (s apiService) listDashCommits(proj *maintner.GerritProject, branches []string, commitsPerPage, skip int) (commits []*apipb.DashCommit, truncated bool, oldestSkipped time.Time) { |
| mixBranches := len(branches) > 1 |
| if mixBranches && skip > 0 { |
| panic("unsupported skip in mixed mode") |
| } |
| // oldestItem is the oldest item on the page. It's used to |
| // stop iteration early on the 2nd and later branches when |
| // len(branches) > 1. |
| var oldestItem time.Time |
| for _, branch := range branches { |
| gh := proj.Ref("refs/heads/" + branch) |
| if gh == "" { |
| continue |
| } |
| skipped := 0 |
| var add []*apipb.DashCommit |
| iter := s.gitLogIter(gh) |
| for len(add) < commitsPerPage && iter.HasNext() { |
| c := iter.Take() |
| if c.CommitTime.Before(oldestItem) { |
| break |
| } |
| if skipped >= skip { |
| dc := dashCommit(c) |
| dc.Branch = branch |
| add = append(add, dc) |
| } else { |
| skipped++ |
| oldestSkipped = c.CommitTime |
| } |
| } |
| commits = append(commits, add...) |
| if !mixBranches { |
| truncated = iter.HasNext() |
| break |
| } |
| |
| sort.Slice(commits, func(i, j int) bool { |
| return commits[i].CommitTimeSec > commits[j].CommitTimeSec |
| }) |
| if len(commits) > commitsPerPage { |
| commits = commits[:commitsPerPage] |
| truncated = true |
| } |
| if len(commits) > 0 { |
| oldestItem = time.Unix(commits[len(commits)-1].CommitTimeSec, 0) |
| } |
| } |
| return commits, truncated, oldestSkipped |
| } |
| |
| // addGoCommits populates each commit's GoCommitAtTime and |
| // GoCommitLatest values. for the oldest and newest corresponding "go" |
| // repo commits, respectively. That way there's at least one |
| // associated Go commit (even if empty) on the dashboard when viewing |
| // https://build.golang.org/?repo=golang.org/x/net. |
| // |
| // The provided commits must be from most recent to oldest. The |
| // oldestSkipped should be the oldest commit time that's on the page |
| // prior to commits, or the zero value for the first (newest) page. |
| // |
| // The maintner corpus must be read-locked. |
| func (s apiService) addGoCommits(oldestSkipped time.Time, commits []*apipb.DashCommit) { |
| if len(commits) == 0 { |
| return |
| } |
| goProj := s.c.Gerrit().Project("go.googlesource.com", "go") |
| if goProj == nil { |
| // Shouldn't happen, except in tests with |
| // an empty maintner corpus. |
| return |
| } |
| // Find the oldest (last) commit. |
| oldestX := time.Unix(commits[len(commits)-1].CommitTimeSec, 0) |
| |
| // Collect enough goCommits going back far enough such that we have one that's older |
| // than the oldest repo item on the page. |
| var goCommits []*maintner.GitCommit // newest to oldest |
| lastGoHash := func() string { |
| if len(goCommits) == 0 { |
| return "" |
| } |
| return goCommits[len(goCommits)-1].Hash.String() |
| } |
| |
| goIter := s.gitLogIter(goProj.Ref("refs/heads/master")) |
| for goIter.HasNext() { |
| c := goIter.Take() |
| goCommits = append(goCommits, c) |
| if c.CommitTime.Before(oldestX) { |
| break |
| } |
| } |
| |
| for i := len(commits) - 1; i >= 0; i-- { // walk from oldest to newest |
| dc := commits[i] |
| var maxGoAge time.Time |
| if i == 0 { |
| maxGoAge = oldestSkipped |
| } else { |
| maxGoAge = time.Unix(commits[i-1].CommitTimeSec, 0) |
| } |
| dc.GoCommitAtTime = lastGoHash() |
| for len(goCommits) >= 2 && goCommits[len(goCommits)-2].CommitTime.Before(maxGoAge) { |
| goCommits = goCommits[:len(goCommits)-1] |
| } |
| dc.GoCommitLatest = lastGoHash() |
| } |
| } |
| |
| // dashRepoHeads returns the DashRepoHead for each Gerrit project on |
| // the go.googlesource.com server. |
| func (s apiService) dashRepoHeads() (heads []*apipb.DashRepoHead) { |
| s.c.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| gh := gp.Ref("refs/heads/master") |
| if gh == "" { |
| return nil |
| } |
| c, err := gp.GitCommit(gh.String()) |
| if err != nil { |
| // In theory we could ignore this error to produce best-effort results for |
| // the remaining projects. However, we expect as an invariant that the |
| // head commit for each project always exists. If it ever doesn't, |
| // something is deeply wrong with the project state and should be |
| // investigated, and surfacing the error makes it more likely to be |
| // investigated and fixed soon after a regression or corruption occurs |
| // (instead of at an arbitrarily later date). |
| return err |
| } |
| heads = append(heads, &apipb.DashRepoHead{ |
| GerritProject: gp.Project(), |
| Commit: dashCommit(c), |
| }) |
| return nil |
| }) |
| sort.Slice(heads, func(i, j int) bool { |
| return heads[i].GerritProject < heads[j].GerritProject |
| }) |
| return |
| } |
| |
| // gitLogIter is a git log iterator. |
| type gitLogIter struct { |
| corpus *maintner.Corpus |
| nexth maintner.GitHash |
| nextc *maintner.GitCommit // lazily looked up |
| } |
| |
| // HasNext reports whether there's another commit to be seen. |
| func (i *gitLogIter) HasNext() bool { |
| if i.nextc == nil { |
| if i.nexth == "" { |
| return false |
| } |
| i.nextc = i.corpus.GitCommit(i.nexth.String()) |
| } |
| return i.nextc != nil |
| } |
| |
| // Take returns the next commit (or nil if none remains) and advances past it. |
| func (i *gitLogIter) Take() *maintner.GitCommit { |
| if !i.HasNext() { |
| return nil |
| } |
| ret := i.nextc |
| i.nextc = nil |
| if len(ret.Parents) == 0 { |
| i.nexth = "" |
| } else { |
| // TODO: care about returning the history from both |
| // sides of merge commits? Go has a linear history for |
| // the most part so punting for now. I think the old |
| // build.golang.org datastore model got confused by |
| // this too. In any case, this is like: |
| // git log --first-parent. |
| i.nexth = ret.Parents[0].Hash |
| } |
| return ret |
| } |
| |
| // Peek returns the next commit (or nil if none remains) without advancing past it. |
| // The next call to Peek or Take will return it again. |
| func (i *gitLogIter) Peek() *maintner.GitCommit { |
| if i.HasNext() { |
| // HasNext guarantees that it populates i.nextc. |
| return i.nextc |
| } |
| return nil |
| } |
| |
| func (s apiService) gitLogIter(start maintner.GitHash) *gitLogIter { |
| return &gitLogIter{ |
| corpus: s.c, |
| nexth: start, |
| } |
| } |
| |
| func dashCommit(c *maintner.GitCommit) *apipb.DashCommit { |
| return &apipb.DashCommit{ |
| Commit: c.Hash.String(), |
| CommitTimeSec: c.CommitTime.Unix(), |
| AuthorName: c.Author.Name(), |
| AuthorEmail: c.Author.Email(), |
| Title: c.Summary(), |
| } |
| } |
| |
| // dashRepoToGerritProj maps a DashboardRequest.repo value to |
| // a go.googlesource.com Gerrit project name. |
| func dashRepoToGerritProj(repo string) (proj string, err error) { |
| if repo == "go" || repo == "" { |
| return "go", nil |
| } |
| ri, ok := repos.ByImportPath[repo] |
| if !ok || ri.GoGerritProject == "" { |
| return "", grpc.Errorf(codes.NotFound, `unknown repo %q; must be empty, "go", or "golang.org/*"`, repo) |
| } |
| return ri.GoGerritProject, nil |
| } |