golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/gerritbot/gerritbot.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 gerritbot binary converts GitHub Pull Requests to Gerrit Changes, 6 // updating the PR and Gerrit Change as appropriate. 7 package main 8 9 import ( 10 "bytes" 11 "context" 12 "crypto/sha1" 13 "flag" 14 "fmt" 15 "log" 16 "net/http" 17 "net/url" 18 "os" 19 "os/exec" 20 "path/filepath" 21 "regexp" 22 "strconv" 23 "strings" 24 "sync" 25 "time" 26 27 "cloud.google.com/go/compute/metadata" 28 "github.com/google/go-github/v48/github" 29 "github.com/gregjones/httpcache" 30 "golang.org/x/build/cmd/gerritbot/internal/rules" 31 "golang.org/x/build/gerrit" 32 "golang.org/x/build/internal/https" 33 "golang.org/x/build/internal/secret" 34 "golang.org/x/build/maintner" 35 "golang.org/x/build/maintner/godata" 36 "golang.org/x/build/repos" 37 "golang.org/x/oauth2" 38 ) 39 40 var ( 41 workdir = flag.String("workdir", cacheDir(), "where git repos and temporary worktrees are created") 42 githubTokenFile = flag.String("github-token-file", filepath.Join(configDir(), "github-token"), "file to load GitHub token from; should only contain the token text") 43 gerritTokenFile = flag.String("gerrit-token-file", filepath.Join(configDir(), "gerrit-token"), "file to load Gerrit token from; should be of form <git-email>:<token>") 44 gitcookiesFile = flag.String("gitcookies-file", "", "if non-empty, write a git http cookiefile to this location using secret manager") 45 dryRun = flag.Bool("dry-run", false, "print out mutating actions but don’t perform any") 46 singlePR = flag.String("single-pr", "", "process only this PR, specified in GitHub shortlink format, e.g. golang/go#1") 47 ) 48 49 // TODO(amedee): set to this value until the SLO numbers are published 50 const secretClientTimeout = 10 * time.Second 51 52 func main() { 53 https.RegisterFlags(flag.CommandLine) 54 flag.Parse() 55 56 var secretClient *secret.Client 57 if metadata.OnGCE() { 58 secretClient = secret.MustNewClient() 59 } 60 if err := writeCookiesFile(secretClient); err != nil { 61 log.Fatalf("writeCookiesFile(): %v", err) 62 } 63 ghc, err := githubClient(secretClient) 64 if err != nil { 65 log.Fatalf("githubClient(): %v", err) 66 } 67 gc, err := gerritClient(secretClient) 68 if err != nil { 69 log.Fatalf("gerritClient(): %v", err) 70 } 71 b := newBot(ghc, gc) 72 73 ctx := context.Background() 74 b.initCorpus(ctx) 75 go b.corpusUpdateLoop(ctx) 76 77 log.Fatalln(https.ListenAndServe(ctx, http.HandlerFunc(handleIndex))) 78 } 79 80 func configDir() string { 81 cd, err := os.UserConfigDir() 82 if err != nil { 83 log.Fatalf("UserConfigDir: %v", err) 84 } 85 return filepath.Join(cd, "gerritbot") 86 } 87 88 func cacheDir() string { 89 cd, err := os.UserCacheDir() 90 if err != nil { 91 log.Fatalf("UserCacheDir: %v", err) 92 } 93 return filepath.Join(cd, "gerritbot") 94 } 95 96 func writeCookiesFile(sc *secret.Client) error { 97 if *gitcookiesFile == "" { 98 return nil 99 } 100 log.Printf("Writing git http cookies file %q ...", *gitcookiesFile) 101 if !metadata.OnGCE() { 102 return fmt.Errorf("cannot write git http cookies file %q from secret manager: not on GCE", *gitcookiesFile) 103 } 104 105 ctx, cancel := context.WithTimeout(context.Background(), secretClientTimeout) 106 defer cancel() 107 108 cookies, err := sc.Retrieve(ctx, secret.NameGerritbotGitCookies) 109 if err != nil { 110 return fmt.Errorf("secret.Retrieve(ctx, %q): %q, %w", secret.NameGerritbotGitCookies, cookies, err) 111 } 112 return os.WriteFile(*gitcookiesFile, []byte(cookies), 0600) 113 } 114 115 func githubClient(sc *secret.Client) (*github.Client, error) { 116 token, err := githubToken(sc) 117 if err != nil { 118 return nil, err 119 } 120 oauthTransport := &oauth2.Transport{ 121 Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), 122 } 123 cachingTransport := &httpcache.Transport{ 124 Transport: oauthTransport, 125 Cache: httpcache.NewMemoryCache(), 126 MarkCachedResponses: true, 127 } 128 httpClient := &http.Client{ 129 Transport: cachingTransport, 130 } 131 return github.NewClient(httpClient), nil 132 } 133 134 func githubToken(sc *secret.Client) (string, error) { 135 if metadata.OnGCE() { 136 ctx, cancel := context.WithTimeout(context.Background(), secretClientTimeout) 137 defer cancel() 138 139 token, err := sc.Retrieve(ctx, secret.NameMaintnerGitHubToken) 140 if err != nil { 141 log.Printf("secret.Retrieve(ctx, %q): %q, %v", secret.NameMaintnerGitHubToken, token, err) 142 } else { 143 return token, nil 144 } 145 } 146 slurp, err := os.ReadFile(*githubTokenFile) 147 if err != nil { 148 return "", err 149 } 150 tok := strings.TrimSpace(string(slurp)) 151 if len(tok) == 0 { 152 return "", fmt.Errorf("token from file %q cannot be empty", *githubTokenFile) 153 } 154 return tok, nil 155 } 156 157 func gerritClient(sc *secret.Client) (*gerrit.Client, error) { 158 username, token, err := gerritAuth(sc) 159 if err != nil { 160 return nil, err 161 } 162 c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(username, token)) 163 return c, nil 164 } 165 166 func gerritAuth(sc *secret.Client) (string, string, error) { 167 var slurp string 168 if metadata.OnGCE() { 169 var err error 170 ctx, cancel := context.WithTimeout(context.Background(), secretClientTimeout) 171 defer cancel() 172 slurp, err = sc.Retrieve(ctx, secret.NameGobotPassword) 173 if err != nil { 174 log.Printf("secret.Retrieve(ctx, %q): %q, %v", secret.NameGobotPassword, slurp, err) 175 } 176 } 177 if len(slurp) == 0 { 178 slurpBytes, err := os.ReadFile(*gerritTokenFile) 179 if err != nil { 180 return "", "", err 181 } 182 slurp = string(slurpBytes) 183 } 184 f := strings.SplitN(strings.TrimSpace(slurp), ":", 2) 185 if len(f) == 1 { 186 // Assume the whole thing is the token. 187 return "git-gobot.golang.org", f[0], nil 188 } 189 if len(f) != 2 || f[0] == "" || f[1] == "" { 190 return "", "", fmt.Errorf("expected Gerrit token to be of form <git-email>:<token>") 191 } 192 return f[0], f[1], nil 193 } 194 195 func handleIndex(w http.ResponseWriter, r *http.Request) { 196 r.Header.Set("Content-Type", "text/html; charset=utf-8") 197 fmt.Fprintln(w, "Hello, GerritBot! 🤖") 198 } 199 200 const ( 201 // Footer that contains the last revision from GitHub that was successfully 202 // imported to Gerrit. 203 prefixGitFooterLastRev = "GitHub-Last-Rev:" 204 205 // Footer containing the GitHub PR associated with the Gerrit Change. 206 prefixGitFooterPR = "GitHub-Pull-Request:" 207 208 // Footer containing the Gerrit Change ID. 209 prefixGitFooterChangeID = "Change-Id:" 210 211 // Footer containing the LUCI SlowBots to run. 212 prefixGitFooterCQIncludeTrybots = "Cq-Include-Trybots:" 213 ) 214 215 // Gerrit projects we accept PRs for. 216 var gerritProjectAllowlist = genProjectAllowlist() 217 218 func genProjectAllowlist() map[string]bool { 219 m := make(map[string]bool) 220 for p, r := range repos.ByGerritProject { 221 if r.MirrorToGitHub { 222 m[p] = true 223 } 224 } 225 return m 226 } 227 228 type bot struct { 229 githubClient *github.Client 230 gerritClient *gerrit.Client 231 232 sync.RWMutex // Protects all fields below 233 corpus *maintner.Corpus 234 235 // PRs and their corresponding Gerrit CLs. 236 importedPRs map[string]*maintner.GerritCL // GitHub owner/repo#n -> Gerrit CL 237 238 // CLs that have been created/updated on Gerrit for GitHub PRs but are not yet 239 // reflected in the maintner corpus yet. 240 pendingCLs map[string]string // GitHub owner/repo#n -> Commit message from PR 241 242 // Cache of Gerrit Account IDs to AccountInfo structs. 243 cachedGerritAccounts map[int]*gerrit.AccountInfo // 1234 -> Detailed Account Info 244 } 245 246 func newBot(githubClient *github.Client, gerritClient *gerrit.Client) *bot { 247 return &bot{ 248 githubClient: githubClient, 249 gerritClient: gerritClient, 250 importedPRs: map[string]*maintner.GerritCL{}, 251 pendingCLs: map[string]string{}, 252 cachedGerritAccounts: map[int]*gerrit.AccountInfo{}, 253 } 254 } 255 256 // initCorpus fetches a full maintner corpus, overwriting any existing data. 257 func (b *bot) initCorpus(ctx context.Context) { 258 b.Lock() 259 defer b.Unlock() 260 var err error 261 b.corpus, err = godata.Get(ctx) 262 if err != nil { 263 log.Fatalf("godata.Get: %v", err) 264 } 265 } 266 267 // corpusUpdateLoop continuously updates the server’s corpus until ctx’s Done 268 // channel is closed. 269 func (b *bot) corpusUpdateLoop(ctx context.Context) { 270 log.Println("Starting corpus update loop ...") 271 for { 272 b.checkPullRequests() 273 err := b.corpus.UpdateWithLocker(ctx, &b.RWMutex) 274 if err != nil { 275 if err == maintner.ErrSplit { 276 log.Println("Corpus out of sync. Re-fetching corpus.") 277 b.initCorpus(ctx) 278 } else { 279 log.Printf("corpus.Update: %v; sleeping 15s", err) 280 time.Sleep(15 * time.Second) 281 continue 282 } 283 } 284 285 select { 286 case <-ctx.Done(): 287 return 288 default: 289 continue 290 } 291 } 292 } 293 294 func (b *bot) checkPullRequests() { 295 b.Lock() 296 defer b.Unlock() 297 b.importedPRs = map[string]*maintner.GerritCL{} 298 b.corpus.Gerrit().ForeachProjectUnsorted(func(p *maintner.GerritProject) error { 299 pname := p.Project() 300 if !gerritProjectAllowlist[pname] { 301 return nil 302 } 303 return p.ForeachOpenCL(func(cl *maintner.GerritCL) error { 304 prv := cl.Footer(prefixGitFooterPR) 305 if prv == "" { 306 return nil 307 } 308 b.importedPRs[prv] = cl 309 return nil 310 }) 311 }) 312 313 b.corpus.GitHub().ForeachRepo(func(ghr *maintner.GitHubRepo) error { 314 id := ghr.ID() 315 if id.Owner != "golang" || !gerritProjectAllowlist[id.Repo] { 316 return nil 317 } 318 return ghr.ForeachIssue(func(issue *maintner.GitHubIssue) error { 319 ctx := context.Background() 320 shortLink := githubShortLink(id.Owner, id.Repo, int(issue.Number)) 321 if *singlePR != "" && shortLink != *singlePR { 322 return nil 323 } 324 if issue.PullRequest && issue.Closed { 325 // Clean up any reference of closed CLs within pendingCLs. 326 delete(b.pendingCLs, shortLink) 327 if cl, ok := b.importedPRs[shortLink]; ok { 328 // The CL associated with the PR is still open since it's 329 // present in importedPRs, so abandon it. 330 if err := b.abandonCL(ctx, cl, shortLink); err != nil { 331 log.Printf("abandonCL(ctx, https://golang.org/cl/%v, %q): %v", cl.Number, shortLink, err) 332 } 333 } 334 return nil 335 } 336 if issue.Closed || !issue.PullRequest { 337 return nil 338 } 339 pr, err := b.getFullPR(ctx, id.Owner, id.Repo, int(issue.Number)) 340 if err != nil { 341 log.Printf("getFullPR(ctx, %q, %q, %d): %v", id.Owner, id.Repo, issue.Number, err) 342 return nil 343 } 344 approved, err := b.claApproved(ctx, id, pr) 345 if err != nil { 346 log.Printf("checking CLA approval: %v", err) 347 return nil 348 } 349 if !approved { 350 return nil 351 } 352 if err := b.processPullRequest(ctx, pr); err != nil { 353 log.Printf("processPullRequest: %v", err) 354 return nil 355 } 356 return nil 357 }) 358 }) 359 } 360 361 // claApproved reports whether the latest head commit of the given PR in repo 362 // has been approved by the Google CLA checker. 363 func (b *bot) claApproved(ctx context.Context, repo maintner.GitHubRepoID, pr *github.PullRequest) (bool, error) { 364 if pr.GetHead().GetSHA() == "" { 365 // Paranoia check. This should never happen. 366 return false, fmt.Errorf("no head SHA for PR %v %v", repo, pr.GetNumber()) 367 } 368 runs, _, err := b.githubClient.Checks.ListCheckRunsForRef(ctx, repo.Owner, repo.Repo, pr.GetHead().GetSHA(), &github.ListCheckRunsOptions{ 369 CheckName: github.String("cla/google"), 370 Status: github.String("completed"), 371 Filter: github.String("latest"), 372 // TODO(heschi): filter for App ID once supported by go-github 373 }) 374 if err != nil { 375 return false, err 376 } 377 for _, run := range runs.CheckRuns { 378 if run.GetApp().GetID() != 42202 { 379 continue 380 } 381 return run.GetConclusion() == "success", nil 382 } 383 return false, nil 384 } 385 386 // githubShortLink returns text referencing an Issue or Pull Request that will be 387 // automatically converted into a link by GitHub. 388 func githubShortLink(owner, repo string, number int) string { 389 return fmt.Sprintf("%s#%d", owner+"/"+repo, number) 390 } 391 392 // prShortLink returns text referencing the given Pull Request that will be 393 // automatically converted into a link by GitHub. 394 func prShortLink(pr *github.PullRequest) string { 395 repo := pr.GetBase().GetRepo() 396 return githubShortLink(repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber()) 397 } 398 399 // processPullRequest is the entry point to the state machine of mirroring a PR 400 // with Gerrit. PRs that are up to date with their respective Gerrit changes are 401 // skipped, and any with a HEAD commit SHA unequal to its Gerrit equivalent are 402 // imported. If the Gerrit change associated with a PR has been merged, the PR 403 // is closed. Those that have no associated open or merged Gerrit changes will 404 // result in one being created. 405 // b.RWMutex must be Lock'ed. 406 func (b *bot) processPullRequest(ctx context.Context, pr *github.PullRequest) error { 407 log.Printf("Processing PR %s ...", pr.GetHTMLURL()) 408 shortLink := prShortLink(pr) 409 cl := b.importedPRs[shortLink] 410 411 if cl != nil && b.pendingCLs[shortLink] == cl.Commit.Msg { 412 delete(b.pendingCLs, shortLink) 413 } 414 if b.pendingCLs[shortLink] != "" { 415 log.Printf("Changes for PR %s have yet to be mirrored in the maintner corpus. Skipping for now.", shortLink) 416 return nil 417 } 418 419 cmsg, err := commitMessage(pr, cl) 420 if err != nil { 421 return fmt.Errorf("commitMessage: %v", err) 422 } 423 424 if cl == nil { 425 gcl, err := b.gerritChangeForPR(pr) 426 if err != nil { 427 return fmt.Errorf("gerritChangeForPR(%+v): %v", pr, err) 428 } 429 if gcl != nil && gcl.Status != "NEW" { 430 if err := b.closePR(ctx, pr, gcl); err != nil { 431 return fmt.Errorf("b.closePR(ctx, %+v, %+v): %v", pr, gcl, err) 432 } 433 } 434 if gcl != nil { 435 b.pendingCLs[shortLink] = cmsg 436 return nil 437 } 438 if err := b.importGerritChangeFromPR(ctx, pr, nil); err != nil { 439 return fmt.Errorf("importGerritChangeFromPR(%v, nil): %v", shortLink, err) 440 } 441 b.pendingCLs[shortLink] = cmsg 442 return nil 443 } 444 445 if err := b.syncGerritCommentsToGitHub(ctx, pr, cl); err != nil { 446 return fmt.Errorf("syncGerritCommentsToGitHub: %v", err) 447 } 448 449 if cmsg == cl.Commit.Msg && pr.GetDraft() == cl.WorkInProgress() { 450 log.Printf("Change https://go-review.googlesource.com/q/%s is up to date; nothing to do.", 451 cl.ChangeID()) 452 return nil 453 } 454 // Import PR to existing Gerrit Change. 455 if err := b.importGerritChangeFromPR(ctx, pr, cl); err != nil { 456 return fmt.Errorf("importGerritChangeFromPR(%v, %v): %v", shortLink, cl, err) 457 } 458 b.pendingCLs[shortLink] = cmsg 459 return nil 460 } 461 462 // gerritMessageAuthorID returns the Gerrit Account ID of the author of m. 463 func gerritMessageAuthorID(m *maintner.GerritMessage) (int, error) { 464 email := m.Author.Email() 465 if !strings.Contains(email, "@") { 466 return -1, fmt.Errorf("message author email %q does not contain '@' character", email) 467 } 468 i, err := strconv.Atoi(strings.Split(email, "@")[0]) 469 if err != nil { 470 return -1, fmt.Errorf("strconv.Atoi: %v (email: %q)", err, email) 471 } 472 return i, nil 473 } 474 475 // gerritMessageAuthorName returns a message author's display name. To prevent a 476 // thundering herd of redundant comments created by posting a different message 477 // via postGitHubMessageNoDup in syncGerritCommentsToGitHub, it will only return 478 // the correct display name for messages posted after a hard-coded date. 479 // b.RWMutex must be Lock'ed. 480 func (b *bot) gerritMessageAuthorName(ctx context.Context, m *maintner.GerritMessage) (string, error) { 481 t := time.Date(2018, time.November, 9, 0, 0, 0, 0, time.UTC) 482 if m.Date.Before(t) { 483 return m.Author.Name(), nil 484 } 485 id, err := gerritMessageAuthorID(m) 486 if err != nil { 487 return "", fmt.Errorf("gerritMessageAuthorID: %v", err) 488 } 489 account := b.cachedGerritAccounts[id] 490 if account != nil { 491 return account.Name, nil 492 } 493 ai, err := b.gerritClient.GetAccountInfo(ctx, strconv.Itoa(id)) 494 if err != nil { 495 return "", fmt.Errorf("b.gerritClient.GetAccountInfo: %v", err) 496 } 497 b.cachedGerritAccounts[id] = &ai 498 return ai.Name, nil 499 } 500 501 // b.RWMutex must be Lock'ed. 502 func (b *bot) syncGerritCommentsToGitHub(ctx context.Context, pr *github.PullRequest, cl *maintner.GerritCL) error { 503 repo := pr.GetBase().GetRepo() 504 for _, m := range cl.Messages { 505 id, err := gerritMessageAuthorID(m) 506 if err != nil { 507 return fmt.Errorf("gerritMessageAuthorID: %v", err) 508 } 509 if id == cl.OwnerID() { 510 continue 511 } 512 authorName, err := b.gerritMessageAuthorName(ctx, m) 513 if err != nil { 514 return fmt.Errorf("b.gerritMessageAuthorName: %v", err) 515 } 516 517 // NOTE: care is required to update this message. 518 // GerritBot needs to avoid duplicating old messages, 519 // which it does by checking whether it is about 520 // to insert a duplicate. Any change to the message 521 // text requires also passing the equivalent old version 522 // of the text to postGitHubMessageNoDup. 523 524 header := fmt.Sprintf("Message from %s:\n", authorName) 525 msg := fmt.Sprintf(` 526 %s 527 528 --- 529 Please don’t reply on this GitHub thread. Visit [golang.org/cl/%d](https://go-review.googlesource.com/c/%s/+/%d#message-%s). 530 After addressing review feedback, remember to [publish your drafts](https://go.dev/wiki/GerritBot#i-left-a-reply-to-a-comment-in-gerrit-but-no-one-but-me-can-see-it)!`, 531 m.Message, cl.Number, cl.Project.Project(), cl.Number, m.Meta.Hash.String()) 532 533 // We used to link to the wiki on GitHub. 534 // That no longer works for contextual links 535 // after issue #61940. 536 oldmsg := strings.Replace(msg, "https://go.dev/wiki/", "https://github.com/golang/go/wiki/", 1) 537 538 if err := b.postGitHubMessageNoDup(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), header, msg, []string{oldmsg}); err != nil { 539 return fmt.Errorf("postGitHubMessageNoDup: %v", err) 540 } 541 } 542 return nil 543 } 544 545 // gerritChangeForPR returns the Gerrit Change info associated with the given PR. 546 // If no change exists for pr, it returns nil (with a nil error). If multiple 547 // changes exist it will return the first open change, and if no open changes 548 // are available, the first closed change is returned. 549 func (b *bot) gerritChangeForPR(pr *github.PullRequest) (*gerrit.ChangeInfo, error) { 550 q := fmt.Sprintf(`"%s %s"`, prefixGitFooterPR, prShortLink(pr)) 551 o := gerrit.QueryChangesOpt{Fields: []string{"MESSAGES"}} 552 cs, err := b.gerritClient.QueryChanges(context.Background(), q, o) 553 if err != nil { 554 return nil, fmt.Errorf("c.QueryChanges(ctx, %q): %v", q, err) 555 } 556 if len(cs) == 0 { 557 return nil, nil 558 } 559 for _, c := range cs { 560 if c.Status == gerrit.ChangeStatusNew { 561 return c, nil 562 } 563 } 564 // All associated changes are closed. It doesn’t matter which one is returned. 565 return cs[0], nil 566 } 567 568 // closePR closes pr using the information from the given Gerrit change. 569 func (b *bot) closePR(ctx context.Context, pr *github.PullRequest, ch *gerrit.ChangeInfo) error { 570 if *dryRun { 571 log.Printf("[dry run] would close PR %v", prShortLink(pr)) 572 return nil 573 } 574 msg := fmt.Sprintf(`This PR is being closed because [golang.org/cl/%d](https://go-review.googlesource.com/c/%s/+/%d) has been %s.`, 575 ch.ChangeNumber, ch.Project, ch.ChangeNumber, strings.ToLower(ch.Status)) 576 if ch.Status != gerrit.ChangeStatusAbandoned && ch.Status != gerrit.ChangeStatusMerged { 577 return fmt.Errorf("invalid status for closed Gerrit change: %q", ch.Status) 578 } 579 580 if ch.Status == gerrit.ChangeStatusAbandoned { 581 if reason := getAbandonReason(ch); reason != "" { 582 msg += "\n\n" + reason 583 } 584 } 585 586 repo := pr.GetBase().GetRepo() 587 if err := b.postGitHubMessageNoDup(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), "", msg, nil); err != nil { 588 return fmt.Errorf("postGitHubMessageNoDup: %v", err) 589 } 590 591 req := &github.IssueRequest{ 592 State: github.String("closed"), 593 } 594 _, resp, err := b.githubClient.Issues.Edit(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), req) 595 if err != nil { 596 return fmt.Errorf("b.githubClient.Issues.Edit(ctx, %q, %q, %d, %+v): %v", 597 repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), req, err) 598 } 599 logGitHubRateLimits(resp) 600 return nil 601 } 602 603 func (b *bot) abandonCL(ctx context.Context, cl *maintner.GerritCL, shortLink string) error { 604 // Don't abandon any CLs to branches other than master, as they might be 605 // cherrypicks. See golang.org/issue/40151. 606 if cl.Branch() != "master" { 607 return nil 608 } 609 if *dryRun { 610 log.Printf("[dry run] would abandon https://golang.org/cl/%v", cl.Number) 611 return nil 612 } 613 // Due to issues like https://golang.org/issue/28226, Gerrit may take time 614 // to catch up on the fact that a CL has been abandoned. We may have to 615 // make sure that we do not attempt to abandon the same CL multiple times. 616 msg := fmt.Sprintf("GitHub PR %s has been closed.", shortLink) 617 return b.gerritClient.AbandonChange(ctx, cl.ChangeID(), msg) 618 } 619 620 // getAbandonReason returns the last abandon reason in ch, 621 // or the empty string if a reason doesn't exist. 622 func getAbandonReason(ch *gerrit.ChangeInfo) string { 623 for i := len(ch.Messages) - 1; i >= 0; i-- { 624 msg := ch.Messages[i] 625 if msg.Tag != "autogenerated:gerrit:abandon" { 626 continue 627 } 628 if msg.Message == "Abandoned" { 629 // An abandon reason wasn't provided. 630 return "" 631 } 632 return strings.TrimPrefix(msg.Message, "Abandoned\n\n") 633 } 634 return "" 635 } 636 637 // downloadRef calls the Gerrit API to retrieve the ref (such as refs/changes/16/81116/1) 638 // of the most recent patch set of the change with changeID. 639 func (b *bot) downloadRef(ctx context.Context, changeID string) (string, error) { 640 opt := gerrit.QueryChangesOpt{Fields: []string{"CURRENT_REVISION"}} 641 ch, err := b.gerritClient.GetChange(ctx, changeID, opt) 642 if err != nil { 643 return "", fmt.Errorf("c.GetChange(ctx, %q, %+v): %v", changeID, opt, err) 644 } 645 rev, ok := ch.Revisions[ch.CurrentRevision] 646 if !ok { 647 return "", fmt.Errorf("revisions[current_revision] is not present in %+v", ch) 648 } 649 return rev.Ref, nil 650 } 651 652 func runCmd(c *exec.Cmd) error { 653 log.Printf("Executing %v", c.Args) 654 if b, err := c.CombinedOutput(); err != nil { 655 return fmt.Errorf("running %v: output: %s; err: %v", c.Args, b, err) 656 } 657 return nil 658 } 659 660 const gerritHostBase = "https://go.googlesource.com/" 661 662 // gerritChangeRE matches the URL to the Change within the git output when pushing to Gerrit. 663 var gerritChangeRE = regexp.MustCompile(`https:\/\/go-review\.googlesource\.com\/c\/[a-zA-Z0-9_\-]+\/\+\/\d+`) 664 665 // importGerritChangeFromPR mirrors the latest state of pr to cl. If cl is nil, 666 // then a new Gerrit Change is created. 667 func (b *bot) importGerritChangeFromPR(ctx context.Context, pr *github.PullRequest, cl *maintner.GerritCL) error { 668 if *dryRun { 669 log.Printf("[dry run] import Gerrit Change from PR %v", prShortLink(pr)) 670 return nil 671 } 672 githubRepo := pr.GetBase().GetRepo() 673 gerritRepo := gerritHostBase + githubRepo.GetName() // GitHub repo name should match Gerrit repo name. 674 repoDir := filepath.Join(reposRoot(), url.PathEscape(gerritRepo)) 675 676 if _, err := os.Stat(repoDir); os.IsNotExist(err) { 677 cmds := []*exec.Cmd{ 678 exec.Command("git", "clone", "--bare", gerritRepo, repoDir), 679 exec.Command("git", "-C", repoDir, "remote", "add", "github", githubRepo.GetCloneURL()), 680 } 681 for _, c := range cmds { 682 if err := runCmd(c); err != nil { 683 return err 684 } 685 } 686 } 687 688 worktree := fmt.Sprintf("worktree_%s_%s_%d", githubRepo.GetOwner().GetLogin(), githubRepo.GetName(), pr.GetNumber()) 689 worktreeDir := filepath.Join(*workdir, "tmp", worktree) 690 // workTreeDir is created by the `git worktree add` command. 691 defer func() { 692 log.Println("Cleaning up...") 693 for _, c := range []*exec.Cmd{ 694 exec.Command("git", "-C", worktreeDir, "checkout", "master"), 695 exec.Command("git", "-C", worktreeDir, "branch", "-D", prShortLink(pr)), 696 exec.Command("rm", "-rf", worktreeDir), 697 exec.Command("git", "-C", repoDir, "worktree", "prune"), 698 exec.Command("git", "-C", repoDir, "branch", "-D", worktree), 699 } { 700 if err := runCmd(c); err != nil { 701 log.Print(err) 702 } 703 } 704 }() 705 prBaseRef := pr.GetBase().GetRef() 706 for _, c := range []*exec.Cmd{ 707 exec.Command("rm", "-rf", worktreeDir), 708 exec.Command("git", "-C", repoDir, "worktree", "prune"), 709 exec.Command("git", "-C", repoDir, "worktree", "add", worktreeDir), 710 exec.Command("git", "-C", worktreeDir, "fetch", "origin", fmt.Sprintf("+%s:%s", prBaseRef, prBaseRef)), 711 exec.Command("git", "-C", worktreeDir, "fetch", "github", fmt.Sprintf("pull/%d/head", pr.GetNumber())), 712 } { 713 if err := runCmd(c); err != nil { 714 return err 715 } 716 } 717 718 mergeBaseSHA, err := cmdOut(exec.Command("git", "-C", worktreeDir, "merge-base", prBaseRef, "FETCH_HEAD")) 719 if err != nil { 720 return err 721 } 722 723 author, err := cmdOut(exec.Command("git", "-C", worktreeDir, "diff-tree", "--always", "--no-patch", "--format=%an <%ae>", "FETCH_HEAD")) 724 if err != nil { 725 return err 726 } 727 728 cmsg, err := commitMessage(pr, cl) 729 if err != nil { 730 return fmt.Errorf("commitMessage: %v", err) 731 } 732 for _, c := range []*exec.Cmd{ 733 exec.Command("git", "-C", worktreeDir, "checkout", "-B", prShortLink(pr), mergeBaseSHA), 734 exec.Command("git", "-C", worktreeDir, "merge", "--squash", "--no-commit", "FETCH_HEAD"), 735 exec.Command("git", "-C", worktreeDir, "commit", "--author", author, "-m", cmsg), 736 } { 737 if err := runCmd(c); err != nil { 738 return err 739 } 740 } 741 742 var pushOpts string 743 if pr.GetDraft() { 744 pushOpts = "%wip" 745 } else { 746 pushOpts = "%ready" 747 } 748 749 newCL := cl == nil 750 if newCL { 751 // Add this informational message only on CL creation. 752 msg := fmt.Sprintf("This Gerrit CL corresponds to GitHub PR %s.\n\nAuthor: %s", prShortLink(pr), author) 753 pushOpts += ",m=" + url.QueryEscape(msg) 754 } 755 756 // nokeycheck is specified to avoid failing silently when a review is created 757 // with what appears to be a private key. Since there are cases where a user 758 // would want a private key checked in (tests). 759 out, err := cmdOut(exec.Command("git", "-C", worktreeDir, "push", "-o", "nokeycheck", "origin", "HEAD:refs/for/"+prBaseRef+pushOpts)) 760 if err != nil { 761 return fmt.Errorf("could not create change: %v", err) 762 } 763 changeURL := gerritChangeRE.FindString(out) 764 if changeURL == "" { 765 return fmt.Errorf("could not find change URL in command output: %q", out) 766 } 767 repo := pr.GetBase().GetRepo() 768 msg := fmt.Sprintf(`This PR (HEAD: %v) has been imported to Gerrit for code review. 769 770 Please visit Gerrit at %s. 771 772 **Important tips**: 773 774 * Don't comment on this PR. All discussion takes place in Gerrit. 775 * You need a Gmail or other Google account to [log in to Gerrit](https://go-review.googlesource.com/login/). 776 * To change your code in response to feedback: 777 * Push a new commit to the branch used by your GitHub PR. 778 * A new "patch set" will then appear in Gerrit. 779 * Respond to each comment by marking as **Done** in Gerrit if implemented as suggested. You can alternatively write a reply. 780 * **Critical**: you must click the [blue **Reply** button](https://go.dev/wiki/GerritBot#i-left-a-reply-to-a-comment-in-gerrit-but-no-one-but-me-can-see-it) near the top to publish your Gerrit responses. 781 * Multiple commits in the PR will be squashed by GerritBot. 782 * The title and description of the GitHub PR are used to construct the final commit message. 783 * Edit these as needed via the GitHub web interface (not via Gerrit or git). 784 * You should word wrap the PR description at ~76 characters unless you need longer lines (e.g., for tables or URLs). 785 * See the [Sending a change via GitHub](https://go.dev/doc/contribute#sending_a_change_github) and [Reviews](https://go.dev/doc/contribute#reviews) sections of the Contribution Guide as well as the [FAQ](https://go.dev/wiki/GerritBot/#frequently-asked-questions) for details.`, 786 pr.Head.GetSHA(), changeURL) 787 err = b.postGitHubMessageNoDup(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), "", msg, nil) 788 if err != nil { 789 return err 790 } 791 792 if newCL { 793 // Check if we spot any problems with the CL according to our internal 794 // set of rules, and if so, add an unresolved comment to Gerrit. 795 // If the author responds to this, it also helps a reviewer see the author has 796 // registered for a Gerrit account and knows how to reply in Gerrit. 797 // TODO: see CL 509135 for possible follow-ups, including possibly always 798 // asking explicitly if the CL is ready for review even if there are no problems, 799 // and possibly reminder comments followed by ultimately automatically 800 // abandoning the CL if the author never replies. 801 change, err := rules.ParseCommitMessage(repo.GetName(), cmsg) 802 if err != nil { 803 return fmt.Errorf("failed to parse commit message for %s: %v", prShortLink(pr), err) 804 } 805 problems := rules.Check(change) 806 if len(problems) > 0 { 807 summary := rules.FormatResults(problems) 808 // If needed, summary contains advice for how to edit the commit message. 809 msg := fmt.Sprintf("I spotted some possible problems.\n\n"+ 810 "These findings are based on simple heuristics. If a finding appears wrong, briefly reply here saying so. "+ 811 "Otherwise, please address any problems and update the GitHub PR. "+ 812 "When complete, mark this comment as 'Done' and click the [blue 'Reply' button](https://go.dev/wiki/GerritBot#i-left-a-reply-to-a-comment-in-gerrit-but-no-one-but-me-can-see-it) above.\n\n"+ 813 "%s\n\n"+ 814 "(In general for Gerrit code reviews, the change author is expected to [log in to Gerrit](https://go-review.googlesource.com/login/) "+ 815 "with a Gmail or other Google account and then close out each piece of feedback by "+ 816 "marking it as 'Done' if implemented as suggested or otherwise reply to each review comment. "+ 817 "See the [Review](https://go.dev/doc/contribute#review) section of the Contributing Guide for details.)", 818 summary) 819 820 gcl, err := b.gerritChangeForPR(pr) 821 if err != nil { 822 return fmt.Errorf("could not look up CL after creation for %s: %v", prShortLink(pr), err) 823 } 824 unresolved := true 825 ri := gerrit.ReviewInput{ 826 Comments: map[string][]gerrit.CommentInput{ 827 "/PATCHSET_LEVEL": {{Message: msg, Unresolved: &unresolved}}, 828 }, 829 } 830 changeID := fmt.Sprintf("%s~%d", url.PathEscape(gcl.Project), gcl.ChangeNumber) 831 err = b.gerritClient.SetReview(ctx, changeID, "1", ri) 832 if err != nil { 833 return fmt.Errorf("could not add findings comment to CL for %s: %v", prShortLink(pr), err) 834 } 835 } 836 } 837 838 return nil 839 } 840 841 var ( 842 changeIdentRE = regexp.MustCompile(`(?m)^Change-Id: (I[0-9a-fA-F]{40})\n?`) 843 CqIncludeTrybotsRE = regexp.MustCompile(`(?m)^Cq-Include-Trybots: (\S+)\n?`) 844 ) 845 846 // commitMessage returns the text used when creating the squashed commit for pr. 847 // A non-nil cl indicates that pr is associated with an existing Gerrit Change. 848 func commitMessage(pr *github.PullRequest, cl *maintner.GerritCL) (string, error) { 849 prBody := pr.GetBody() 850 var changeID string 851 if cl != nil { 852 changeID = cl.ChangeID() 853 } else { 854 sms := changeIdentRE.FindStringSubmatch(prBody) 855 if sms != nil { 856 changeID = sms[1] 857 prBody = strings.Replace(prBody, sms[0], "", -1) 858 } 859 } 860 if changeID == "" { 861 changeID = genChangeID(pr) 862 } 863 864 // LUCI requires this in the footer (hence why we do so below), but we 865 // are intentionally more lenient here and allow the line to appear 866 // anywhere in an attempt to catch simple mistakes. 867 tryBots := CqIncludeTrybotsRE.FindStringSubmatch(prBody) 868 if tryBots != nil { 869 prBody = strings.Replace(prBody, tryBots[0], "", -1) 870 } 871 872 var msg bytes.Buffer 873 fmt.Fprintf(&msg, "%s\n\n%s\n\n", cleanTitle(pr.GetTitle()), prBody) 874 fmt.Fprintf(&msg, "%s %s\n", prefixGitFooterChangeID, changeID) 875 fmt.Fprintf(&msg, "%s %s\n", prefixGitFooterLastRev, pr.Head.GetSHA()) 876 fmt.Fprintf(&msg, "%s %s\n", prefixGitFooterPR, prShortLink(pr)) 877 if tryBots != nil { 878 fmt.Fprintf(&msg, "%s %s\n", prefixGitFooterCQIncludeTrybots, tryBots[1]) 879 } 880 881 // Clean the commit message up. 882 cmd := exec.Command("git", "stripspace") 883 cmd.Stdin = &msg 884 out, err := cmd.CombinedOutput() 885 if err != nil { 886 return "", fmt.Errorf("could not execute command %v: %v", cmd.Args, err) 887 } 888 return string(out), nil 889 } 890 891 var xRemove = regexp.MustCompile(`^x/\w+/`) 892 893 // cleanTitle removes "x/foo/" from the beginning of t. 894 // It's a common mistake that people make in their PR titles (since we 895 // use that convention for issues, but not PRs) and it's better to just fix 896 // it here rather than ask everybody to fix it manually. 897 func cleanTitle(t string) string { 898 if strings.HasPrefix(t, "x/") { 899 return xRemove.ReplaceAllString(t, "") 900 } 901 return t 902 } 903 904 // genChangeID returns a new Gerrit Change ID using the Pull Request’s ID. 905 // Change IDs are SHA-1 hashes prefixed by an "I" character. 906 func genChangeID(pr *github.PullRequest) string { 907 var buf bytes.Buffer 908 fmt.Fprintf(&buf, "golang_github_pull_request_id_%d", pr.GetID()) 909 return fmt.Sprintf("I%x", sha1.Sum(buf.Bytes())) 910 } 911 912 func cmdOut(cmd *exec.Cmd) (string, error) { 913 log.Printf("Executing %v", cmd.Args) 914 out, err := cmd.CombinedOutput() 915 if err != nil { 916 return "", fmt.Errorf("running %v: output: %s; err: %v", cmd.Args, out, err) 917 } 918 return strings.TrimSpace(string(out)), nil 919 } 920 921 func reposRoot() string { 922 return filepath.Join(*workdir, "repos") 923 } 924 925 // getFullPR retrieves a Pull Request via GitHub’s API. 926 func (b *bot) getFullPR(ctx context.Context, owner, repo string, number int) (*github.PullRequest, error) { 927 pr, resp, err := b.githubClient.PullRequests.Get(ctx, owner, repo, number) 928 if err != nil { 929 return nil, fmt.Errorf("b.githubClient.Do: %v", err) 930 } 931 logGitHubRateLimits(resp) 932 return pr, nil 933 } 934 935 func logGitHubRateLimits(resp *github.Response) { 936 if resp == nil { 937 return 938 } 939 log.Printf("GitHub: %d/%d calls remaining; Reset in %v", resp.Rate.Remaining, resp.Rate.Limit, time.Until(resp.Rate.Reset.Time)) 940 } 941 942 // postGitHubMessageNoDup ensures that the message being posted on an issue does not already have the 943 // same exact content, except for a header which is ignored. These comments can be toggled by the user 944 // via a slash command /comments {on|off} at the beginning of a message. 945 // The oldMsgs parameter holds a list of older versions of this message; 946 // if one of those appears the new message is considered a dup. 947 // TODO(andybons): This logic is shared by gopherbot. Consolidate it somewhere. 948 func (b *bot) postGitHubMessageNoDup(ctx context.Context, org, repo string, issueNum int, header, msg string, oldMsgs []string) error { 949 isDup := func(s string) bool { 950 // TODO: check for exact match? 951 if strings.Contains(s, msg) { 952 return true 953 } 954 for _, m := range oldMsgs { 955 if strings.Contains(s, m) { 956 return true 957 } 958 } 959 return false 960 } 961 962 gr := b.corpus.GitHub().Repo(org, repo) 963 if gr == nil { 964 return fmt.Errorf("unknown github repo %s/%s", org, repo) 965 } 966 var since time.Time 967 var noComment bool 968 var ownerID int64 969 if gi := gr.Issue(int32(issueNum)); gi != nil { 970 ownerID = gi.User.ID 971 var dup bool 972 gi.ForeachComment(func(c *maintner.GitHubComment) error { 973 since = c.Updated 974 if isDup(c.Body) { 975 dup = true 976 return nil 977 } 978 if c.User.ID == ownerID && strings.HasPrefix(c.Body, "/comments ") { 979 if strings.HasPrefix(c.Body, "/comments off") { 980 noComment = true 981 } else if strings.HasPrefix(c.Body, "/comments on") { 982 noComment = false 983 } 984 } 985 return nil 986 }) 987 if dup { 988 // Comment's already been posted. Nothing to do. 989 return nil 990 } 991 } 992 // See if there is a dup comment from when GerritBot last got 993 // its data from maintner. 994 opt := &github.IssueListCommentsOptions{ListOptions: github.ListOptions{PerPage: 1000}} 995 if !since.IsZero() { 996 opt.Since = &since 997 } 998 ics, resp, err := b.githubClient.Issues.ListComments(ctx, org, repo, issueNum, opt) 999 if err != nil { 1000 return err 1001 } 1002 logGitHubRateLimits(resp) 1003 for _, ic := range ics { 1004 if isDup(ic.GetBody()) { 1005 return nil 1006 } 1007 } 1008 1009 if ownerID == 0 { 1010 issue, resp, err := b.githubClient.Issues.Get(ctx, org, repo, issueNum) 1011 if err != nil { 1012 return err 1013 } 1014 logGitHubRateLimits(resp) 1015 ownerID = issue.GetUser().GetID() 1016 } 1017 for _, ic := range ics { 1018 if isDup(ic.GetBody()) { 1019 return nil 1020 } 1021 body := ic.GetBody() 1022 if ic.GetUser().GetID() == ownerID && strings.HasPrefix(body, "/comments ") { 1023 if strings.HasPrefix(body, "/comments off") { 1024 noComment = true 1025 } else if strings.HasPrefix(body, "/comments on") { 1026 noComment = false 1027 } 1028 } 1029 } 1030 if noComment { 1031 return nil 1032 } 1033 if *dryRun { 1034 log.Printf("[dry run] would post comment to %v/%v#%v: %q", org, repo, issueNum, msg) 1035 return nil 1036 } 1037 _, resp, err = b.githubClient.Issues.CreateComment(ctx, org, repo, issueNum, &github.IssueComment{ 1038 Body: github.String(header + msg), 1039 }) 1040 if err != nil { 1041 return err 1042 } 1043 logGitHubRateLimits(resp) 1044 return nil 1045 }