golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/gopherbot/gopherbot.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 // The gopherbot command runs Go's gopherbot role account on 6 // GitHub and Gerrit. 7 // 8 // General documentation is at https://go.dev/wiki/gopherbot. 9 // Consult the tasks slice in gopherbot.go for an up-to-date 10 // list of all gopherbot tasks. 11 package main 12 13 import ( 14 "bufio" 15 "bytes" 16 "context" 17 "crypto/tls" 18 "encoding/json" 19 "errors" 20 "flag" 21 "fmt" 22 "log" 23 "net/http" 24 "os" 25 "path/filepath" 26 "regexp" 27 "sort" 28 "strconv" 29 "strings" 30 "sync" 31 "time" 32 "unicode" 33 34 "cloud.google.com/go/compute/metadata" 35 "github.com/google/go-github/v48/github" 36 "github.com/shurcooL/githubv4" 37 "go4.org/strutil" 38 "golang.org/x/build/devapp/owners" 39 "golang.org/x/build/gerrit" 40 "golang.org/x/build/internal/foreach" 41 "golang.org/x/build/internal/gophers" 42 "golang.org/x/build/internal/secret" 43 "golang.org/x/build/maintner" 44 "golang.org/x/build/maintner/godata" 45 "golang.org/x/build/maintner/maintnerd/apipb" 46 "golang.org/x/exp/slices" 47 "golang.org/x/oauth2" 48 "google.golang.org/grpc" 49 "google.golang.org/grpc/credentials" 50 ) 51 52 var ( 53 dryRun = flag.Bool("dry-run", false, "just report what would've been done, without changing anything") 54 daemon = flag.Bool("daemon", false, "run in daemon mode") 55 githubTokenFile = flag.String("github-token-file", filepath.Join(os.Getenv("HOME"), "keys", "github-gobot"), `File to load GitHub token from. File should be of form <username>:<token>`) 56 // go here: https://go-review.googlesource.com/settings#HTTPCredentials 57 // click "Obtain Password" 58 // The next page will have a .gitcookies file - look for the part that has 59 // "git-youremail@yourcompany.com=password". Copy and paste that to the 60 // token file with a colon in between the email and password. 61 gerritTokenFile = flag.String("gerrit-token-file", filepath.Join(os.Getenv("HOME"), "keys", "gerrit-gobot"), `File to load Gerrit token from. File should be of form <git-email>:<token>`) 62 63 onlyRun = flag.String("only-run", "", "if non-empty, the name of a task to run. Mostly for debugging, but tasks (like 'kicktrain') may choose to only run in explicit mode") 64 ) 65 66 func init() { 67 flag.Usage = func() { 68 output := flag.CommandLine.Output() 69 fmt.Fprintf(output, "gopherbot runs Go's gopherbot role account on GitHub and Gerrit.\n\n") 70 flag.PrintDefaults() 71 fmt.Fprintln(output, "") 72 fmt.Fprintln(output, "Tasks (can be used for the --only-run flag):") 73 for _, t := range tasks { 74 fmt.Fprintf(output, " %q\n", t.name) 75 } 76 } 77 } 78 79 const ( 80 gopherbotGitHubID = 8566911 81 ) 82 83 const ( 84 gobotGerritID = "5976" 85 gerritbotGerritID = "12446" 86 kokoroGerritID = "37747" 87 goLUCIGerritID = "60063" 88 triciumGerritID = "62045" 89 ) 90 91 // GitHub Label IDs for the golang/go repo. 92 const ( 93 needsDecisionID = 373401956 94 needsFixID = 373399998 95 needsInvestigationID = 373402289 96 earlyInCycleID = 626114143 97 ) 98 99 // Label names (that are used in multiple places). 100 const ( 101 frozenDueToAge = "FrozenDueToAge" 102 ) 103 104 // GitHub Milestone numbers for the golang/go repo. 105 var ( 106 proposal = milestone{30, "Proposal"} 107 unreleased = milestone{22, "Unreleased"} 108 unplanned = milestone{6, "Unplanned"} 109 gccgo = milestone{23, "Gccgo"} 110 vgo = milestone{71, "vgo"} 111 vulnUnplanned = milestone{288, "vuln/unplanned"} 112 ) 113 114 // GitHub Milestone numbers for the golang/vscode-go repo. 115 var vscodeUntriaged = milestone{26, "Untriaged"} 116 117 type milestone struct { 118 Number int 119 Name string 120 } 121 122 func getGitHubToken(ctx context.Context, sc *secret.Client) (string, error) { 123 if metadata.OnGCE() && sc != nil { 124 ctxSc, cancel := context.WithTimeout(ctx, 10*time.Second) 125 defer cancel() 126 127 token, err := sc.Retrieve(ctxSc, secret.NameMaintnerGitHubToken) 128 if err == nil && token != "" { 129 return token, nil 130 } 131 } 132 slurp, err := os.ReadFile(*githubTokenFile) 133 if err != nil { 134 return "", err 135 } 136 f := strings.SplitN(strings.TrimSpace(string(slurp)), ":", 2) 137 if len(f) != 2 || f[0] == "" || f[1] == "" { 138 return "", fmt.Errorf("expected token %q to be of form <username>:<token>", slurp) 139 } 140 return f[1], nil 141 } 142 143 func getGerritAuth(ctx context.Context, sc *secret.Client) (username string, password string, err error) { 144 if metadata.OnGCE() && sc != nil { 145 ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 146 defer cancel() 147 148 token, err := sc.Retrieve(ctx, secret.NameGobotPassword) 149 if err != nil { 150 return "", "", err 151 } 152 return "git-gobot.golang.org", token, nil 153 } 154 155 var slurpBytes []byte 156 slurpBytes, err = os.ReadFile(*gerritTokenFile) 157 if err != nil { 158 return "", "", err 159 } 160 slurp := string(slurpBytes) 161 162 f := strings.SplitN(strings.TrimSpace(slurp), ":", 2) 163 if len(f) == 1 { 164 // assume the whole thing is the token 165 return "git-gobot.golang.org", f[0], nil 166 } 167 if len(f) != 2 || f[0] == "" || f[1] == "" { 168 return "", "", fmt.Errorf("expected Gerrit token %q to be of form <git-email>:<token>", slurp) 169 } 170 return f[0], f[1], nil 171 } 172 173 func getGitHubClients(ctx context.Context, sc *secret.Client) (*github.Client, *githubv4.Client, error) { 174 token, err := getGitHubToken(ctx, sc) 175 if err != nil { 176 if *dryRun { 177 // Note: GitHub API v4 requires requests to be authenticated, which isn't implemented here. 178 return github.NewClient(http.DefaultClient), githubv4.NewClient(http.DefaultClient), nil 179 } 180 return nil, nil, err 181 } 182 ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) 183 tc := oauth2.NewClient(context.Background(), ts) 184 return github.NewClient(tc), githubv4.NewClient(tc), nil 185 } 186 187 func getGerritClient(ctx context.Context, sc *secret.Client) (*gerrit.Client, error) { 188 username, token, err := getGerritAuth(ctx, sc) 189 if err != nil { 190 if *dryRun { 191 c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth) 192 return c, nil 193 } 194 return nil, err 195 } 196 c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(username, token)) 197 return c, nil 198 } 199 200 func getMaintnerClient(ctx context.Context) (apipb.MaintnerServiceClient, error) { 201 ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 202 defer cancel() 203 mServer := "maintner.golang.org:443" 204 cc, err := grpc.DialContext(ctx, mServer, 205 grpc.WithBlock(), 206 grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{NextProtos: []string{"h2"}}))) 207 if err != nil { 208 return nil, err 209 } 210 return apipb.NewMaintnerServiceClient(cc), nil 211 } 212 213 type gerritChange struct { 214 project string 215 num int32 216 } 217 218 func (c gerritChange) ID() string { 219 // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id 220 return fmt.Sprintf("%s~%d", c.project, c.num) 221 } 222 223 func (c gerritChange) String() string { 224 return c.ID() 225 } 226 227 type githubIssue struct { 228 repo maintner.GitHubRepoID 229 num int32 230 } 231 232 func main() { 233 flag.Parse() 234 235 var sc *secret.Client 236 if metadata.OnGCE() { 237 sc = secret.MustNewClient() 238 } 239 ctx := context.Background() 240 241 ghV3, ghV4, err := getGitHubClients(ctx, sc) 242 if err != nil { 243 log.Fatal(err) 244 } 245 gerrit, err := getGerritClient(ctx, sc) 246 if err != nil { 247 log.Fatal(err) 248 } 249 mc, err := getMaintnerClient(ctx) 250 if err != nil { 251 log.Fatal(err) 252 } 253 254 var goRepo = maintner.GitHubRepoID{Owner: "golang", Repo: "go"} 255 var vscode = maintner.GitHubRepoID{Owner: "golang", Repo: "vscode-go"} 256 bot := &gopherbot{ 257 ghc: ghV3, 258 ghV4: ghV4, 259 gerrit: gerrit, 260 mc: mc, 261 is: ghV3.Issues, 262 deletedChanges: map[gerritChange]bool{ 263 {"crypto", 35958}: true, 264 {"scratch", 71730}: true, 265 {"scratch", 71850}: true, 266 {"scratch", 72090}: true, 267 {"scratch", 72091}: true, 268 {"scratch", 72110}: true, 269 {"scratch", 72131}: true, 270 }, 271 deletedIssues: map[githubIssue]bool{ 272 {goRepo, 13084}: true, 273 {goRepo, 23772}: true, 274 {goRepo, 27223}: true, 275 {goRepo, 28522}: true, 276 {goRepo, 29309}: true, 277 {goRepo, 32047}: true, 278 {goRepo, 32048}: true, 279 {goRepo, 32469}: true, 280 {goRepo, 32706}: true, 281 {goRepo, 32737}: true, 282 {goRepo, 33315}: true, 283 {goRepo, 33316}: true, 284 {goRepo, 33592}: true, 285 {goRepo, 33593}: true, 286 {goRepo, 33697}: true, 287 {goRepo, 33785}: true, 288 {goRepo, 34296}: true, 289 {goRepo, 34476}: true, 290 {goRepo, 34766}: true, 291 {goRepo, 34780}: true, 292 {goRepo, 34786}: true, 293 {goRepo, 34821}: true, 294 {goRepo, 35493}: true, 295 {goRepo, 35649}: true, 296 {goRepo, 36322}: true, 297 {goRepo, 36323}: true, 298 {goRepo, 36324}: true, 299 {goRepo, 36342}: true, 300 {goRepo, 36343}: true, 301 {goRepo, 36406}: true, 302 {goRepo, 36517}: true, 303 {goRepo, 36829}: true, 304 {goRepo, 36885}: true, 305 {goRepo, 36933}: true, 306 {goRepo, 36939}: true, 307 {goRepo, 36941}: true, 308 {goRepo, 36947}: true, 309 {goRepo, 36962}: true, 310 {goRepo, 36963}: true, 311 {goRepo, 37516}: true, 312 {goRepo, 37522}: true, 313 {goRepo, 37582}: true, 314 {goRepo, 37896}: true, 315 {goRepo, 38132}: true, 316 {goRepo, 38241}: true, 317 {goRepo, 38483}: true, 318 {goRepo, 38560}: true, 319 {goRepo, 38840}: true, 320 {goRepo, 39112}: true, 321 {goRepo, 39141}: true, 322 {goRepo, 39229}: true, 323 {goRepo, 39234}: true, 324 {goRepo, 39335}: true, 325 {goRepo, 39401}: true, 326 {goRepo, 39453}: true, 327 {goRepo, 39522}: true, 328 {goRepo, 39718}: true, 329 {goRepo, 40400}: true, 330 {goRepo, 40593}: true, 331 {goRepo, 40600}: true, 332 {goRepo, 41211}: true, 333 {goRepo, 41336}: true, 334 {goRepo, 41649}: true, 335 {goRepo, 41650}: true, 336 {goRepo, 41655}: true, 337 {goRepo, 41675}: true, 338 {goRepo, 41676}: true, 339 {goRepo, 41678}: true, 340 {goRepo, 41679}: true, 341 {goRepo, 41714}: true, 342 {goRepo, 42309}: true, 343 {goRepo, 43102}: true, 344 {goRepo, 43169}: true, 345 {goRepo, 43231}: true, 346 {goRepo, 43330}: true, 347 {goRepo, 43409}: true, 348 {goRepo, 43410}: true, 349 {goRepo, 43411}: true, 350 {goRepo, 43433}: true, 351 {goRepo, 43613}: true, 352 {goRepo, 43751}: true, 353 {goRepo, 44124}: true, 354 {goRepo, 44185}: true, 355 {goRepo, 44566}: true, 356 {goRepo, 44652}: true, 357 {goRepo, 44711}: true, 358 {goRepo, 44768}: true, 359 {goRepo, 44769}: true, 360 {goRepo, 44771}: true, 361 {goRepo, 44773}: true, 362 {goRepo, 44871}: true, 363 {goRepo, 45018}: true, 364 {goRepo, 45082}: true, 365 {goRepo, 45201}: true, 366 {goRepo, 45202}: true, 367 {goRepo, 47140}: true, 368 369 {vscode, 298}: true, 370 {vscode, 524}: true, 371 {vscode, 650}: true, 372 {vscode, 741}: true, 373 {vscode, 773}: true, 374 {vscode, 959}: true, 375 {vscode, 1402}: true, 376 }, 377 } 378 for n := int32(55359); n <= 55828; n++ { 379 bot.deletedIssues[githubIssue{goRepo, n}] = true 380 } 381 bot.initCorpus() 382 383 for { 384 t0 := time.Now() 385 taskErrors := bot.doTasks(ctx) 386 for _, err := range taskErrors { 387 log.Print(err) 388 } 389 botDur := time.Since(t0) 390 log.Printf("gopherbot ran in %v", botDur) 391 if !*daemon { 392 if len(taskErrors) > 0 { 393 os.Exit(1) 394 } 395 return 396 } 397 if len(taskErrors) > 0 { 398 log.Printf("sleeping 30s after previous error.") 399 time.Sleep(30 * time.Second) 400 } 401 for { 402 t0 := time.Now() 403 err := bot.corpus.Update(ctx) 404 if err != nil { 405 if err == maintner.ErrSplit { 406 log.Print("Corpus out of sync. Re-fetching corpus.") 407 bot.initCorpus() 408 } else { 409 log.Printf("corpus.Update: %v; sleeping 15s", err) 410 time.Sleep(15 * time.Second) 411 continue 412 } 413 } 414 log.Printf("got corpus update after %v", time.Since(t0)) 415 break 416 } 417 lastTask = "" 418 } 419 } 420 421 type gopherbot struct { 422 ghc *github.Client 423 ghV4 *githubv4.Client 424 gerrit *gerrit.Client 425 mc apipb.MaintnerServiceClient 426 corpus *maintner.Corpus 427 gorepo *maintner.GitHubRepo 428 is issuesService 429 430 knownContributors map[string]bool 431 432 // Until golang.org/issue/22635 is fixed, keep a map of changes and issues 433 // that were deleted to prevent calls to Gerrit or GitHub that will always 404. 434 deletedChanges map[gerritChange]bool 435 deletedIssues map[githubIssue]bool 436 437 releases struct { 438 sync.Mutex 439 lastUpdate time.Time 440 major []string // Last two releases and the next upcoming release, like: "1.9", "1.10", "1.11". 441 nextMinor map[string]string // Key is a major release like "1.9", value is its next minor release like "1.9.7". 442 } 443 } 444 445 var tasks = []struct { 446 name string 447 fn func(*gopherbot, context.Context) error 448 }{ 449 // Tasks that are specific to the golang/go repo. 450 {"kicktrain", (*gopherbot).getOffKickTrain}, 451 {"label build issues", (*gopherbot).labelBuildIssues}, 452 {"label compiler/runtime issues", (*gopherbot).labelCompilerRuntimeIssues}, 453 {"label mobile issues", (*gopherbot).labelMobileIssues}, 454 {"label tools issues", (*gopherbot).labelToolsIssues}, 455 {"label website issues", (*gopherbot).labelWebsiteIssues}, 456 {"label pkgsite issues", (*gopherbot).labelPkgsiteIssues}, 457 {"label proxy.golang.org issues", (*gopherbot).labelProxyIssues}, 458 {"label vulncheck or vulndb issues", (*gopherbot).labelVulnIssues}, 459 {"label proposals", (*gopherbot).labelProposals}, 460 {"handle gopls issues", (*gopherbot).handleGoplsIssues}, 461 {"handle telemetry issues", (*gopherbot).handleTelemetryIssues}, 462 {"open cherry pick issues", (*gopherbot).openCherryPickIssues}, 463 {"close cherry pick issues", (*gopherbot).closeCherryPickIssues}, 464 {"set subrepo milestones", (*gopherbot).setSubrepoMilestones}, 465 {"set misc milestones", (*gopherbot).setMiscMilestones}, 466 {"apply minor release milestones", (*gopherbot).setMinorMilestones}, 467 {"update needs", (*gopherbot).updateNeeds}, 468 469 // Tasks that can be applied to many repos. 470 {"freeze old issues", (*gopherbot).freezeOldIssues}, 471 {"label documentation issues", (*gopherbot).labelDocumentationIssues}, 472 {"close stale WaitingForInfo", (*gopherbot).closeStaleWaitingForInfo}, 473 {"apply labels from comments", (*gopherbot).applyLabelsFromComments}, 474 475 // Gerrit tasks are applied to all projects by default. 476 {"abandon scratch reviews", (*gopherbot).abandonScratchReviews}, 477 {"assign reviewers to CLs", (*gopherbot).assignReviewersToCLs}, 478 {"auto-submit CLs", (*gopherbot).autoSubmitCLs}, 479 480 // Tasks that are specific to the golang/vscode-go repo. 481 {"set vscode-go milestones", (*gopherbot).setVSCodeGoMilestones}, 482 483 {"access", (*gopherbot).whoNeedsAccess}, 484 {"cl2issue", (*gopherbot).cl2issue}, 485 {"congratulate new contributors", (*gopherbot).congratulateNewContributors}, 486 {"un-wait CLs", (*gopherbot).unwaitCLs}, 487 } 488 489 // gardenIssues reports whether GopherBot should perform general issue 490 // gardening tasks for the repo. 491 func gardenIssues(repo *maintner.GitHubRepo) bool { 492 if repo.ID().Owner != "golang" { 493 return false 494 } 495 switch repo.ID().Repo { 496 case "go", "vscode-go", "vulndb": 497 return true 498 } 499 return false 500 } 501 502 func (b *gopherbot) initCorpus() { 503 ctx := context.Background() 504 corpus, err := godata.Get(ctx) 505 if err != nil { 506 log.Fatalf("godata.Get: %v", err) 507 } 508 509 repo := corpus.GitHub().Repo("golang", "go") 510 if repo == nil { 511 log.Fatal("Failed to find Go repo in Corpus.") 512 } 513 514 b.corpus = corpus 515 b.gorepo = repo 516 } 517 518 // doTasks performs tasks in sequence. It doesn't stop if 519 // if encounters an error, but reports errors at the end. 520 func (b *gopherbot) doTasks(ctx context.Context) []error { 521 var errs []error 522 for _, task := range tasks { 523 if *onlyRun != "" && task.name != *onlyRun { 524 continue 525 } 526 err := task.fn(b, ctx) 527 if err != nil { 528 errs = append(errs, fmt.Errorf("%s: %v", task.name, err)) 529 } 530 } 531 return errs 532 } 533 534 // issuesService represents portions of github.IssuesService that we want to override in tests. 535 type issuesService interface { 536 ListLabelsByIssue(ctx context.Context, owner string, repo string, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) 537 AddLabelsToIssue(ctx context.Context, owner string, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) 538 RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error) 539 } 540 541 func (b *gopherbot) addLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error { 542 return b.addLabels(ctx, repoID, gi, []string{label}) 543 } 544 545 func (b *gopherbot) addLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error { 546 var toAdd []string 547 for _, label := range labels { 548 if gi.HasLabel(label) { 549 log.Printf("Issue %d already has label %q; no need to send request to add it", gi.Number, label) 550 continue 551 } 552 printIssue("label-"+label, repoID, gi) 553 toAdd = append(toAdd, label) 554 } 555 556 if *dryRun || len(toAdd) == 0 { 557 return nil 558 } 559 560 _, resp, err := b.is.AddLabelsToIssue(ctx, repoID.Owner, repoID.Repo, int(gi.Number), toAdd) 561 if err != nil && resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone) { 562 // TODO(golang/go#40640) - This issue was transferred or otherwise is gone. We should permanently skip it. This 563 // is a temporary fix to keep gopherbot working. 564 log.Printf("addLabels: Issue %v#%v returned %s when trying to add labels. Skipping. See golang/go#40640.", repoID, gi.Number, resp.Status) 565 b.deletedIssues[githubIssue{repoID, gi.Number}] = true 566 return nil 567 } 568 return err 569 } 570 571 // removeLabel removes the label from the given issue in the given repo. 572 func (b *gopherbot) removeLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error { 573 return b.removeLabels(ctx, repoID, gi, []string{label}) 574 } 575 576 func (b *gopherbot) removeLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error { 577 var removeLabels bool 578 for _, l := range labels { 579 if !gi.HasLabel(l) { 580 log.Printf("Issue %d (in maintner) does not have label %q; no need to send request to remove it", gi.Number, l) 581 continue 582 } 583 printIssue("label-"+l, repoID, gi) 584 removeLabels = true 585 } 586 587 if *dryRun || !removeLabels { 588 return nil 589 } 590 591 ghLabels, err := labelsForIssue(ctx, repoID, b.is, int(gi.Number)) 592 if err != nil { 593 return err 594 } 595 toRemove := make(map[string]bool) 596 for _, l := range labels { 597 toRemove[l] = true 598 } 599 600 for _, l := range ghLabels { 601 if toRemove[l] { 602 if err := removeLabelFromIssue(ctx, repoID, b.is, int(gi.Number), l); err != nil { 603 log.Printf("Could not remove label %q from issue %d: %v", l, gi.Number, err) 604 continue 605 } 606 } 607 } 608 return nil 609 } 610 611 // labelsForIssue returns all labels for the given issue in the given repo. 612 func labelsForIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int) ([]string, error) { 613 ghLabels, _, err := issues.ListLabelsByIssue(ctx, repoID.Owner, repoID.Repo, issueNum, &github.ListOptions{PerPage: 100}) 614 if err != nil { 615 return nil, fmt.Errorf("could not list labels for %s#%d: %v", repoID, issueNum, err) 616 } 617 var labels []string 618 for _, l := range ghLabels { 619 labels = append(labels, l.GetName()) 620 } 621 return labels, nil 622 } 623 624 // removeLabelFromIssue removes the given label from the given repo with the 625 // given issueNum. If the issue did not have the label already (or the label 626 // didn't exist), return nil. 627 func removeLabelFromIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int, label string) error { 628 _, err := issues.RemoveLabelForIssue(ctx, repoID.Owner, repoID.Repo, issueNum, label) 629 if ge, ok := err.(*github.ErrorResponse); ok && ge.Response != nil && ge.Response.StatusCode == http.StatusNotFound { 630 return nil 631 } 632 return err 633 } 634 635 func (b *gopherbot) setMilestone(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, m milestone) error { 636 printIssue("milestone-"+m.Name, repoID, gi) 637 if *dryRun { 638 return nil 639 } 640 _, _, err := b.ghc.Issues.Edit(ctx, repoID.Owner, repoID.Repo, int(gi.Number), &github.IssueRequest{ 641 Milestone: github.Int(m.Number), 642 }) 643 return err 644 } 645 646 func (b *gopherbot) addGitHubComment(ctx context.Context, repo *maintner.GitHubRepo, issueNum int32, msg string) error { 647 var since time.Time 648 if gi := repo.Issue(issueNum); gi != nil { 649 dup := false 650 gi.ForeachComment(func(c *maintner.GitHubComment) error { 651 since = c.Updated 652 // TODO: check for gopherbot as author? check for exact match? 653 // This seems fine for now. 654 if strings.Contains(c.Body, msg) { 655 dup = true 656 return errStopIteration 657 } 658 return nil 659 }) 660 if dup { 661 // Comment's already been posted. Nothing to do. 662 return nil 663 } 664 } 665 // See if there is a dup comment from when gopherbot last got 666 // its data from maintner. 667 opt := &github.IssueListCommentsOptions{ListOptions: github.ListOptions{PerPage: 1000}} 668 if !since.IsZero() { 669 opt.Since = &since 670 } 671 ics, resp, err := b.ghc.Issues.ListComments(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), opt) 672 if err != nil { 673 // TODO(golang/go#40640) - This issue was transferred or otherwise is gone. We should permanently skip it. This 674 // is a temporary fix to keep gopherbot working. 675 if resp != nil && resp.StatusCode == http.StatusNotFound { 676 log.Printf("addGitHubComment: Issue %v#%v returned a 404 when trying to load comments. Skipping. See golang/go#40640.", repo.ID(), issueNum) 677 b.deletedIssues[githubIssue{repo.ID(), issueNum}] = true 678 return nil 679 } 680 return err 681 } 682 for _, ic := range ics { 683 if strings.Contains(ic.GetBody(), msg) { 684 // Dup. 685 return nil 686 } 687 } 688 if *dryRun { 689 log.Printf("[dry-run] would add comment to github.com/%s/issues/%d: %v", repo.ID(), issueNum, msg) 690 return nil 691 } 692 _, resp, createError := b.ghc.Issues.CreateComment(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), &github.IssueComment{ 693 Body: github.String(msg), 694 }) 695 if createError != nil && resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { 696 // While maintner's tracking of deleted issues is incomplete (see go.dev/issue/30184), 697 // we sometimes see a deleted issue whose /comments endpoint returns 200 OK with an 698 // empty list, so the error check from ListComments doesn't catch it. (The deleted 699 // issue 55403 is an example of such a case.) So check again with the Get endpoint, 700 // which seems to return 404 more reliably in such cases at least as of 2022-10-11. 701 if _, resp, err := b.ghc.Issues.Get(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum)); err != nil && 702 resp != nil && resp.StatusCode == http.StatusNotFound { 703 log.Printf("addGitHubComment: Issue %v#%v returned a 404 after posting comment failed with 422. Skipping. See go.dev/issue/30184.", repo.ID(), issueNum) 704 b.deletedIssues[githubIssue{repo.ID(), issueNum}] = true 705 return nil 706 } 707 } 708 return createError 709 } 710 711 // createGitHubIssue returns the number of the created issue, or 4242 in dry-run mode. 712 // baseEvent is the timestamp of the event causing this action, and is used for de-duplication. 713 func (b *gopherbot) createGitHubIssue(ctx context.Context, title, msg string, labels []string, baseEvent time.Time) (int, error) { 714 var dup int 715 b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error { 716 // TODO: check for gopherbot as author? check for exact match? 717 // This seems fine for now. 718 if gi.Title == title { 719 dup = int(gi.Number) 720 return errStopIteration 721 } 722 return nil 723 }) 724 if dup != 0 { 725 // Issue's already been posted. Nothing to do. 726 return dup, nil 727 } 728 // See if there is a dup issue from when gopherbot last got its data from maintner. 729 is, _, err := b.ghc.Issues.ListByRepo(ctx, "golang", "go", &github.IssueListByRepoOptions{ 730 State: "all", 731 ListOptions: github.ListOptions{PerPage: 100}, 732 Since: baseEvent, 733 }) 734 if err != nil { 735 return 0, err 736 } 737 for _, i := range is { 738 if i.GetTitle() == title { 739 // Dup. 740 return i.GetNumber(), nil 741 } 742 } 743 if *dryRun { 744 log.Printf("[dry-run] would create issue with title %s and labels %v\n%s", title, labels, msg) 745 return 4242, nil 746 } 747 i, _, err := b.ghc.Issues.Create(ctx, "golang", "go", &github.IssueRequest{ 748 Title: github.String(title), 749 Body: github.String(msg), 750 Labels: &labels, 751 }) 752 return i.GetNumber(), err 753 } 754 755 // issueCloseReason is a reason given when closing an issue on GitHub. 756 // See https://docs.github.com/en/issues/tracking-your-work-with-issues/closing-an-issue. 757 type issueCloseReason *string 758 759 var ( 760 completed issueCloseReason = github.String("completed") // Done, closed, fixed, resolved. 761 notPlanned issueCloseReason = github.String("not_planned") // Won't fix, can't repro, duplicate, stale. 762 ) 763 764 // closeGitHubIssue closes a GitHub issue. 765 // reason specifies why it's being closed. (GitHub's default reason on 2023-06-12 is "completed".) 766 func (b *gopherbot) closeGitHubIssue(ctx context.Context, repoID maintner.GitHubRepoID, number int32, reason issueCloseReason) error { 767 if *dryRun { 768 var suffix string 769 if reason != nil { 770 suffix = " as " + *reason 771 } 772 log.Printf("[dry-run] would close go.dev/issue/%v%s", number, suffix) 773 return nil 774 } 775 _, _, err := b.ghc.Issues.Edit(ctx, repoID.Owner, repoID.Repo, int(number), &github.IssueRequest{ 776 State: github.String("closed"), 777 StateReason: reason, 778 }) 779 return err 780 } 781 782 type gerritCommentOpts struct { 783 OldPhrases []string 784 Version string // if empty, latest version is used 785 } 786 787 var emptyGerritCommentOpts gerritCommentOpts 788 789 // addGerritComment adds the given comment to the CL specified by the changeID 790 // and the patch set identified by the version. 791 // 792 // As an idempotence check, before adding the comment and the list 793 // of oldPhrases are checked against the CL to ensure that no phrase in the list 794 // has already been added to the list as a comment. 795 func (b *gopherbot) addGerritComment(ctx context.Context, changeID, comment string, opts *gerritCommentOpts) error { 796 if b == nil { 797 panic("nil gopherbot") 798 } 799 if *dryRun { 800 log.Printf("[dry-run] would add comment to golang.org/cl/%s: %v", changeID, comment) 801 return nil 802 } 803 if opts == nil { 804 opts = &emptyGerritCommentOpts 805 } 806 // One final staleness check before sending a message: get the list 807 // of comments from the API and check whether any of them match. 808 info, err := b.gerrit.GetChange(ctx, changeID, gerrit.QueryChangesOpt{ 809 Fields: []string{"MESSAGES", "CURRENT_REVISION"}, 810 }) 811 if err != nil { 812 return err 813 } 814 for _, msg := range info.Messages { 815 if strings.Contains(msg.Message, comment) { 816 return nil // Our comment is already there 817 } 818 for j := range opts.OldPhrases { 819 // Message looks something like "Patch set X:\n\n(our text)" 820 if strings.Contains(msg.Message, opts.OldPhrases[j]) { 821 return nil // Our comment is already there 822 } 823 } 824 } 825 var rev string 826 if opts.Version != "" { 827 rev = opts.Version 828 } else { 829 rev = info.CurrentRevision 830 } 831 return b.gerrit.SetReview(ctx, changeID, rev, gerrit.ReviewInput{ 832 Message: comment, 833 }) 834 } 835 836 // Move any issue to "Unplanned" if it looks like it keeps getting kicked along between releases. 837 func (b *gopherbot) getOffKickTrain(ctx context.Context) error { 838 // We only run this task if it was explicitly requested via 839 // the --only-run flag. 840 if *onlyRun == "" { 841 return nil 842 } 843 type match struct { 844 url string 845 title string 846 gi *maintner.GitHubIssue 847 } 848 var matches []match 849 b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 850 curMilestone := gi.Milestone.Title 851 if !strings.HasPrefix(curMilestone, "Go1.") || strings.Count(curMilestone, ".") != 1 { 852 return nil 853 } 854 if gi.HasLabel("release-blocker") || gi.HasLabel("Security") { 855 return nil 856 } 857 if len(gi.Assignees) > 0 { 858 return nil 859 } 860 was := map[string]bool{} 861 gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { 862 if e.Type == "milestoned" { 863 switch e.Milestone { 864 case "Unreleased", "Unplanned", "Proposal": 865 return nil 866 } 867 if strings.Count(e.Milestone, ".") > 1 { 868 return nil 869 } 870 ms := strings.TrimSuffix(e.Milestone, "Maybe") 871 ms = strings.TrimSuffix(ms, "Early") 872 was[ms] = true 873 } 874 return nil 875 }) 876 if len(was) > 2 { 877 var mss []string 878 for ms := range was { 879 mss = append(mss, ms) 880 } 881 sort.Slice(mss, func(i, j int) bool { 882 if len(mss[i]) == len(mss[j]) { 883 return mss[i] < mss[j] 884 } 885 return len(mss[i]) < len(mss[j]) 886 }) 887 matches = append(matches, match{ 888 url: fmt.Sprintf("https://go.dev/issue/%d", gi.Number), 889 title: fmt.Sprintf("%s - %v", gi.Title, mss), 890 gi: gi, 891 }) 892 } 893 return nil 894 }) 895 sort.Slice(matches, func(i, j int) bool { 896 return matches[i].title < matches[j].title 897 }) 898 fmt.Printf("%d issues:\n", len(matches)) 899 for _, m := range matches { 900 fmt.Printf("%-30s - %s\n", m.url, m.title) 901 if !*dryRun { 902 if err := b.setMilestone(ctx, b.gorepo.ID(), m.gi, unplanned); err != nil { 903 return err 904 } 905 } 906 } 907 return nil 908 } 909 910 // freezeOldIssues locks any issue that's old and closed. 911 // (Otherwise people find ancient bugs via searches and start asking questions 912 // into a void and it's sad for everybody.) 913 // This method doesn't need to explicitly avoid edit wars with humans because 914 // it bails out if the issue was edited recently. A human unlocking an issue 915 // causes the updated time to bump, which means the bot wouldn't try to lock it 916 // again for another year. 917 func (b *gopherbot) freezeOldIssues(ctx context.Context) error { 918 tooOld := time.Now().Add(-365 * 24 * time.Hour) 919 return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { 920 if !gardenIssues(repo) { 921 return nil 922 } 923 if !repoHasLabel(repo, frozenDueToAge) { 924 return nil 925 } 926 return b.foreachIssue(repo, closed, func(gi *maintner.GitHubIssue) error { 927 if gi.Locked || gi.Updated.After(tooOld) { 928 return nil 929 } 930 printIssue("freeze", repo.ID(), gi) 931 if *dryRun { 932 return nil 933 } 934 _, err := b.ghc.Issues.Lock(ctx, repo.ID().Owner, repo.ID().Repo, int(gi.Number), nil) 935 if ge, ok := err.(*github.ErrorResponse); ok && ge.Response.StatusCode == http.StatusNotFound { 936 // An issue can become 404 on GitHub due to being deleted or transferred. See go.dev/issue/30182. 937 b.deletedIssues[githubIssue{repo.ID(), gi.Number}] = true 938 return nil 939 } else if err != nil { 940 return err 941 } 942 return b.addLabel(ctx, repo.ID(), gi, frozenDueToAge) 943 }) 944 }) 945 } 946 947 // labelProposals adds the "Proposal" label and "Proposal" milestone 948 // to open issues with title beginning with "Proposal:". It tries not 949 // to get into an edit war with a human. 950 func (b *gopherbot) labelProposals(ctx context.Context) error { 951 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 952 if !strings.HasPrefix(gi.Title, "proposal:") && !strings.HasPrefix(gi.Title, "Proposal:") { 953 return nil 954 } 955 // Add Milestone if missing: 956 if gi.Milestone.IsNone() && !gi.HasEvent("milestoned") && !gi.HasEvent("demilestoned") { 957 if err := b.setMilestone(ctx, b.gorepo.ID(), gi, proposal); err != nil { 958 return err 959 } 960 } 961 // Add Proposal label if missing: 962 if !gi.HasLabel("Proposal") && !gi.HasEvent("unlabeled") { 963 if err := b.addLabel(ctx, b.gorepo.ID(), gi, "Proposal"); err != nil { 964 return err 965 } 966 } 967 968 // Remove NeedsDecision label if exists, but not for Go 2 issues: 969 if !isGo2Issue(gi) && gi.HasLabel("NeedsDecision") && !gopherbotRemovedLabel(gi, "NeedsDecision") { 970 if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "NeedsDecision"); err != nil { 971 return err 972 } 973 } 974 return nil 975 }) 976 } 977 978 // gopherbotRemovedLabel reports whether gopherbot has 979 // previously removed label in the GitHub issue gi. 980 // 981 // Note that until golang.org/issue/28226 is resolved, 982 // there's a brief delay before maintner catches up on 983 // GitHub issue events and learns that it has happened. 984 func gopherbotRemovedLabel(gi *maintner.GitHubIssue, label string) bool { 985 var hasRemoved bool 986 gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { 987 if e.Actor != nil && e.Actor.ID == gopherbotGitHubID && 988 e.Type == "unlabeled" && 989 e.Label == label { 990 hasRemoved = true 991 return errStopIteration 992 } 993 return nil 994 }) 995 return hasRemoved 996 } 997 998 // isGo2Issue reports whether gi seems like it's about Go 2, based on either labels or its title. 999 func isGo2Issue(gi *maintner.GitHubIssue) bool { 1000 if gi.HasLabel("Go2") { 1001 return true 1002 } 1003 if !strings.Contains(gi.Title, "2") { 1004 // Common case. 1005 return false 1006 } 1007 return strings.Contains(gi.Title, "Go 2") || strings.Contains(gi.Title, "go2") || strings.Contains(gi.Title, "Go2") 1008 } 1009 1010 func (b *gopherbot) setSubrepoMilestones(ctx context.Context) error { 1011 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1012 if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { 1013 return nil 1014 } 1015 if !strings.HasPrefix(gi.Title, "x/") { 1016 return nil 1017 } 1018 pkg := gi.Title 1019 if colon := strings.IndexByte(pkg, ':'); colon >= 0 { 1020 pkg = pkg[:colon] 1021 } 1022 if sp := strings.IndexByte(pkg, ' '); sp >= 0 { 1023 pkg = pkg[:sp] 1024 } 1025 switch pkg { 1026 case "", 1027 "x/arch", 1028 "x/crypto/chacha20poly1305", 1029 "x/crypto/curve25519", 1030 "x/crypto/poly1305", 1031 "x/net/http2", 1032 "x/net/idna", 1033 "x/net/lif", 1034 "x/net/proxy", 1035 "x/net/route", 1036 "x/text/unicode/norm", 1037 "x/text/width": 1038 // These get vendored in. Don't mess with them. 1039 return nil 1040 case "x/vgo": 1041 // Handled by setMiscMilestones 1042 return nil 1043 } 1044 return b.setMilestone(ctx, b.gorepo.ID(), gi, unreleased) 1045 }) 1046 } 1047 1048 func (b *gopherbot) setMiscMilestones(ctx context.Context) error { 1049 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1050 if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { 1051 return nil 1052 } 1053 if strings.Contains(gi.Title, "gccgo") { // TODO: better gccgo bug report heuristic? 1054 return b.setMilestone(ctx, b.gorepo.ID(), gi, gccgo) 1055 } 1056 if strings.HasPrefix(gi.Title, "x/vgo") { 1057 return b.setMilestone(ctx, b.gorepo.ID(), gi, vgo) 1058 } 1059 if strings.HasPrefix(gi.Title, "x/vuln") { 1060 return b.setMilestone(ctx, b.gorepo.ID(), gi, vulnUnplanned) 1061 } 1062 return nil 1063 }) 1064 } 1065 1066 func (b *gopherbot) setVSCodeGoMilestones(ctx context.Context) error { 1067 vscode := b.corpus.GitHub().Repo("golang", "vscode-go") 1068 if vscode == nil { 1069 return nil 1070 } 1071 return b.foreachIssue(vscode, open, func(gi *maintner.GitHubIssue) error { 1072 if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { 1073 return nil 1074 } 1075 // Work-around golang/go#40640 by only milestoning new issues. 1076 if time.Since(gi.Created) > 24*time.Hour { 1077 return nil 1078 } 1079 return b.setMilestone(ctx, vscode.ID(), gi, vscodeUntriaged) 1080 }) 1081 } 1082 1083 func (b *gopherbot) labelBuildIssues(ctx context.Context) error { 1084 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1085 if !strings.HasPrefix(gi.Title, "x/build") || gi.HasLabel("Builders") || gi.HasEvent("unlabeled") { 1086 return nil 1087 } 1088 return b.addLabel(ctx, b.gorepo.ID(), gi, "Builders") 1089 }) 1090 } 1091 1092 func (b *gopherbot) labelCompilerRuntimeIssues(ctx context.Context) error { 1093 entries, err := getAllCodeOwners(ctx) 1094 if err != nil { 1095 return err 1096 } 1097 // Filter out any entries that don't contain compiler/runtime owners into 1098 // a set of compiler/runtime-owned packages whose names match the names 1099 // used in the issue tracker. 1100 crtPackages := make(map[string]struct{}) // Key is issue title prefix, like "cmd/compile" or "x/sys/unix." 1101 for pkg, entry := range entries { 1102 for _, owner := range entry.Primary { 1103 name := owner.GitHubUsername 1104 if name == "golang/compiler" || name == "golang/runtime" { 1105 crtPackages[owners.TranslatePathForIssues(pkg)] = struct{}{} 1106 break 1107 } 1108 } 1109 } 1110 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1111 if gi.HasLabel("compiler/runtime") || gi.HasEvent("unlabeled") { 1112 return nil 1113 } 1114 components := strings.SplitN(gi.Title, ":", 2) 1115 if len(components) != 2 { 1116 return nil 1117 } 1118 for _, p := range strings.Split(strings.TrimSpace(components[0]), ",") { 1119 if _, ok := crtPackages[strings.TrimSpace(p)]; !ok { 1120 continue 1121 } 1122 // TODO(mknyszek): Add this issue to the GitHub project as well. 1123 return b.addLabel(ctx, b.gorepo.ID(), gi, "compiler/runtime") 1124 } 1125 return nil 1126 }) 1127 } 1128 1129 func (b *gopherbot) labelMobileIssues(ctx context.Context) error { 1130 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1131 if !strings.HasPrefix(gi.Title, "x/mobile") || gi.HasLabel("mobile") || gi.HasEvent("unlabeled") { 1132 return nil 1133 } 1134 return b.addLabel(ctx, b.gorepo.ID(), gi, "mobile") 1135 }) 1136 } 1137 1138 func (b *gopherbot) labelDocumentationIssues(ctx context.Context) error { 1139 const documentation = "Documentation" 1140 return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { 1141 if !gardenIssues(repo) { 1142 return nil 1143 } 1144 if !repoHasLabel(repo, documentation) { 1145 return nil 1146 } 1147 return b.foreachIssue(repo, open, func(gi *maintner.GitHubIssue) error { 1148 if !isDocumentationTitle(gi.Title) || gi.HasLabel("Documentation") || gi.HasEvent("unlabeled") { 1149 return nil 1150 } 1151 return b.addLabel(ctx, repo.ID(), gi, documentation) 1152 }) 1153 }) 1154 } 1155 1156 func (b *gopherbot) labelToolsIssues(ctx context.Context) error { 1157 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1158 if !strings.HasPrefix(gi.Title, "x/tools") || gi.HasLabel("Tools") || gi.HasEvent("unlabeled") { 1159 return nil 1160 } 1161 return b.addLabel(ctx, b.gorepo.ID(), gi, "Tools") 1162 }) 1163 } 1164 1165 func (b *gopherbot) labelWebsiteIssues(ctx context.Context) error { 1166 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1167 hasWebsiteTitle := strings.HasPrefix(gi.Title, "x/website:") 1168 if !hasWebsiteTitle || gi.HasLabel("website") || gi.HasEvent("unlabeled") { 1169 return nil 1170 } 1171 return b.addLabel(ctx, b.gorepo.ID(), gi, "website") 1172 }) 1173 } 1174 1175 func (b *gopherbot) labelPkgsiteIssues(ctx context.Context) error { 1176 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1177 hasPkgsiteTitle := strings.HasPrefix(gi.Title, "x/pkgsite:") 1178 if !hasPkgsiteTitle || gi.HasLabel("pkgsite") || gi.HasEvent("unlabeled") { 1179 return nil 1180 } 1181 return b.addLabel(ctx, b.gorepo.ID(), gi, "pkgsite") 1182 }) 1183 } 1184 1185 func (b *gopherbot) labelProxyIssues(ctx context.Context) error { 1186 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1187 hasProxyTitle := strings.Contains(gi.Title, "proxy.golang.org") || strings.Contains(gi.Title, "sum.golang.org") || strings.Contains(gi.Title, "index.golang.org") 1188 if !hasProxyTitle || gi.HasLabel("proxy.golang.org") || gi.HasEvent("unlabeled") { 1189 return nil 1190 } 1191 return b.addLabel(ctx, b.gorepo.ID(), gi, "proxy.golang.org") 1192 }) 1193 } 1194 1195 func (b *gopherbot) labelVulnIssues(ctx context.Context) error { 1196 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1197 hasVulnTitle := strings.HasPrefix(gi.Title, "x/vuln:") || strings.HasPrefix(gi.Title, "x/vuln/") || 1198 strings.HasPrefix(gi.Title, "x/vulndb:") || strings.HasPrefix(gi.Title, "x/vulndb/") 1199 if !hasVulnTitle || gi.HasLabel("vulncheck or vulndb") || gi.HasEvent("unlabeled") { 1200 return nil 1201 } 1202 return b.addLabel(ctx, b.gorepo.ID(), gi, "vulncheck or vulndb") 1203 }) 1204 } 1205 1206 // handleGoplsIssues labels and asks for additional information on gopls issues. 1207 // 1208 // This is necessary because gopls issues often require additional information to diagnose, 1209 // and we don't ask for this information in the Go issue template. 1210 func (b *gopherbot) handleGoplsIssues(ctx context.Context) error { 1211 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1212 if !isGoplsTitle(gi.Title) || gi.HasLabel("gopls") || gi.HasEvent("unlabeled") { 1213 return nil 1214 } 1215 return b.addLabel(ctx, b.gorepo.ID(), gi, "gopls") 1216 }) 1217 } 1218 1219 func (b *gopherbot) handleTelemetryIssues(ctx context.Context) error { 1220 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1221 if !strings.HasPrefix(gi.Title, "x/telemetry") || gi.HasLabel("telemetry") || gi.HasEvent("unlabeled") { 1222 return nil 1223 } 1224 return b.addLabel(ctx, b.gorepo.ID(), gi, "telemetry") 1225 }) 1226 } 1227 1228 func (b *gopherbot) closeStaleWaitingForInfo(ctx context.Context) error { 1229 const waitingForInfo = "WaitingForInfo" 1230 now := time.Now() 1231 return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { 1232 if !gardenIssues(repo) { 1233 return nil 1234 } 1235 if !repoHasLabel(repo, waitingForInfo) { 1236 return nil 1237 } 1238 return b.foreachIssue(repo, open, func(gi *maintner.GitHubIssue) error { 1239 if !gi.HasLabel(waitingForInfo) { 1240 return nil 1241 } 1242 var waitStart time.Time 1243 gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { 1244 if e.Type == "reopened" { 1245 // Ignore any previous WaitingForInfo label if it's reopend. 1246 waitStart = time.Time{} 1247 return nil 1248 } 1249 if e.Label == waitingForInfo { 1250 switch e.Type { 1251 case "unlabeled": 1252 waitStart = time.Time{} 1253 case "labeled": 1254 waitStart = e.Created 1255 } 1256 return nil 1257 } 1258 return nil 1259 }) 1260 if waitStart.IsZero() { 1261 return nil 1262 } 1263 1264 deadline := waitStart.AddDate(0, 1, 0) // 1 month 1265 if gi.HasLabel("CherryPickCandidate") || gi.HasLabel("CherryPickApproved") { 1266 // Cherry-pick issues may sometimes need to wait while 1267 // fixes get prepared and soak, so give them more time. 1268 deadline = waitStart.AddDate(0, 6, 0) 1269 } 1270 if repo.ID().Repo == "vscode-go" && gi.HasLabel("automatedReport") { 1271 // Automated issue reports have low response rates. 1272 // Apply shorter timeout. 1273 deadline = waitStart.AddDate(0, 0, 7) 1274 } 1275 if now.Before(deadline) { 1276 return nil 1277 } 1278 1279 var lastOPComment time.Time 1280 gi.ForeachComment(func(c *maintner.GitHubComment) error { 1281 if c.User.ID == gi.User.ID { 1282 lastOPComment = c.Created 1283 } 1284 return nil 1285 }) 1286 if lastOPComment.After(waitStart) { 1287 return nil 1288 } 1289 1290 printIssue("close-stale-waiting-for-info", repo.ID(), gi) 1291 // TODO: write a task that reopens issues if the OP speaks up. 1292 if err := b.addGitHubComment(ctx, repo, gi.Number, 1293 "Timed out in state WaitingForInfo. Closing.\n\n(I am just a bot, though. Please speak up if this is a mistake or you have the requested information.)"); err != nil { 1294 return fmt.Errorf("b.addGitHubComment(_, %v, %v) = %w", repo.ID(), gi.Number, err) 1295 } 1296 return b.closeGitHubIssue(ctx, repo.ID(), gi.Number, notPlanned) 1297 }) 1298 }) 1299 } 1300 1301 // cl2issue writes "Change https://go.dev/cl/NNNN mentions this issue" 1302 // and the change summary on GitHub when a new Gerrit change references a GitHub issue. 1303 func (b *gopherbot) cl2issue(ctx context.Context) error { 1304 monthAgo := time.Now().Add(-30 * 24 * time.Hour) 1305 return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 1306 if gp.Server() != "go.googlesource.com" { 1307 return nil 1308 } 1309 return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { 1310 if cl.Meta.Commit.AuthorTime.Before(monthAgo) { 1311 // If the CL was last updated over a 1312 // month ago, assume (as an 1313 // optimization) that gopherbot 1314 // already processed this issue. 1315 return nil 1316 } 1317 for _, ref := range cl.GitHubIssueRefs { 1318 if !gardenIssues(ref.Repo) { 1319 continue 1320 } 1321 gi := ref.Repo.Issue(ref.Number) 1322 if gi == nil || gi.NotExist || gi.PullRequest || gi.Locked || b.deletedIssues[githubIssue{ref.Repo.ID(), gi.Number}] { 1323 continue 1324 } 1325 hasComment := false 1326 substr := fmt.Sprintf("%d mentions this issue", cl.Number) 1327 gi.ForeachComment(func(c *maintner.GitHubComment) error { 1328 if strings.Contains(c.Body, substr) { 1329 hasComment = true 1330 return errStopIteration 1331 } 1332 return nil 1333 }) 1334 if hasComment { 1335 continue 1336 } 1337 printIssue("cl2issue", ref.Repo.ID(), gi) 1338 msg := fmt.Sprintf("Change https://go.dev/cl/%d mentions this issue: `%s`", cl.Number, cl.Commit.Summary()) 1339 if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, msg); err != nil { 1340 return err 1341 } 1342 } 1343 return nil 1344 }) 1345 }) 1346 } 1347 1348 // canonicalLabelName returns "needsfix" for "needs-fix" or "NeedsFix" 1349 // in prep for future label renaming. 1350 func canonicalLabelName(s string) string { 1351 return strings.Replace(strings.ToLower(s), "-", "", -1) 1352 } 1353 1354 // If an issue has multiple "needs" labels, remove all but the most recent. 1355 // These were originally called NeedsFix, NeedsDecision, and NeedsInvestigation, 1356 // but are being renamed to "needs-foo". 1357 func (b *gopherbot) updateNeeds(ctx context.Context) error { 1358 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1359 var numNeeds int 1360 if gi.Labels[needsDecisionID] != nil { 1361 numNeeds++ 1362 } 1363 if gi.Labels[needsFixID] != nil { 1364 numNeeds++ 1365 } 1366 if gi.Labels[needsInvestigationID] != nil { 1367 numNeeds++ 1368 } 1369 if numNeeds <= 1 { 1370 return nil 1371 } 1372 1373 labels := map[string]int{} // lowercase no-hyphen "needsfix" -> position 1374 var pos, maxPos int 1375 gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { 1376 var add bool 1377 switch e.Type { 1378 case "labeled": 1379 add = true 1380 case "unlabeled": 1381 default: 1382 return nil 1383 } 1384 if !strings.HasPrefix(e.Label, "Needs") && !strings.HasPrefix(e.Label, "needs-") { 1385 return nil 1386 } 1387 key := canonicalLabelName(e.Label) 1388 pos++ 1389 if add { 1390 labels[key] = pos 1391 maxPos = pos 1392 } else { 1393 delete(labels, key) 1394 } 1395 return nil 1396 }) 1397 if len(labels) <= 1 { 1398 return nil 1399 } 1400 1401 // Remove any label that's not the newest (added in 1402 // last position). 1403 for _, lab := range gi.Labels { 1404 key := canonicalLabelName(lab.Name) 1405 if !strings.HasPrefix(key, "needs") || labels[key] == maxPos { 1406 continue 1407 } 1408 printIssue("updateneeds", b.gorepo.ID(), gi) 1409 fmt.Printf("\t... removing label %q\n", lab.Name) 1410 if err := b.removeLabel(ctx, b.gorepo.ID(), gi, lab.Name); err != nil { 1411 return err 1412 } 1413 } 1414 return nil 1415 }) 1416 } 1417 1418 // TODO: Improve this message. Some ideas: 1419 // 1420 // Provide more helpful info? Amend, don't add 2nd commit, link to a review guide? 1421 // Make this a template? May want to provide more dynamic information in the future. 1422 // Only show freeze message during freeze. 1423 const ( 1424 congratsSentence = `Congratulations on opening your first change. Thank you for your contribution!` 1425 1426 defaultCongratsMsg = congratsSentence + ` 1427 1428 Next steps: 1429 A maintainer will review your change and provide feedback. See 1430 https://go.dev/doc/contribute#review for more info and tips to get your 1431 patch through code review. 1432 1433 Most changes in the Go project go through a few rounds of revision. This can be 1434 surprising to people new to the project. The careful, iterative review process 1435 is our way of helping mentor contributors and ensuring that their contributions 1436 have a lasting impact.` 1437 1438 // Not all x/ repos are subject to the freeze, and so shouldn't get the 1439 // warning about it. See isSubjectToFreeze for the complete list. 1440 freezeCongratsMsg = defaultCongratsMsg + ` 1441 1442 During May-July and Nov-Jan the Go project is in a code freeze, during which 1443 little code gets reviewed or merged. If a reviewer responds with a comment like 1444 R=go1.11 or adds a tag like "wait-release", it means that this CL will be 1445 reviewed as part of the next development cycle. See https://go.dev/s/release 1446 for more details.` 1447 ) 1448 1449 // If messages containing any of the sentences in this array have been posted 1450 // on a CL, don't post again. If you amend the message even slightly, please 1451 // prepend the new message to this list, to avoid re-spamming people. 1452 // 1453 // The first message is the "current" message. 1454 var oldCongratsMsgs = []string{ 1455 congratsSentence, 1456 `It's your first ever CL! Congrats, and thanks for sending!`, 1457 } 1458 1459 // isSubjectToFreeze returns true if a repository is subject to the release 1460 // freeze. x/ repos can be subject if they are vendored into golang/go. 1461 func isSubjectToFreeze(repo string) bool { 1462 switch repo { 1463 case "go": // main repo 1464 return true 1465 case "crypto", "net", "sys", "text": // vendored x/ repos 1466 return true 1467 } 1468 return false 1469 } 1470 1471 // Don't want to congratulate people on CL's they submitted a year ago. 1472 var congratsEpoch = time.Date(2017, 6, 17, 0, 0, 0, 0, time.UTC) 1473 1474 func (b *gopherbot) congratulateNewContributors(ctx context.Context) error { 1475 cls := make(map[string]*maintner.GerritCL) 1476 b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 1477 if gp.Server() != "go.googlesource.com" { 1478 return nil 1479 } 1480 return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { 1481 // CLs can be returned by maintner in any order. Note also that 1482 // Gerrit CL numbers are sparse (CL N does not guarantee that CL N-1 1483 // exists) and Gerrit issues CL's out of order - it may issue CL N, 1484 // then CL (N - 18), then CL (N - 40). 1485 if b.knownContributors == nil { 1486 b.knownContributors = make(map[string]bool) 1487 } 1488 if cl.Commit == nil { 1489 return nil 1490 } 1491 email := cl.Commit.Author.Email() 1492 if email == "" { 1493 email = cl.Commit.Author.Str 1494 } 1495 if b.knownContributors[email] { 1496 return nil 1497 } 1498 if cls[email] != nil { 1499 // this person has multiple CLs; not a new contributor. 1500 b.knownContributors[email] = true 1501 delete(cls, email) 1502 return nil 1503 } 1504 cls[email] = cl 1505 return nil 1506 }) 1507 }) 1508 for email, cl := range cls { 1509 // See golang.org/issue/23865 1510 if cl.Branch() == "refs/meta/config" { 1511 b.knownContributors[email] = true 1512 continue 1513 } 1514 if cl.Commit == nil || cl.Commit.CommitTime.Before(congratsEpoch) { 1515 b.knownContributors[email] = true 1516 continue 1517 } 1518 if cl.Status == "merged" { 1519 b.knownContributors[email] = true 1520 continue 1521 } 1522 foundMessage := false 1523 congratulatoryMessage := defaultCongratsMsg 1524 if isSubjectToFreeze(cl.Project.Project()) { 1525 congratulatoryMessage = freezeCongratsMsg 1526 } 1527 for i := range cl.Messages { 1528 // TODO: once gopherbot starts posting these messages and we 1529 // have the author's name for Gopherbot, check the author name 1530 // matches as well. 1531 for j := range oldCongratsMsgs { 1532 // Message looks something like "Patch set X:\n\n(our text)" 1533 if strings.Contains(cl.Messages[i].Message, oldCongratsMsgs[j]) { 1534 foundMessage = true 1535 break 1536 } 1537 } 1538 if foundMessage { 1539 break 1540 } 1541 } 1542 1543 if foundMessage { 1544 b.knownContributors[email] = true 1545 continue 1546 } 1547 // Don't add all of the old congratulatory messages here, since we've 1548 // already checked for them above. 1549 opts := &gerritCommentOpts{ 1550 OldPhrases: []string{congratulatoryMessage}, 1551 } 1552 err := b.addGerritComment(ctx, cl.ChangeID(), congratulatoryMessage, opts) 1553 if err != nil { 1554 return fmt.Errorf("could not add comment to golang.org/cl/%d: %v", cl.Number, err) 1555 } 1556 b.knownContributors[email] = true 1557 } 1558 return nil 1559 } 1560 1561 // unwaitCLs removes wait-* hashtags from CLs. 1562 func (b *gopherbot) unwaitCLs(ctx context.Context) error { 1563 return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 1564 if gp.Server() != "go.googlesource.com" { 1565 return nil 1566 } 1567 return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { 1568 tags := cl.Meta.Hashtags() 1569 if tags.Len() == 0 { 1570 return nil 1571 } 1572 // If the CL is tagged "wait-author", remove 1573 // that tag if the author has replied since 1574 // the last time the "wait-author" tag was 1575 // added. 1576 if tags.Contains("wait-author") { 1577 // Figure out the last index at which "wait-author" was added. 1578 waitAuthorIndex := -1 1579 for i := len(cl.Metas) - 1; i >= 0; i-- { 1580 if cl.Metas[i].HashtagsAdded().Contains("wait-author") { 1581 waitAuthorIndex = i 1582 break 1583 } 1584 } 1585 1586 // Find out whether the author has replied since. 1587 authorEmail := cl.Metas[0].Commit.Author.Email() // Equivalent to "{{cl.OwnerID}}@62eb7196-b449-3ce5-99f1-c037f21e1705". 1588 hasReplied := false 1589 for _, m := range cl.Metas[waitAuthorIndex+1:] { 1590 if m.Commit.Author.Email() == authorEmail { 1591 hasReplied = true 1592 break 1593 } 1594 } 1595 if hasReplied { 1596 log.Printf("https://go.dev/cl/%d -- remove wait-author; reply from %s", cl.Number, cl.Owner()) 1597 err := b.onLatestCL(ctx, cl, func() error { 1598 if *dryRun { 1599 log.Printf("[dry run] would remove hashtag 'wait-author' from CL %d", cl.Number) 1600 return nil 1601 } 1602 _, err := b.gerrit.RemoveHashtags(ctx, fmt.Sprint(cl.Number), "wait-author") 1603 if err != nil { 1604 log.Printf("https://go.dev/cl/%d: error removing wait-author: %v", cl.Number, err) 1605 return err 1606 } 1607 log.Printf("https://go.dev/cl/%d: removed wait-author", cl.Number) 1608 return nil 1609 }) 1610 if err != nil { 1611 return err 1612 } 1613 } 1614 } 1615 return nil 1616 }) 1617 }) 1618 } 1619 1620 // onLatestCL checks whether cl's metadata is up to date with Gerrit's 1621 // upstream data and, if so, returns f(). If it's out of date, it does 1622 // nothing more and returns nil. 1623 func (b *gopherbot) onLatestCL(ctx context.Context, cl *maintner.GerritCL, f func() error) error { 1624 ci, err := b.gerrit.GetChangeDetail(ctx, fmt.Sprint(cl.Number), gerrit.QueryChangesOpt{Fields: []string{"MESSAGES"}}) 1625 if err != nil { 1626 return err 1627 } 1628 if len(ci.Messages) == 0 { 1629 log.Printf("onLatestCL: CL %d has no messages. Odd. Ignoring.", cl.Number) 1630 return nil 1631 } 1632 latestGerritID := ci.Messages[len(ci.Messages)-1].ID 1633 // Check all metas and not just the latest, because there are some meta commits 1634 // that don't have a corresponding message in the Gerrit REST API response. 1635 for i := len(cl.Metas) - 1; i >= 0; i-- { 1636 metaHash := cl.Metas[i].Commit.Hash.String() 1637 if metaHash == latestGerritID { 1638 // latestGerritID is contained by maintner metadata for this CL, so run f(). 1639 return f() 1640 } 1641 } 1642 log.Printf("onLatestCL: maintner metadata for CL %d is behind; skipping action for now.", cl.Number) 1643 return nil 1644 } 1645 1646 // fetchReleases returns the two most recent major Go 1.x releases, and 1647 // the next upcoming release, sorted and formatted like []string{"1.9", "1.10", "1.11"}. 1648 // It also returns the next minor release for each major release, 1649 // like map[string]string{"1.9": "1.9.7", "1.10": "1.10.4", "1.11": "1.11.1"}. 1650 // 1651 // The data returned is fetched from Maintner Service occasionally 1652 // and cached for some time. 1653 func (b *gopherbot) fetchReleases(ctx context.Context) (major []string, nextMinor map[string]string, _ error) { 1654 b.releases.Lock() 1655 defer b.releases.Unlock() 1656 1657 if expiry := b.releases.lastUpdate.Add(10 * time.Minute); time.Now().Before(expiry) { 1658 return b.releases.major, b.releases.nextMinor, nil 1659 } 1660 1661 ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 1662 defer cancel() 1663 resp, err := b.mc.ListGoReleases(ctx, &apipb.ListGoReleasesRequest{}) 1664 if err != nil { 1665 return nil, nil, err 1666 } 1667 rs := resp.Releases // Supported Go releases, sorted with latest first. 1668 1669 nextMinor = make(map[string]string) 1670 for i := len(rs) - 1; i >= 0; i-- { 1671 x, y, z := rs[i].Major, rs[i].Minor, rs[i].Patch 1672 major = append(major, fmt.Sprintf("%d.%d", x, y)) 1673 nextMinor[fmt.Sprintf("%d.%d", x, y)] = fmt.Sprintf("%d.%d.%d", x, y, z+1) 1674 } 1675 // Include the next release in the list of major releases. 1676 if len(rs) > 0 { 1677 // Assume the next major release after Go X.Y is Go X.(Y+1). This is true more often than not. 1678 nextX, nextY := rs[0].Major, rs[0].Minor+1 1679 major = append(major, fmt.Sprintf("%d.%d", nextX, nextY)) 1680 nextMinor[fmt.Sprintf("%d.%d", nextX, nextY)] = fmt.Sprintf("%d.%d.1", nextX, nextY) 1681 } 1682 1683 b.releases.major = major 1684 b.releases.nextMinor = nextMinor 1685 b.releases.lastUpdate = time.Now() 1686 1687 return major, nextMinor, nil 1688 } 1689 1690 // openCherryPickIssues opens CherryPickCandidate issues for backport when 1691 // asked on the main issue. 1692 func (b *gopherbot) openCherryPickIssues(ctx context.Context) error { 1693 return b.foreachIssue(b.gorepo, open|closed|includePRs, func(gi *maintner.GitHubIssue) error { 1694 if gi.HasLabel("CherryPickApproved") && gi.HasLabel("CherryPickCandidate") { 1695 if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "CherryPickCandidate"); err != nil { 1696 return err 1697 } 1698 } 1699 if gi.Locked || gi.PullRequest { 1700 return nil 1701 } 1702 var backportComment *maintner.GitHubComment 1703 if err := gi.ForeachComment(func(c *maintner.GitHubComment) error { 1704 if strings.HasPrefix(c.Body, "Backport issue(s) opened") { 1705 backportComment = nil 1706 return errStopIteration 1707 } 1708 body := strings.ToLower(c.Body) 1709 if strings.Contains(body, "@gopherbot") && 1710 strings.Contains(body, "please") && 1711 strings.Contains(body, "backport") { 1712 backportComment = c 1713 } 1714 return nil 1715 }); err != nil && err != errStopIteration { 1716 return err 1717 } 1718 if backportComment == nil { 1719 return nil 1720 } 1721 1722 // Figure out releases to open backport issues for. 1723 var selectedReleases []string 1724 majorReleases, _, err := b.fetchReleases(ctx) 1725 if err != nil { 1726 return err 1727 } 1728 for _, r := range majorReleases { 1729 if strings.Contains(backportComment.Body, r) { 1730 selectedReleases = append(selectedReleases, r) 1731 } 1732 } 1733 if len(selectedReleases) == 0 { 1734 // Only backport to major releases unless explicitly 1735 // asked to backport to the upcoming release. 1736 selectedReleases = majorReleases[:len(majorReleases)-1] 1737 } 1738 1739 // Figure out extra labels to include from the main issue. 1740 // Only copy a subset that's relevant to backport issue management. 1741 var extraLabels []string 1742 for _, l := range [...]string{ 1743 "Security", 1744 "GoCommand", 1745 "Testing", 1746 } { 1747 if gi.HasLabel(l) { 1748 extraLabels = append(extraLabels, l) 1749 } 1750 } 1751 1752 // Open backport issues. 1753 var openedIssues []string 1754 for _, rel := range selectedReleases { 1755 printIssue("open-backport-issue-"+rel, b.gorepo.ID(), gi) 1756 id, err := b.createGitHubIssue(ctx, 1757 fmt.Sprintf("%s [%s backport]", gi.Title, rel), 1758 fmt.Sprintf("@%s requested issue #%d to be considered for backport to the next %s minor release.\n\n%s\n", 1759 backportComment.User.Login, gi.Number, rel, blockqoute(backportComment.Body)), 1760 append([]string{"CherryPickCandidate"}, extraLabels...), backportComment.Created) 1761 if err != nil { 1762 return err 1763 } 1764 openedIssues = append(openedIssues, fmt.Sprintf("#%d (for %s)", id, rel)) 1765 } 1766 return b.addGitHubComment(ctx, b.gorepo, gi.Number, fmt.Sprintf("Backport issue(s) opened: %s.\n\nRemember to create the cherry-pick CL(s) as soon as the patch is submitted to master, according to https://go.dev/wiki/MinorReleases.", strings.Join(openedIssues, ", "))) 1767 }) 1768 } 1769 1770 // setMinorMilestones applies the next minor release milestone 1771 // to issues with [1.X backport] in the title. 1772 func (b *gopherbot) setMinorMilestones(ctx context.Context) error { 1773 majorReleases, nextMinor, err := b.fetchReleases(ctx) 1774 if err != nil { 1775 return err 1776 } 1777 return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1778 if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { 1779 return nil 1780 } 1781 var majorRel string 1782 for _, r := range majorReleases { 1783 if strings.Contains(gi.Title, "backport") && strings.HasSuffix(gi.Title, "["+r+" backport]") { 1784 majorRel = r 1785 } 1786 } 1787 if majorRel == "" { 1788 return nil 1789 } 1790 if _, ok := nextMinor[majorRel]; !ok { 1791 return fmt.Errorf("internal error: fetchReleases returned majorReleases=%q nextMinor=%q, and nextMinor doesn't have %q", majorReleases, nextMinor, majorRel) 1792 } 1793 lowerTitle := "go" + nextMinor[majorRel] 1794 var nextMinorMilestone milestone 1795 if b.gorepo.ForeachMilestone(func(m *maintner.GitHubMilestone) error { 1796 if m.Closed || strings.ToLower(m.Title) != lowerTitle { 1797 return nil 1798 } 1799 nextMinorMilestone = milestone{ 1800 Number: int(m.Number), 1801 Name: m.Title, 1802 } 1803 return errStopIteration 1804 }); nextMinorMilestone == (milestone{}) { 1805 // Fail silently, the milestone might not exist yet. 1806 log.Printf("Failed to apply minor release milestone to issue %d", gi.Number) 1807 return nil 1808 } 1809 return b.setMilestone(ctx, b.gorepo.ID(), gi, nextMinorMilestone) 1810 }) 1811 } 1812 1813 // closeCherryPickIssues closes cherry-pick issues when CLs are merged to 1814 // release branches, as GitHub only does that on merge to master. 1815 func (b *gopherbot) closeCherryPickIssues(ctx context.Context) error { 1816 cherryPickIssues := make(map[int32]*maintner.GitHubIssue) // by GitHub Issue Number 1817 b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { 1818 if gi.Milestone.IsNone() || gi.HasEvent("reopened") { 1819 return nil 1820 } 1821 if !strings.HasPrefix(gi.Milestone.Title, "Go") { 1822 return nil 1823 } 1824 cherryPickIssues[gi.Number] = gi 1825 return nil 1826 }) 1827 monthAgo := time.Now().Add(-30 * 24 * time.Hour) 1828 return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 1829 if gp.Server() != "go.googlesource.com" { 1830 return nil 1831 } 1832 return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { 1833 if cl.Commit.CommitTime.Before(monthAgo) { 1834 // If the CL was last updated over a month ago, assume (as an 1835 // optimization) that gopherbot already processed this CL. 1836 return nil 1837 } 1838 if cl.Status != "merged" || cl.Private || !strings.HasPrefix(cl.Branch(), "release-branch.") { 1839 return nil 1840 } 1841 clBranchVersion := cl.Branch()[len("release-branch."):] // "go1.11" or "go1.12". 1842 for _, ref := range cl.GitHubIssueRefs { 1843 if ref.Repo != b.gorepo { 1844 continue 1845 } 1846 gi, ok := cherryPickIssues[ref.Number] 1847 if !ok { 1848 continue 1849 } 1850 if !strutil.HasPrefixFold(gi.Milestone.Title, clBranchVersion) { 1851 // This issue's milestone (e.g., "Go1.11.6", "Go1.12", "Go1.12.1", etc.) 1852 // doesn't match the CL branch goX.Y version, so skip it. 1853 continue 1854 } 1855 printIssue("close-cherry-pick", ref.Repo.ID(), gi) 1856 if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, fmt.Sprintf( 1857 "Closed by merging %s to %s.", cl.Commit.Hash, cl.Branch())); err != nil { 1858 return err 1859 } 1860 return b.closeGitHubIssue(ctx, ref.Repo.ID(), gi.Number, completed) 1861 } 1862 return nil 1863 }) 1864 }) 1865 } 1866 1867 type labelCommand struct { 1868 action string // "add" or "remove" 1869 label string // the label name 1870 created time.Time // creation time of the comment containing the command 1871 noop bool // whether to apply the command or not 1872 } 1873 1874 // applyLabelsFromComments looks within open GitHub issues for commands to add or 1875 // remove labels. Anyone can use the /label <label> or /unlabel <label> commands. 1876 func (b *gopherbot) applyLabelsFromComments(ctx context.Context) error { 1877 return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { 1878 if !gardenIssues(repo) { 1879 return nil 1880 } 1881 1882 allLabels := make(map[string]string) // lowercase label name -> proper casing 1883 repo.ForeachLabel(func(gl *maintner.GitHubLabel) error { 1884 allLabels[strings.ToLower(gl.Name)] = gl.Name 1885 return nil 1886 }) 1887 1888 return b.foreachIssue(repo, open|includePRs, func(gi *maintner.GitHubIssue) error { 1889 var cmds []labelCommand 1890 1891 cmds = append(cmds, labelCommandsFromBody(gi.Body, gi.Created)...) 1892 gi.ForeachComment(func(gc *maintner.GitHubComment) error { 1893 cmds = append(cmds, labelCommandsFromBody(gc.Body, gc.Created)...) 1894 return nil 1895 }) 1896 1897 for i, c := range cmds { 1898 // Does the label even exist? If so, use the proper capitalization. 1899 // If it doesn't exist, the command is a no-op. 1900 if l, ok := allLabels[c.label]; ok { 1901 cmds[i].label = l 1902 } else { 1903 cmds[i].noop = true 1904 continue 1905 } 1906 1907 // If any action has been taken on the label since the comment containing 1908 // the command to add or remove it, then it should be a no-op. 1909 gi.ForeachEvent(func(ge *maintner.GitHubIssueEvent) error { 1910 if (ge.Type == "unlabeled" || ge.Type == "labeled") && 1911 strings.ToLower(ge.Label) == c.label && 1912 ge.Created.After(c.created) { 1913 cmds[i].noop = true 1914 return errStopIteration 1915 } 1916 return nil 1917 }) 1918 } 1919 1920 toAdd, toRemove := mutationsFromCommands(cmds) 1921 if err := b.addLabels(ctx, repo.ID(), gi, toAdd); err != nil { 1922 log.Printf("Unable to add labels (%v) to issue %d: %v", toAdd, gi.Number, err) 1923 } 1924 if err := b.removeLabels(ctx, repo.ID(), gi, toRemove); err != nil { 1925 log.Printf("Unable to remove labels (%v) from issue %d: %v", toRemove, gi.Number, err) 1926 } 1927 1928 return nil 1929 }) 1930 }) 1931 } 1932 1933 // labelCommandsFromBody returns a slice of commands inferred by the given body text. 1934 // The format of commands is: 1935 // @gopherbot[,] [please] [add|remove] <label>[{,|;} label... and remove <label>...] 1936 // Omission of add or remove will default to adding a label. 1937 func labelCommandsFromBody(body string, created time.Time) []labelCommand { 1938 if !strutil.ContainsFold(body, "@gopherbot") { 1939 return nil 1940 } 1941 var cmds []labelCommand 1942 lines := strings.Split(body, "\n") 1943 for _, l := range lines { 1944 if !strutil.ContainsFold(l, "@gopherbot") { 1945 continue 1946 } 1947 l = strings.ToLower(l) 1948 scanner := bufio.NewScanner(strings.NewReader(l)) 1949 scanner.Split(bufio.ScanWords) 1950 var ( 1951 add strings.Builder 1952 remove strings.Builder 1953 inRemove bool 1954 ) 1955 for scanner.Scan() { 1956 switch scanner.Text() { 1957 case "@gopherbot", "@gopherbot,", "@gopherbot:", "please", "and", "label", "labels": 1958 continue 1959 case "add": 1960 inRemove = false 1961 continue 1962 case "remove", "unlabel": 1963 inRemove = true 1964 continue 1965 } 1966 1967 if inRemove { 1968 remove.WriteString(scanner.Text()) 1969 remove.WriteString(" ") // preserve whitespace within labels 1970 } else { 1971 add.WriteString(scanner.Text()) 1972 add.WriteString(" ") // preserve whitespace within labels 1973 } 1974 } 1975 if add.Len() > 0 { 1976 cmds = append(cmds, labelCommands(add.String(), "add", created)...) 1977 } 1978 if remove.Len() > 0 { 1979 cmds = append(cmds, labelCommands(remove.String(), "remove", created)...) 1980 } 1981 } 1982 return cmds 1983 } 1984 1985 // labelCommands returns a slice of commands for the given action and string of 1986 // text following commands like @gopherbot add/remove. 1987 func labelCommands(s, action string, created time.Time) []labelCommand { 1988 var cmds []labelCommand 1989 f := func(c rune) bool { 1990 return c != '-' && !unicode.IsLetter(c) && !unicode.IsNumber(c) && !unicode.IsSpace(c) 1991 } 1992 for _, label := range strings.FieldsFunc(s, f) { 1993 label = strings.TrimSpace(label) 1994 if label == "" { 1995 continue 1996 } 1997 cmds = append(cmds, labelCommand{action: action, label: label, created: created}) 1998 } 1999 return cmds 2000 } 2001 2002 // mutationsFromCommands returns two sets of labels to add and remove based on 2003 // the given cmds. 2004 func mutationsFromCommands(cmds []labelCommand) (add, remove []string) { 2005 // Split the labels into what to add and what to remove. 2006 // Account for two opposing commands that have yet to be applied canceling 2007 // each other out. 2008 var ( 2009 toAdd map[string]bool 2010 toRemove map[string]bool 2011 ) 2012 for _, c := range cmds { 2013 if c.noop { 2014 continue 2015 } 2016 switch c.action { 2017 case "add": 2018 if toRemove[c.label] { 2019 delete(toRemove, c.label) 2020 continue 2021 } 2022 if toAdd == nil { 2023 toAdd = make(map[string]bool) 2024 } 2025 toAdd[c.label] = true 2026 case "remove": 2027 if toAdd[c.label] { 2028 delete(toAdd, c.label) 2029 continue 2030 } 2031 if toRemove == nil { 2032 toRemove = make(map[string]bool) 2033 } 2034 toRemove[c.label] = true 2035 default: 2036 log.Printf("Invalid label action type: %q", c.action) 2037 } 2038 } 2039 2040 for l := range toAdd { 2041 if toAdd[l] && !labelChangeDisallowed(l, "add") { 2042 add = append(add, l) 2043 } 2044 } 2045 2046 for l := range toRemove { 2047 if toRemove[l] && !labelChangeDisallowed(l, "remove") { 2048 remove = append(remove, l) 2049 } 2050 } 2051 return add, remove 2052 } 2053 2054 // labelChangeDisallowed reports whether an action on the given label is 2055 // forbidden via gopherbot. 2056 func labelChangeDisallowed(label, action string) bool { 2057 if action == "remove" && label == "Security" { 2058 return true 2059 } 2060 for _, prefix := range []string{ 2061 "CherryPick", 2062 "cla:", 2063 "Proposal-", 2064 } { 2065 if strings.HasPrefix(label, prefix) { 2066 return true 2067 } 2068 } 2069 return false 2070 } 2071 2072 // assignReviewersOptOut lists contributors who have opted out from 2073 // having reviewers automatically added to their CLs. 2074 var assignReviewersOptOut = map[string]bool{ 2075 "mdempsky@google.com": true, 2076 } 2077 2078 // assignReviewersToCLs looks for CLs with no humans in the reviewer or CC fields 2079 // that have been open for a short amount of time (enough of a signal that the 2080 // author does not intend to add anyone to the review), then assigns reviewers/CCs 2081 // using the go.dev/s/owners API. 2082 func (b *gopherbot) assignReviewersToCLs(ctx context.Context) error { 2083 const tagNoOwners = "no-owners" 2084 b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 2085 if gp.Project() == "scratch" || gp.Server() != "go.googlesource.com" { 2086 return nil 2087 } 2088 gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { 2089 if cl.Private || cl.WorkInProgress() || time.Since(cl.Created) < 10*time.Minute { 2090 return nil 2091 } 2092 if assignReviewersOptOut[cl.Owner().Email()] { 2093 return nil 2094 } 2095 2096 // Don't auto-assign reviewers to CLs on shared branches; 2097 // the presumption is that developers there will know which 2098 // reviewers to assign. 2099 if strings.HasPrefix(cl.Branch(), "dev.") { 2100 return nil 2101 } 2102 2103 tags := cl.Meta.Hashtags() 2104 if tags.Contains(tagNoOwners) { 2105 return nil 2106 } 2107 2108 gc := gerritChange{gp.Project(), cl.Number} 2109 if b.deletedChanges[gc] { 2110 return nil 2111 } 2112 if strutil.ContainsFold(cl.Commit.Msg, "do not submit") || strutil.ContainsFold(cl.Commit.Msg, "do not review") { 2113 return nil 2114 } 2115 2116 currentReviewers, ok := b.humanReviewersOnChange(ctx, gc, cl) 2117 if ok { 2118 return nil 2119 } 2120 log.Printf("humanReviewersOnChange reported insufficient reviewers or CC on CL %d, attempting to add some", cl.Number) 2121 2122 changeURL := fmt.Sprintf("https://go-review.googlesource.com/c/%s/+/%d", gp.Project(), cl.Number) 2123 files, err := b.gerrit.ListFiles(ctx, gc.ID(), cl.Commit.Hash.String()) 2124 if err != nil { 2125 log.Printf("Could not get change %+v: %v", gc, err) 2126 if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound { 2127 b.deletedChanges[gc] = true 2128 } 2129 return nil 2130 } 2131 2132 var paths []string 2133 for f := range files { 2134 if f == "/COMMIT_MSG" { 2135 continue 2136 } 2137 paths = append(paths, gp.Project()+"/"+f) 2138 } 2139 2140 entries, err := getCodeOwners(ctx, paths) 2141 if err != nil { 2142 log.Printf("Could not get owners for change %s: %v", changeURL, err) 2143 return nil 2144 } 2145 2146 // Remove owners that can't be reviewers. 2147 entries = filterGerritOwners(entries) 2148 2149 authorEmail := cl.Commit.Author.Email() 2150 merged := mergeOwnersEntries(entries, authorEmail) 2151 if len(merged.Primary) == 0 && len(merged.Secondary) == 0 { 2152 // No owners found for the change. Add the #no-owners tag. 2153 log.Printf("Adding no-owners tag to change %s...", changeURL) 2154 if *dryRun { 2155 return nil 2156 } 2157 if _, err := b.gerrit.AddHashtags(ctx, gc.ID(), tagNoOwners); err != nil { 2158 log.Printf("Could not add hashtag to change %q: %v", gc.ID(), err) 2159 return nil 2160 } 2161 return nil 2162 } 2163 2164 // Assign reviewers. 2165 var review gerrit.ReviewInput 2166 for _, owner := range merged.Primary { 2167 review.Reviewers = append(review.Reviewers, gerrit.ReviewerInput{Reviewer: owner.GerritEmail}) 2168 } 2169 for _, owner := range merged.Secondary { 2170 review.Reviewers = append(review.Reviewers, gerrit.ReviewerInput{Reviewer: owner.GerritEmail, State: "CC"}) 2171 } 2172 2173 // If the reviewers that would be set are the same as the existing 2174 // reviewers (minus the bots), there is no work to be done. 2175 if sameReviewers(currentReviewers, review) { 2176 log.Printf("Setting review %+v on %s would have no effect, continuing", review, changeURL) 2177 return nil 2178 } 2179 if *dryRun { 2180 log.Printf("[dry run] Would set review on %s: %+v", changeURL, review) 2181 return nil 2182 } 2183 log.Printf("Setting review on %s: %+v", changeURL, review) 2184 if err := b.gerrit.SetReview(ctx, gc.ID(), "current", review); err != nil { 2185 log.Printf("Could not set review for change %q: %v", gc.ID(), err) 2186 return nil 2187 } 2188 return nil 2189 }) 2190 return nil 2191 }) 2192 return nil 2193 } 2194 2195 func sameReviewers(reviewers []string, review gerrit.ReviewInput) bool { 2196 if len(reviewers) != len(review.Reviewers) { 2197 return false 2198 } 2199 sort.Strings(reviewers) 2200 var people []*gophers.Person 2201 for _, id := range reviewers { 2202 p := gophers.GetPerson(fmt.Sprintf("%s%s", id, gerritInstanceID)) 2203 // If an existing reviewer is not known to us, we have no way of 2204 // checking if these reviewer lists are identical. 2205 if p == nil { 2206 return false 2207 } 2208 people = append(people, p) 2209 } 2210 sort.Slice(review.Reviewers, func(i, j int) bool { 2211 return review.Reviewers[i].Reviewer < review.Reviewers[j].Reviewer 2212 }) 2213 // Check if any of the person's emails match the expected reviewer email. 2214 outer: 2215 for i, p := range people { 2216 reviewerEmail := review.Reviewers[i].Reviewer 2217 for _, email := range p.Emails { 2218 if email == reviewerEmail { 2219 continue outer 2220 } 2221 } 2222 return false 2223 } 2224 return true 2225 } 2226 2227 // abandonScratchReviews abandons Gerrit CLs in the "scratch" project if they've been open for over a week. 2228 func (b *gopherbot) abandonScratchReviews(ctx context.Context) error { 2229 tooOld := time.Now().Add(-24 * time.Hour * 7) 2230 return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 2231 if gp.Project() != "scratch" || gp.Server() != "go.googlesource.com" { 2232 return nil 2233 } 2234 return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { 2235 if b.deletedChanges[gerritChange{gp.Project(), cl.Number}] || !cl.Meta.Commit.CommitTime.Before(tooOld) { 2236 return nil 2237 } 2238 if *dryRun { 2239 log.Printf("[dry-run] would've closed scratch CL https://go.dev/cl/%d ...", cl.Number) 2240 return nil 2241 } 2242 log.Printf("closing scratch CL https://go.dev/cl/%d ...", cl.Number) 2243 err := b.gerrit.AbandonChange(ctx, fmt.Sprint(cl.Number), "Auto-abandoning old scratch review.") 2244 if err != nil && strings.Contains(err.Error(), "404 Not Found") { 2245 return nil 2246 } 2247 return err 2248 }) 2249 }) 2250 } 2251 2252 func (b *gopherbot) whoNeedsAccess(ctx context.Context) error { 2253 // We only run this task if it was explicitly requested via 2254 // the --only-run flag. 2255 if *onlyRun == "" { 2256 return nil 2257 } 2258 level := map[int64]int{} // gerrit id -> 1 for try, 2 for submit 2259 ais, err := b.gerrit.GetGroupMembers(ctx, "may-start-trybots") 2260 if err != nil { 2261 return err 2262 } 2263 for _, ai := range ais { 2264 level[ai.NumericID] = 1 2265 } 2266 ais, err = b.gerrit.GetGroupMembers(ctx, "approvers") 2267 if err != nil { 2268 return err 2269 } 2270 for _, ai := range ais { 2271 level[ai.NumericID] = 2 2272 } 2273 2274 quarterAgo := time.Now().Add(-90 * 24 * time.Hour) 2275 missing := map[string]int{} // "only level N: $WHO" -> number of CLs for that user 2276 err = b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 2277 if gp.Server() != "go.googlesource.com" { 2278 return nil 2279 } 2280 return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { 2281 if cl.Meta.Commit.AuthorTime.Before(quarterAgo) { 2282 return nil 2283 } 2284 authorID := int64(cl.OwnerID()) 2285 if authorID == -1 { 2286 return nil 2287 } 2288 if level[authorID] == 2 { 2289 return nil 2290 } 2291 missing[fmt.Sprintf("only level %d: %v", level[authorID], cl.Commit.Author)]++ 2292 return nil 2293 }) 2294 }) 2295 if err != nil { 2296 return err 2297 } 2298 var people []string 2299 for who := range missing { 2300 people = append(people, who) 2301 } 2302 sort.Slice(people, func(i, j int) bool { return missing[people[j]] < missing[people[i]] }) 2303 fmt.Println("Number of CLs created in last 90 days | Access (0=none, 1=trybots) | Author") 2304 for i, who := range people { 2305 num := missing[who] 2306 if num < 3 { 2307 break 2308 } 2309 fmt.Printf("%3d: %s\n", num, who) 2310 if i == 20 { 2311 break 2312 } 2313 } 2314 return nil 2315 } 2316 2317 // humanReviewersOnChange reports whether there is (or was) a sufficient 2318 // number of human reviewers in the given change, and returns the IDs of 2319 // the current human reviewers. It includes reviewers in REVIEWER and CC 2320 // states. 2321 // 2322 // The given gerritChange works as a key for deletedChanges. 2323 func (b *gopherbot) humanReviewersOnChange(ctx context.Context, change gerritChange, cl *maintner.GerritCL) ([]string, bool) { 2324 // The CL's owner will be GerritBot if it is imported from a PR. 2325 // In that case, if the CL's author has a Gerrit account, they will be 2326 // added as a reviewer (go.dev/issue/30265). Otherwise, no reviewers 2327 // will be added. Work around this by requiring 2 human reviewers on PRs. 2328 ownerID := strconv.Itoa(cl.OwnerID()) 2329 isPR := ownerID == gerritbotGerritID 2330 minHumans := 1 2331 if isPR { 2332 minHumans = 2 2333 } 2334 reject := []string{gobotGerritID, gerritbotGerritID, kokoroGerritID, goLUCIGerritID, triciumGerritID, ownerID} 2335 ownerOrRobot := func(gerritID string) bool { 2336 for _, r := range reject { 2337 if gerritID == r { 2338 return true 2339 } 2340 } 2341 return false 2342 } 2343 2344 ids := slices.DeleteFunc(reviewersInMetas(cl.Metas), ownerOrRobot) 2345 if len(ids) >= minHumans { 2346 return ids, true 2347 } 2348 2349 reviewers, err := b.gerrit.ListReviewers(ctx, change.ID()) 2350 if err != nil { 2351 if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound { 2352 b.deletedChanges[change] = true 2353 } 2354 log.Printf("Could not list reviewers on change %q: %v", change.ID(), err) 2355 return nil, true 2356 } 2357 ids = []string{} 2358 for _, r := range reviewers { 2359 id := strconv.FormatInt(r.NumericID, 10) 2360 if hasServiceUserTag(r.AccountInfo) || ownerOrRobot(id) { 2361 // Skip bots and owner. 2362 continue 2363 } 2364 ids = append(ids, id) 2365 } 2366 return ids, len(ids) >= minHumans 2367 } 2368 2369 // hasServiceUserTag reports whether the account has a SERVICE_USER tag. 2370 func hasServiceUserTag(a gerrit.AccountInfo) bool { 2371 for _, t := range a.Tags { 2372 if t == "SERVICE_USER" { 2373 return true 2374 } 2375 } 2376 return false 2377 } 2378 2379 // autoSubmitCLs submits CLs which are labelled "Auto-Submit", 2380 // have all submit requirements satisfied according to Gerrit, and 2381 // aren't waiting for a parent CL in the stack to be handled. 2382 // 2383 // See go.dev/issue/48021. 2384 func (b *gopherbot) autoSubmitCLs(ctx context.Context) error { 2385 return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { 2386 if gp.Server() != "go.googlesource.com" { 2387 return nil 2388 } 2389 return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { 2390 gc := gerritChange{gp.Project(), cl.Number} 2391 if b.deletedChanges[gc] { 2392 return nil 2393 } 2394 2395 // Break out early (before making Gerrit API calls) if the Auto-Submit label 2396 // hasn't been used at all in this CL. 2397 var autosubmitPresent bool 2398 for _, meta := range cl.Metas { 2399 if strings.Contains(meta.Commit.Msg, "\nLabel: Auto-Submit") { 2400 autosubmitPresent = true 2401 break 2402 } 2403 } 2404 if !autosubmitPresent { 2405 return nil 2406 } 2407 2408 // Skip this CL if Auto-Submit+1 isn't actively set on it. 2409 changeInfo, err := b.gerrit.GetChange(ctx, fmt.Sprint(cl.Number), gerrit.QueryChangesOpt{Fields: []string{"LABELS", "SUBMITTABLE"}}) 2410 if err != nil { 2411 if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound { 2412 b.deletedChanges[gc] = true 2413 } 2414 log.Printf("Could not retrieve change %q: %v", gc.ID(), err) 2415 return nil 2416 } 2417 if autosubmitActive := changeInfo.Labels["Auto-Submit"].Approved != nil; !autosubmitActive { 2418 return nil 2419 } 2420 // NOTE: we might be able to skip this as well, since the revision action 2421 // check will also cover this... 2422 if !changeInfo.Submittable { 2423 return nil 2424 } 2425 2426 // We need to check the mergeability, as well as the submitability, 2427 // as the latter doesn't take into account merge conflicts, just 2428 // if the change satisfies the project submit rules. 2429 // 2430 // NOTE: this may now be redundant, since the revision action check 2431 // below will also inherently checks mergeability, since the change 2432 // cannot actually be submitted if there is a merge conflict. We 2433 // may be able to just skip this entirely. 2434 mi, err := b.gerrit.GetMergeable(ctx, fmt.Sprint(cl.Number), "current") 2435 if err != nil { 2436 return err 2437 } 2438 if !mi.Mergeable || mi.CommitMerged { 2439 return nil 2440 } 2441 2442 ra, err := b.gerrit.GetRevisionActions(ctx, fmt.Sprint(cl.Number), "current") 2443 if err != nil { 2444 return err 2445 } 2446 if ra["submit"] == nil || !ra["submit"].Enabled { 2447 return nil 2448 } 2449 2450 // If this change is part of a stack, we'd like to merge the stack 2451 // in the correct order (i.e. from the bottom of the stack to the 2452 // top), so we'll only merge the current change if every change 2453 // below it in the stack is either merged, or abandoned. 2454 // GetRelatedChanges gives us the stack from top to bottom (the 2455 // order of the git commits, from newest to oldest, see Gerrit 2456 // documentation for RelatedChangesInfo), so first we find our 2457 // change in the stack, then check everything below it. 2458 relatedChanges, err := b.gerrit.GetRelatedChanges(ctx, fmt.Sprint(cl.Number), "current") 2459 if err != nil { 2460 return err 2461 } 2462 if len(relatedChanges.Changes) > 0 { 2463 var parentChanges bool 2464 for _, ci := range relatedChanges.Changes { 2465 if !parentChanges { 2466 // Skip everything before the change we are checking, as 2467 // they are the children of this change, and we only care 2468 // about the parents. 2469 parentChanges = ci.ChangeNumber == cl.Number 2470 continue 2471 } 2472 if ci.Status != gerrit.ChangeStatusAbandoned && 2473 ci.Status != gerrit.ChangeStatusMerged { 2474 return nil 2475 } 2476 // We do not check the revision number of merged/abandoned 2477 // parents since, even if they are not current according to 2478 // gerrit, if there were any merge conflicts, caused by the 2479 // diffs between the revision this change was based on and 2480 // the current revision, the change would not be considered 2481 // submittable anyway. 2482 } 2483 } 2484 2485 if *dryRun { 2486 log.Printf("[dry-run] would've submitted CL https://golang.org/cl/%d ...", cl.Number) 2487 return nil 2488 } 2489 log.Printf("submitting CL https://golang.org/cl/%d ...", cl.Number) 2490 2491 // TODO: if maintner isn't fast enough (or is too fast) and it re-runs this 2492 // before the submission is noticed, we may run this more than once. This 2493 // could be handled with a local cache of "recently submitted" changes to 2494 // be ignored. 2495 _, err = b.gerrit.SubmitChange(ctx, fmt.Sprint(cl.Number)) 2496 return err 2497 }) 2498 }) 2499 } 2500 2501 type issueFlags uint8 2502 2503 const ( 2504 open issueFlags = 1 << iota // Include open issues. 2505 closed // Include closed issues. 2506 includePRs // Include issues that are Pull Requests. 2507 includeGone // Include issues that are gone (e.g., deleted or transferred). 2508 ) 2509 2510 // foreachIssue calls fn for each issue in repo gr as controlled by flags. 2511 // 2512 // If fn returns an error, iteration ends and foreachIssue returns 2513 // with that error. 2514 // 2515 // The fn function is called serially, with increasingly numbered 2516 // issues. 2517 func (b *gopherbot) foreachIssue(gr *maintner.GitHubRepo, flags issueFlags, fn func(*maintner.GitHubIssue) error) error { 2518 return gr.ForeachIssue(func(gi *maintner.GitHubIssue) error { 2519 switch { 2520 case (flags&open == 0) && !gi.Closed, 2521 (flags&closed == 0) && gi.Closed, 2522 (flags&includePRs == 0) && gi.PullRequest, 2523 (flags&includeGone == 0) && (gi.NotExist || b.deletedIssues[githubIssue{gr.ID(), gi.Number}]): 2524 // Skip issue. 2525 return nil 2526 default: 2527 return fn(gi) 2528 } 2529 }) 2530 } 2531 2532 // reviewerRe extracts the reviewer's Gerrit ID from a line that looks like: 2533 // 2534 // Reviewer: Rebecca Stambler <16140@62eb7196-b449-3ce5-99f1-c037f21e1705> 2535 var reviewerRe = regexp.MustCompile(`.* <(?P<id>\d+)@.*>`) 2536 2537 const gerritInstanceID = "@62eb7196-b449-3ce5-99f1-c037f21e1705" 2538 2539 // reviewersInMetas returns the unique Gerrit IDs of reviewers 2540 // (in REVIEWER and CC states) that were at some point added 2541 // to the given Gerrit CL, even if they've been since removed. 2542 func reviewersInMetas(metas []*maintner.GerritMeta) []string { 2543 var ids []string 2544 for _, m := range metas { 2545 if !strings.Contains(m.Commit.Msg, "Reviewer:") && !strings.Contains(m.Commit.Msg, "CC:") { 2546 continue 2547 } 2548 2549 err := foreach.LineStr(m.Commit.Msg, func(ln string) error { 2550 if !strings.HasPrefix(ln, "Reviewer:") && !strings.HasPrefix(ln, "CC:") { 2551 return nil 2552 } 2553 match := reviewerRe.FindStringSubmatch(ln) 2554 if match == nil { 2555 return nil 2556 } 2557 // Extract the reviewer's Gerrit ID. 2558 for i, name := range reviewerRe.SubexpNames() { 2559 if name != "id" { 2560 continue 2561 } 2562 if i < 0 || i > len(match) { 2563 continue 2564 } 2565 ids = append(ids, match[i]) 2566 } 2567 return nil 2568 }) 2569 if err != nil { 2570 log.Printf("reviewersInMetas: got unexpected error from foreach.LineStr: %v", err) 2571 } 2572 } 2573 // Remove duplicates. 2574 slices.Sort(ids) 2575 ids = slices.Compact(ids) 2576 return ids 2577 } 2578 2579 func getCodeOwners(ctx context.Context, paths []string) ([]*owners.Entry, error) { 2580 oReq := owners.Request{Version: 1} 2581 oReq.Payload.Paths = paths 2582 2583 oResp, err := fetchCodeOwners(ctx, &oReq) 2584 if err != nil { 2585 return nil, err 2586 } 2587 2588 var entries []*owners.Entry 2589 for _, entry := range oResp.Payload.Entries { 2590 if entry == nil { 2591 continue 2592 } 2593 entries = append(entries, entry) 2594 } 2595 return entries, nil 2596 } 2597 2598 func getAllCodeOwners(ctx context.Context) (map[string]*owners.Entry, error) { 2599 oReq := owners.Request{Version: 1} 2600 oReq.Payload.All = true 2601 oResp, err := fetchCodeOwners(ctx, &oReq) 2602 if err != nil { 2603 return nil, err 2604 } 2605 return oResp.Payload.Entries, nil 2606 } 2607 2608 func fetchCodeOwners(ctx context.Context, oReq *owners.Request) (*owners.Response, error) { 2609 b, err := json.Marshal(oReq) 2610 if err != nil { 2611 return nil, err 2612 } 2613 req, err := http.NewRequest("POST", "https://dev.golang.org/owners/", bytes.NewReader(b)) 2614 if err != nil { 2615 return nil, err 2616 } 2617 req.Header.Set("Content-Type", "application/json") 2618 ctx, cancel := context.WithTimeout(ctx, 10*time.Second) 2619 defer cancel() 2620 resp, err := http.DefaultClient.Do(req.WithContext(ctx)) 2621 if err != nil { 2622 return nil, err 2623 } 2624 defer resp.Body.Close() 2625 var oResp owners.Response 2626 if err := json.NewDecoder(resp.Body).Decode(&oResp); err != nil { 2627 return nil, fmt.Errorf("could not decode owners response: %v", err) 2628 } 2629 if oResp.Error != "" { 2630 return nil, fmt.Errorf("error from dev.golang.org/owners endpoint: %v", oResp.Error) 2631 } 2632 return &oResp, nil 2633 } 2634 2635 // mergeOwnersEntries takes multiple owners.Entry structs and aggregates all 2636 // primary and secondary users into a single entry. 2637 // If a user is a primary in one entry but secondary on another, they are 2638 // primary in the returned entry. 2639 // If a users email matches the authorEmail, the user is omitted from the 2640 // result. 2641 // The resulting order of the entries is non-deterministic. 2642 func mergeOwnersEntries(entries []*owners.Entry, authorEmail string) *owners.Entry { 2643 var result owners.Entry 2644 pm := make(map[owners.Owner]int) 2645 for _, e := range entries { 2646 for _, o := range e.Primary { 2647 pm[o]++ 2648 } 2649 } 2650 sm := make(map[owners.Owner]int) 2651 for _, e := range entries { 2652 for _, o := range e.Secondary { 2653 if pm[o] > 0 { 2654 pm[o]++ 2655 } else { 2656 sm[o]++ 2657 } 2658 } 2659 } 2660 2661 const maxReviewers = 3 2662 if len(pm) > maxReviewers { 2663 // Spamming many reviewers. 2664 // Cut to three most common reviewers 2665 // and drop all the secondaries. 2666 var keep []owners.Owner 2667 for o := range pm { 2668 keep = append(keep, o) 2669 } 2670 sort.Slice(keep, func(i, j int) bool { 2671 return pm[keep[i]] > pm[keep[j]] 2672 }) 2673 keep = keep[:maxReviewers] 2674 sort.Slice(keep, func(i, j int) bool { 2675 return keep[i].GerritEmail < keep[j].GerritEmail 2676 }) 2677 return &owners.Entry{Primary: keep} 2678 } 2679 2680 for o := range pm { 2681 if o.GerritEmail != authorEmail { 2682 result.Primary = append(result.Primary, o) 2683 } 2684 } 2685 for o := range sm { 2686 if o.GerritEmail != authorEmail { 2687 result.Secondary = append(result.Secondary, o) 2688 } 2689 } 2690 return &result 2691 } 2692 2693 // filterGerritOwners removes all primary and secondary owners from entries 2694 // that are missing GerritEmail, and thus cannot be Gerrit reviewers (e.g., 2695 // GitHub Teams). 2696 // 2697 // If an Entry's primary reviewers is empty after this process, the secondary 2698 // owners are upgraded to primary. 2699 func filterGerritOwners(entries []*owners.Entry) []*owners.Entry { 2700 result := make([]*owners.Entry, 0, len(entries)) 2701 for _, e := range entries { 2702 var clean owners.Entry 2703 for _, owner := range e.Primary { 2704 if owner.GerritEmail != "" { 2705 clean.Primary = append(clean.Primary, owner) 2706 } 2707 } 2708 for _, owner := range e.Secondary { 2709 if owner.GerritEmail != "" { 2710 clean.Secondary = append(clean.Secondary, owner) 2711 } 2712 } 2713 if len(clean.Primary) == 0 { 2714 clean.Primary = clean.Secondary 2715 clean.Secondary = nil 2716 } 2717 result = append(result, &clean) 2718 } 2719 return result 2720 } 2721 2722 func blockqoute(s string) string { 2723 s = strings.TrimSpace(s) 2724 s = "> " + s 2725 s = strings.Replace(s, "\n", "\n> ", -1) 2726 return s 2727 } 2728 2729 // errStopIteration is used to stop iteration over issues or comments. 2730 // It has no special meaning. 2731 var errStopIteration = errors.New("stop iteration") 2732 2733 func isDocumentationTitle(t string) bool { 2734 if !strings.Contains(t, "doc") && !strings.Contains(t, "Doc") { 2735 return false 2736 } 2737 t = strings.ToLower(t) 2738 if strings.HasPrefix(t, "x/pkgsite:") { 2739 // Don't label pkgsite issues with the Documentation label. 2740 return false 2741 } 2742 if strings.HasPrefix(t, "doc:") { 2743 return true 2744 } 2745 if strings.HasPrefix(t, "docs:") { 2746 return true 2747 } 2748 if strings.HasPrefix(t, "cmd/doc:") { 2749 return false 2750 } 2751 if strings.HasPrefix(t, "go/doc:") { 2752 return false 2753 } 2754 if strings.Contains(t, "godoc:") { // in x/tools, or the dozen places people file it as 2755 return false 2756 } 2757 return strings.Contains(t, "document") || 2758 strings.Contains(t, "docs ") 2759 } 2760 2761 func isGoplsTitle(t string) bool { 2762 // If the prefix doesn't contain "gopls" or "lsp", 2763 // then it may not be a gopls issue. 2764 i := strings.Index(t, ":") 2765 if i > -1 { 2766 t = t[:i] 2767 } 2768 return strings.Contains(t, "gopls") || strings.Contains(t, "lsp") 2769 } 2770 2771 var lastTask string 2772 2773 func printIssue(task string, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue) { 2774 if *dryRun { 2775 task = task + " [dry-run]" 2776 } 2777 if task != lastTask { 2778 fmt.Println(task) 2779 lastTask = task 2780 } 2781 if repoID.Owner != "golang" || repoID.Repo != "go" { 2782 fmt.Printf("\thttps://github.com/%s/issues/%v %s\n", repoID, gi.Number, gi.Title) 2783 } else { 2784 fmt.Printf("\thttps://go.dev/issue/%v %s\n", gi.Number, gi.Title) 2785 } 2786 } 2787 2788 func repoHasLabel(repo *maintner.GitHubRepo, name string) bool { 2789 has := false 2790 repo.ForeachLabel(func(label *maintner.GitHubLabel) error { 2791 if label.Name == name { 2792 has = true 2793 } 2794 return nil 2795 }) 2796 return has 2797 }