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  }