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 }