| // Copyright 2017 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // The gerritbot binary converts GitHub Pull Requests to Gerrit Changes, |
| // updating the PR and Gerrit Change as appropriate. |
| package main |
| |
| import ( |
| "bytes" |
| "context" |
| "crypto/sha1" |
| "errors" |
| "flag" |
| "fmt" |
| "io/ioutil" |
| "log" |
| "net/http" |
| "net/url" |
| "os" |
| "os/exec" |
| "os/user" |
| "path/filepath" |
| "regexp" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| |
| "cloud.google.com/go/compute/metadata" |
| "github.com/google/go-github/github" |
| "golang.org/x/build/gerrit" |
| "golang.org/x/build/internal/https" |
| "golang.org/x/build/internal/secret" |
| "golang.org/x/build/maintner" |
| "golang.org/x/build/maintner/godata" |
| "golang.org/x/build/repos" |
| "golang.org/x/oauth2" |
| ) |
| |
| var ( |
| listen = flag.String("listen", "localhost:6343", "listen address") |
| autocertBucket = flag.String("autocert-bucket", "", "if non-empty, listen on port 443 and serve a LetsEncrypt TLS cert using this Google Cloud Storage bucket as a cache") |
| workdir = flag.String("workdir", defaultWorkdir(), "where git repos and temporary worktrees are created") |
| githubTokenFile = flag.String("github-token-file", filepath.Join(defaultWorkdir(), "github-token"), "file to load GitHub token from; should only contain the token text") |
| gerritTokenFile = flag.String("gerrit-token-file", filepath.Join(defaultWorkdir(), "gerrit-token"), "file to load Gerrit token from; should be of form <git-email>:<token>") |
| gitcookiesFile = flag.String("gitcookies-file", "", "if non-empty, write a git http cookiefile to this location using secret manager") |
| dryRun = flag.Bool("dry-run", false, "print out mutating actions but don’t perform any") |
| ) |
| |
| // TODO(amedee): set to this value until the SLO numbers are published |
| const secretClientTimeout = 10 * time.Second |
| |
| func main() { |
| flag.Parse() |
| |
| var secretClient *secret.Client |
| if metadata.OnGCE() { |
| secretClient = secret.MustNewClient() |
| } |
| if err := writeCookiesFile(secretClient); err != nil { |
| log.Fatalf("writeCookiesFile(): %v", err) |
| } |
| ghc, err := githubClient(secretClient) |
| if err != nil { |
| log.Fatalf("githubClient(): %v", err) |
| } |
| gc, err := gerritClient(secretClient) |
| if err != nil { |
| log.Fatalf("gerritClient(): %v", err) |
| } |
| b := newBot(ghc, gc) |
| |
| ctx := context.Background() |
| b.initCorpus(ctx) |
| go b.corpusUpdateLoop(ctx) |
| |
| err = https.ListenAndServe(http.HandlerFunc(handleIndex), &https.Options{ |
| Addr: *listen, |
| AutocertCacheBucket: *autocertBucket, |
| }) |
| log.Fatalln(err) |
| } |
| |
| func defaultWorkdir() string { |
| // TODO(andybons): Use os.UserCacheDir (issue 22536) when it's available. |
| return filepath.Join(home(), ".gerritbot") |
| } |
| |
| func home() string { |
| h := os.Getenv("HOME") |
| if h != "" { |
| return h |
| } |
| u, err := user.Current() |
| if err != nil { |
| log.Fatalf("user.Current(): %v", err) |
| } |
| return u.HomeDir |
| } |
| |
| func writeCookiesFile(sc *secret.Client) error { |
| if *gitcookiesFile == "" { |
| return nil |
| } |
| log.Printf("Writing git http cookies file %q ...", *gitcookiesFile) |
| if !metadata.OnGCE() { |
| return fmt.Errorf("cannot write git http cookies file %q from secret manager: not on GCE", *gitcookiesFile) |
| } |
| |
| ctx, cancel := context.WithTimeout(context.Background(), secretClientTimeout) |
| defer cancel() |
| |
| cookies, err := sc.Retrieve(ctx, secret.NameGerritbotGitCookies) |
| if err != nil { |
| return fmt.Errorf("secret.Retrieve(ctx, %q): %q, %w", secret.NameGerritbotGitCookies, cookies, err) |
| } |
| return ioutil.WriteFile(*gitcookiesFile, []byte(cookies), 0600) |
| } |
| |
| func githubClient(sc *secret.Client) (*github.Client, error) { |
| token, err := githubToken(sc) |
| if err != nil { |
| return nil, err |
| } |
| ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) |
| tc := oauth2.NewClient(context.Background(), ts) |
| return github.NewClient(tc), nil |
| } |
| |
| func githubToken(sc *secret.Client) (string, error) { |
| if metadata.OnGCE() { |
| ctx, cancel := context.WithTimeout(context.Background(), secretClientTimeout) |
| defer cancel() |
| |
| token, err := sc.Retrieve(ctx, secret.NameMaintnerGitHubToken) |
| if err != nil { |
| log.Printf("secret.Retrieve(ctx, %q): %q, %v", secret.NameMaintnerGitHubToken, token, err) |
| } else { |
| return token, nil |
| } |
| } |
| slurp, err := ioutil.ReadFile(*githubTokenFile) |
| if err != nil { |
| return "", err |
| } |
| tok := strings.TrimSpace(string(slurp)) |
| if len(tok) == 0 { |
| return "", fmt.Errorf("token from file %q cannot be empty", *githubTokenFile) |
| } |
| return tok, nil |
| } |
| |
| func gerritClient(sc *secret.Client) (*gerrit.Client, error) { |
| username, token, err := gerritAuth(sc) |
| if err != nil { |
| return nil, err |
| } |
| c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(username, token)) |
| return c, nil |
| } |
| |
| func gerritAuth(sc *secret.Client) (string, string, error) { |
| var slurp string |
| if metadata.OnGCE() { |
| var err error |
| ctx, cancel := context.WithTimeout(context.Background(), secretClientTimeout) |
| defer cancel() |
| slurp, err = sc.Retrieve(ctx, secret.NameGobotPassword) |
| if err != nil { |
| log.Printf("secret.Retrieve(ctx, %q): %q, %v", secret.NameGobotPassword, slurp, err) |
| } |
| } |
| if len(slurp) == 0 { |
| slurpBytes, err := ioutil.ReadFile(*gerritTokenFile) |
| if err != nil { |
| return "", "", err |
| } |
| slurp = string(slurpBytes) |
| } |
| f := strings.SplitN(strings.TrimSpace(slurp), ":", 2) |
| if len(f) == 1 { |
| // Assume the whole thing is the token. |
| return "git-gobot.golang.org", f[0], nil |
| } |
| if len(f) != 2 || f[0] == "" || f[1] == "" { |
| return "", "", fmt.Errorf("expected Gerrit token to be of form <git-email>:<token>") |
| } |
| return f[0], f[1], nil |
| } |
| |
| func handleIndex(w http.ResponseWriter, r *http.Request) { |
| r.Header.Set("Content-Type", "text/html; charset=utf-8") |
| fmt.Fprintln(w, "Hello, GerritBot! 🤖") |
| } |
| |
| const ( |
| // Footer that contains the last revision from GitHub that was successfully |
| // imported to Gerrit. |
| prefixGitFooterLastRev = "GitHub-Last-Rev:" |
| |
| // Footer containing the GitHub PR associated with the Gerrit Change. |
| prefixGitFooterPR = "GitHub-Pull-Request:" |
| |
| // Footer containing the Gerrit Change ID. |
| prefixGitFooterChangeID = "Change-Id:" |
| ) |
| |
| // Gerrit projects we accept PRs for. |
| var gerritProjectWhitelist = genProjectWhitelist() |
| |
| func genProjectWhitelist() map[string]bool { |
| m := make(map[string]bool) |
| for p, r := range repos.ByGerritProject { |
| if r.MirrorToGitHub { |
| m[p] = true |
| } |
| } |
| return m |
| } |
| |
| type cachedPullRequest struct { |
| pr *github.PullRequest |
| etag string |
| } |
| |
| type bot struct { |
| githubClient *github.Client |
| gerritClient *gerrit.Client |
| |
| sync.RWMutex // Protects all fields below |
| corpus *maintner.Corpus |
| |
| // importedPRs and cachedPRs should share the same method for determining keys |
| // given the same Pull Request, as the presence of a key in the former determined |
| // whether it should remain in the latter. |
| importedPRs map[string]*maintner.GerritCL // GitHub owner/repo#n -> Gerrit CL |
| |
| // Pull Requests that have been cached locally since maintner doesn’t support |
| // PRs and this is used to make conditional requests to the API. |
| cachedPRs map[string]*cachedPullRequest // GitHub owner/repo#n -> GitHub Pull Request |
| |
| // CLs that have been created/updated on Gerrit for GitHub PRs but are not yet |
| // reflected in the maintner corpus yet. |
| pendingCLs map[string]string // GitHub owner/repo#n -> Commit message from PR |
| |
| // Cache of Gerrit Account IDs to AccountInfo structs. |
| cachedGerritAccounts map[int]*gerrit.AccountInfo // 1234 -> Detailed Account Info |
| } |
| |
| func newBot(githubClient *github.Client, gerritClient *gerrit.Client) *bot { |
| return &bot{ |
| githubClient: githubClient, |
| gerritClient: gerritClient, |
| importedPRs: map[string]*maintner.GerritCL{}, |
| pendingCLs: map[string]string{}, |
| cachedPRs: map[string]*cachedPullRequest{}, |
| cachedGerritAccounts: map[int]*gerrit.AccountInfo{}, |
| } |
| } |
| |
| // initCorpus fetches a full maintner corpus, overwriting any existing data. |
| func (b *bot) initCorpus(ctx context.Context) { |
| b.Lock() |
| defer b.Unlock() |
| var err error |
| b.corpus, err = godata.Get(ctx) |
| if err != nil { |
| log.Fatalf("godata.Get: %v", err) |
| } |
| } |
| |
| // corpusUpdateLoop continuously updates the server’s corpus until ctx’s Done |
| // channel is closed. |
| func (b *bot) corpusUpdateLoop(ctx context.Context) { |
| log.Println("Starting corpus update loop ...") |
| for { |
| b.checkPullRequests() |
| err := b.corpus.UpdateWithLocker(ctx, &b.RWMutex) |
| if err != nil { |
| if err == maintner.ErrSplit { |
| log.Println("Corpus out of sync. Re-fetching corpus.") |
| b.initCorpus(ctx) |
| } else { |
| log.Printf("corpus.Update: %v; sleeping 15s", err) |
| time.Sleep(15 * time.Second) |
| continue |
| } |
| } |
| |
| select { |
| case <-ctx.Done(): |
| return |
| default: |
| continue |
| } |
| } |
| } |
| |
| func (b *bot) checkPullRequests() { |
| b.Lock() |
| defer b.Unlock() |
| b.importedPRs = map[string]*maintner.GerritCL{} |
| b.corpus.Gerrit().ForeachProjectUnsorted(func(p *maintner.GerritProject) error { |
| pname := p.Project() |
| if !gerritProjectWhitelist[pname] { |
| return nil |
| } |
| return p.ForeachOpenCL(func(cl *maintner.GerritCL) error { |
| prv := cl.Footer(prefixGitFooterPR) |
| if prv == "" { |
| return nil |
| } |
| b.importedPRs[prv] = cl |
| return nil |
| }) |
| }) |
| |
| // Remove any cached PRs that are no longer being checked. |
| for k := range b.cachedPRs { |
| if b.importedPRs[k] == nil { |
| delete(b.cachedPRs, k) |
| } |
| } |
| |
| b.corpus.GitHub().ForeachRepo(func(ghr *maintner.GitHubRepo) error { |
| id := ghr.ID() |
| if id.Owner != "golang" || !gerritProjectWhitelist[id.Repo] { |
| return nil |
| } |
| return ghr.ForeachIssue(func(issue *maintner.GitHubIssue) error { |
| ctx := context.Background() |
| if issue.PullRequest && issue.Closed { |
| // Clean up any reference of closed CLs within pendingCLs. |
| shortLink := githubShortLink(id.Owner, id.Repo, int(issue.Number)) |
| delete(b.pendingCLs, shortLink) |
| if cl, ok := b.importedPRs[shortLink]; ok { |
| // The CL associated with the PR is still open since it's |
| // present in importedPRs, so abandon it. |
| if err := b.abandonCL(ctx, cl, shortLink); err != nil { |
| log.Printf("abandonCL(ctx, https://golang.org/cl/%v, %q): %v", cl.Number, shortLink, err) |
| } |
| } |
| return nil |
| } |
| if issue.Closed || !issue.PullRequest || !issue.HasLabel("cla: yes") { |
| return nil |
| } |
| pr, err := b.getFullPR(ctx, id.Owner, id.Repo, int(issue.Number)) |
| if err != nil { |
| log.Printf("getFullPR(ctx, %q, %q, %d): %v", id.Owner, id.Repo, issue.Number, err) |
| return nil |
| } |
| if err := b.processPullRequest(ctx, pr); err != nil { |
| log.Printf("processPullRequest: %v", err) |
| return nil |
| } |
| return nil |
| }) |
| }) |
| } |
| |
| // prShortLink returns text referencing an Issue or Pull Request that will be |
| // automatically converted into a link by GitHub. |
| func githubShortLink(owner, repo string, number int) string { |
| return fmt.Sprintf("%s#%d", owner+"/"+repo, number) |
| } |
| |
| // prShortLink returns text referencing the given Pull Request that will be |
| // automatically converted into a link by GitHub. |
| func prShortLink(pr *github.PullRequest) string { |
| repo := pr.GetBase().GetRepo() |
| return githubShortLink(repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber()) |
| } |
| |
| // processPullRequest is the entry point to the state machine of mirroring a PR |
| // with Gerrit. PRs that are up to date with their respective Gerrit changes are |
| // skipped, and any with a HEAD commit SHA unequal to its Gerrit equivalent are |
| // imported. If the Gerrit change associated with a PR has been merged, the PR |
| // is closed. Those that have no associated open or merged Gerrit changes will |
| // result in one being created. |
| // b.RWMutex must be Lock'ed. |
| func (b *bot) processPullRequest(ctx context.Context, pr *github.PullRequest) error { |
| log.Printf("Processing PR %s ...", pr.GetHTMLURL()) |
| shortLink := prShortLink(pr) |
| cl := b.importedPRs[shortLink] |
| |
| if cl != nil && b.pendingCLs[shortLink] == cl.Commit.Msg { |
| delete(b.pendingCLs, shortLink) |
| } |
| if b.pendingCLs[shortLink] != "" { |
| log.Printf("Changes for PR %s have yet to be mirrored in the maintner corpus. Skipping for now.", shortLink) |
| return nil |
| } |
| |
| cmsg, err := commitMessage(pr, cl) |
| if err != nil { |
| return fmt.Errorf("commitMessage: %v", err) |
| } |
| |
| if cl == nil { |
| gcl, err := b.gerritChangeForPR(pr) |
| if err != nil { |
| return fmt.Errorf("gerritChangeForPR(%+v): %v", pr, err) |
| } |
| if gcl != nil && gcl.Status != "NEW" { |
| if err := b.closePR(ctx, pr, gcl); err != nil { |
| return fmt.Errorf("b.closePR(ctx, %+v, %+v): %v", pr, gcl, err) |
| } |
| } |
| if gcl != nil { |
| b.pendingCLs[shortLink] = cmsg |
| return nil |
| } |
| if err := b.importGerritChangeFromPR(ctx, pr, nil); err != nil { |
| return fmt.Errorf("importGerritChangeFromPR(%v, nil): %v", shortLink, err) |
| } |
| b.pendingCLs[shortLink] = cmsg |
| return nil |
| } |
| |
| if err := b.syncGerritCommentsToGitHub(ctx, pr, cl); err != nil { |
| return fmt.Errorf("syncGerritCommentsToGitHub: %v", err) |
| } |
| |
| if cmsg == cl.Commit.Msg { |
| log.Printf("Change https://go-review.googlesource.com/q/%s is up to date; nothing to do.", |
| cl.ChangeID()) |
| return nil |
| } |
| // Import PR to existing Gerrit Change. |
| if err := b.importGerritChangeFromPR(ctx, pr, cl); err != nil { |
| return fmt.Errorf("importGerritChangeFromPR(%v, %v): %v", shortLink, cl, err) |
| } |
| b.pendingCLs[shortLink] = cmsg |
| return nil |
| } |
| |
| // gerritMessageAuthorID returns the Gerrit Account ID of the author of m. |
| func gerritMessageAuthorID(m *maintner.GerritMessage) (int, error) { |
| email := m.Author.Email() |
| if !strings.Contains(email, "@") { |
| return -1, fmt.Errorf("message author email %q does not contain '@' character", email) |
| } |
| i, err := strconv.Atoi(strings.Split(email, "@")[0]) |
| if err != nil { |
| return -1, fmt.Errorf("strconv.Atoi: %v (email: %q)", err, email) |
| } |
| return i, nil |
| } |
| |
| // gerritMessageAuthorName returns a message author's display name. To prevent a |
| // thundering herd of redundant comments created by posting a different message |
| // via postGitHubMessageNoDup in syncGerritCommentsToGitHub, it will only return |
| // the correct display name for messages posted after a hard-coded date. |
| // b.RWMutex must be Lock'ed. |
| func (b *bot) gerritMessageAuthorName(ctx context.Context, m *maintner.GerritMessage) (string, error) { |
| t := time.Date(2018, time.November, 9, 0, 0, 0, 0, time.UTC) |
| if m.Date.Before(t) { |
| return m.Author.Name(), nil |
| } |
| id, err := gerritMessageAuthorID(m) |
| if err != nil { |
| return "", fmt.Errorf("gerritMessageAuthorID: %v", err) |
| } |
| account := b.cachedGerritAccounts[id] |
| if account != nil { |
| return account.Name, nil |
| } |
| ai, err := b.gerritClient.GetAccountInfo(ctx, strconv.Itoa(id)) |
| if err != nil { |
| return "", fmt.Errorf("b.gerritClient.GetAccountInfo: %v", err) |
| } |
| b.cachedGerritAccounts[id] = &ai |
| return ai.Name, nil |
| } |
| |
| // b.RWMutex must be Lock'ed. |
| func (b *bot) syncGerritCommentsToGitHub(ctx context.Context, pr *github.PullRequest, cl *maintner.GerritCL) error { |
| if *dryRun { |
| log.Printf("[dry run] would sync Gerrit comments to %v", prShortLink(pr)) |
| return nil |
| } |
| repo := pr.GetBase().GetRepo() |
| for _, m := range cl.Messages { |
| id, err := gerritMessageAuthorID(m) |
| if err != nil { |
| return fmt.Errorf("gerritMessageAuthorID: %v", err) |
| } |
| if id == cl.OwnerID() { |
| continue |
| } |
| authorName, err := b.gerritMessageAuthorName(ctx, m) |
| if err != nil { |
| return fmt.Errorf("b.gerritMessageAuthorName: %v", err) |
| } |
| msg := fmt.Sprintf(`Message from %s: |
| |
| %s |
| |
| --- |
| Please don’t reply on this GitHub thread. Visit [golang.org/cl/%d](https://go-review.googlesource.com/c/%s/+/%d#message-%s). |
| After addressing review feedback, remember to [publish your drafts](https://github.com/golang/go/wiki/GerritBot#i-left-a-reply-to-a-comment-in-gerrit-but-no-one-but-me-can-see-it)!`, |
| authorName, m.Message, cl.Number, cl.Project.Project(), cl.Number, m.Meta.Hash.String()) |
| if err := b.postGitHubMessageNoDup(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), msg); err != nil { |
| return fmt.Errorf("postGitHubMessageNoDup: %v", err) |
| } |
| } |
| return nil |
| } |
| |
| // gerritChangeForPR returns the Gerrit Change info associated with the given PR. |
| // If no change exists for pr, it returns nil (with a nil error). If multiple |
| // changes exist it will return the first open change, and if no open changes |
| // are available, the first closed change is returned. |
| func (b *bot) gerritChangeForPR(pr *github.PullRequest) (*gerrit.ChangeInfo, error) { |
| q := fmt.Sprintf(`"%s %s"`, prefixGitFooterPR, prShortLink(pr)) |
| o := gerrit.QueryChangesOpt{Fields: []string{"MESSAGES"}} |
| cs, err := b.gerritClient.QueryChanges(context.Background(), q, o) |
| if err != nil { |
| return nil, fmt.Errorf("c.QueryChanges(ctx, %q): %v", q, err) |
| } |
| if len(cs) == 0 { |
| return nil, nil |
| } |
| for _, c := range cs { |
| if c.Status == gerrit.ChangeStatusNew { |
| return c, nil |
| } |
| } |
| // All associated changes are closed. It doesn’t matter which one is returned. |
| return cs[0], nil |
| } |
| |
| // closePR closes pr using the information from the given Gerrit change. |
| func (b *bot) closePR(ctx context.Context, pr *github.PullRequest, ch *gerrit.ChangeInfo) error { |
| if *dryRun { |
| log.Printf("[dry run] would close PR %v", prShortLink(pr)) |
| return nil |
| } |
| msg := fmt.Sprintf(`This PR is being closed because [golang.org/cl/%d](https://go-review.googlesource.com/c/%s/+/%d) has been %s.`, |
| ch.ChangeNumber, ch.Project, ch.ChangeNumber, strings.ToLower(ch.Status)) |
| if ch.Status != gerrit.ChangeStatusAbandoned && ch.Status != gerrit.ChangeStatusMerged { |
| return fmt.Errorf("invalid status for closed Gerrit change: %q", ch.Status) |
| } |
| |
| if ch.Status == gerrit.ChangeStatusAbandoned { |
| if reason := getAbandonReason(ch); reason != "" { |
| msg += "\n\n" + reason |
| } |
| } |
| |
| repo := pr.GetBase().GetRepo() |
| if err := b.postGitHubMessageNoDup(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), msg); err != nil { |
| return fmt.Errorf("postGitHubMessageNoDup: %v", err) |
| } |
| |
| req := &github.IssueRequest{ |
| State: github.String("closed"), |
| } |
| _, resp, err := b.githubClient.Issues.Edit(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), req) |
| if err != nil { |
| return fmt.Errorf("b.githubClient.Issues.Edit(ctx, %q, %q, %d, %+v): %v", |
| repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), req, err) |
| } |
| logGitHubRateLimits(resp) |
| return nil |
| } |
| |
| func (b *bot) abandonCL(ctx context.Context, cl *maintner.GerritCL, shortLink string) error { |
| // Don't abandon any CLs to branches other than master, as they might be |
| // cherrypicks. See golang.org/issue/40151. |
| if cl.Branch() != "master" { |
| return nil |
| } |
| if *dryRun { |
| log.Printf("[dry run] would abandon https://golang.org/cl/%v", cl.Number) |
| return nil |
| } |
| // Due to issues like https://golang.org/issue/28226, Gerrit may take time |
| // to catch up on the fact that a CL has been abandoned. We may have to |
| // make sure that we do not attempt to abandon the same CL multiple times. |
| msg := fmt.Sprintf("GitHub PR %s has been closed.", shortLink) |
| return b.gerritClient.AbandonChange(ctx, cl.ChangeID(), msg) |
| } |
| |
| // getAbandonReason returns the last abandon reason in ch, |
| // or the empty string if a reason doesn't exist. |
| func getAbandonReason(ch *gerrit.ChangeInfo) string { |
| for i := len(ch.Messages) - 1; i >= 0; i-- { |
| msg := ch.Messages[i] |
| if msg.Tag != "autogenerated:gerrit:abandon" { |
| continue |
| } |
| if msg.Message == "Abandoned" { |
| // An abandon reason wasn't provided. |
| return "" |
| } |
| return strings.TrimPrefix(msg.Message, "Abandoned\n\n") |
| } |
| return "" |
| } |
| |
| // downloadRef calls the Gerrit API to retrieve the ref (such as refs/changes/16/81116/1) |
| // of the most recent patch set of the change with changeID. |
| func (b *bot) downloadRef(ctx context.Context, changeID string) (string, error) { |
| opt := gerrit.QueryChangesOpt{Fields: []string{"CURRENT_REVISION"}} |
| ch, err := b.gerritClient.GetChange(ctx, changeID, opt) |
| if err != nil { |
| return "", fmt.Errorf("c.GetChange(ctx, %q, %+v): %v", changeID, opt, err) |
| } |
| rev, ok := ch.Revisions[ch.CurrentRevision] |
| if !ok { |
| return "", fmt.Errorf("revisions[current_revision] is not present in %+v", ch) |
| } |
| return rev.Ref, nil |
| } |
| |
| func runCmd(c *exec.Cmd) error { |
| log.Printf("Executing %v", c.Args) |
| if b, err := c.CombinedOutput(); err != nil { |
| return fmt.Errorf("running %v: output: %s; err: %v", c.Args, b, err) |
| } |
| return nil |
| } |
| |
| const gerritHostBase = "https://go.googlesource.com/" |
| |
| // gerritChangeRE matches the URL to the Change within the git output when pushing to Gerrit. |
| var gerritChangeRE = regexp.MustCompile(`https:\/\/go-review\.googlesource\.com\/c\/[a-zA-Z0-9_\-]+\/\+\/\d+`) |
| |
| // importGerritChangeFromPR mirrors the latest state of pr to cl. If cl is nil, |
| // then a new Gerrit Change is created. |
| func (b *bot) importGerritChangeFromPR(ctx context.Context, pr *github.PullRequest, cl *maintner.GerritCL) error { |
| if *dryRun { |
| log.Printf("[dry run] import Gerrit Change from PR %v", prShortLink(pr)) |
| return nil |
| } |
| githubRepo := pr.GetBase().GetRepo() |
| gerritRepo := gerritHostBase + githubRepo.GetName() // GitHub repo name should match Gerrit repo name. |
| repoDir := filepath.Join(reposRoot(), url.PathEscape(gerritRepo)) |
| |
| if _, err := os.Stat(repoDir); os.IsNotExist(err) { |
| cmds := []*exec.Cmd{ |
| exec.Command("git", "clone", "--bare", gerritRepo, repoDir), |
| exec.Command("git", "-C", repoDir, "remote", "add", "github", githubRepo.GetGitURL()), |
| } |
| for _, c := range cmds { |
| if err := runCmd(c); err != nil { |
| return err |
| } |
| } |
| } |
| |
| worktree := fmt.Sprintf("worktree_%s_%s_%d", githubRepo.GetOwner().GetLogin(), githubRepo.GetName(), pr.GetNumber()) |
| worktreeDir := filepath.Join(*workdir, "tmp", worktree) |
| // workTreeDir is created by the `git worktree add` command. |
| defer func() { |
| log.Println("Cleaning up...") |
| for _, c := range []*exec.Cmd{ |
| exec.Command("git", "-C", worktreeDir, "checkout", "master"), |
| exec.Command("git", "-C", worktreeDir, "branch", "-D", prShortLink(pr)), |
| exec.Command("rm", "-rf", worktreeDir), |
| exec.Command("git", "-C", repoDir, "worktree", "prune"), |
| exec.Command("git", "-C", repoDir, "branch", "-D", worktree), |
| } { |
| if err := runCmd(c); err != nil { |
| log.Print(err) |
| } |
| } |
| }() |
| prBaseRef := pr.GetBase().GetRef() |
| for _, c := range []*exec.Cmd{ |
| exec.Command("rm", "-rf", worktreeDir), |
| exec.Command("git", "-C", repoDir, "worktree", "prune"), |
| exec.Command("git", "-C", repoDir, "worktree", "add", worktreeDir), |
| exec.Command("git", "-C", worktreeDir, "fetch", "origin", fmt.Sprintf("+%s:%s", prBaseRef, prBaseRef)), |
| exec.Command("git", "-C", worktreeDir, "fetch", "github", fmt.Sprintf("pull/%d/head", pr.GetNumber())), |
| } { |
| if err := runCmd(c); err != nil { |
| return err |
| } |
| } |
| |
| mergeBaseSHA, err := cmdOut(exec.Command("git", "-C", worktreeDir, "merge-base", prBaseRef, "FETCH_HEAD")) |
| if err != nil { |
| return err |
| } |
| |
| author, err := cmdOut(exec.Command("git", "-C", worktreeDir, "diff-tree", "--always", "--no-patch", "--format=%an <%ae>", "FETCH_HEAD")) |
| if err != nil { |
| return err |
| } |
| |
| cmsg, err := commitMessage(pr, cl) |
| if err != nil { |
| return fmt.Errorf("commitMessage: %v", err) |
| } |
| for _, c := range []*exec.Cmd{ |
| exec.Command("git", "-C", worktreeDir, "checkout", "-B", prShortLink(pr), mergeBaseSHA), |
| exec.Command("git", "-C", worktreeDir, "merge", "--squash", "--no-commit", "FETCH_HEAD"), |
| exec.Command("git", "-C", worktreeDir, "commit", "--author", author, "-m", cmsg), |
| } { |
| if err := runCmd(c); err != nil { |
| return err |
| } |
| } |
| |
| var pushOpts string |
| if cl == nil { |
| // Add this informational message only on CL creation. |
| msg := fmt.Sprintf("This Gerrit CL corresponds to GitHub PR %s.\n\nAuthor: %s", prShortLink(pr), author) |
| pushOpts = "%m=" + url.QueryEscape(msg) |
| } |
| |
| // nokeycheck is specified to avoid failing silently when a review is created |
| // with what appears to be a private key. Since there are cases where a user |
| // would want a private key checked in (tests). |
| out, err := cmdOut(exec.Command("git", "-C", worktreeDir, "push", "-o", "nokeycheck", "origin", "HEAD:refs/for/"+prBaseRef+pushOpts)) |
| if err != nil { |
| return fmt.Errorf("could not create change: %v", err) |
| } |
| changeURL := gerritChangeRE.FindString(out) |
| if changeURL == "" { |
| return fmt.Errorf("could not find change URL in command output: %q", out) |
| } |
| repo := pr.GetBase().GetRepo() |
| msg := fmt.Sprintf(`This PR (HEAD: %v) has been imported to Gerrit for code review. |
| |
| Please visit %s to see it. |
| |
| Tip: You can toggle comments from me using the %s slash command (e.g. %s) |
| See the [Wiki page](https://golang.org/wiki/GerritBot) for more info`, |
| pr.Head.GetSHA(), changeURL, "`comments`", "`/comments off`") |
| return b.postGitHubMessageNoDup(ctx, repo.GetOwner().GetLogin(), repo.GetName(), pr.GetNumber(), msg) |
| } |
| |
| var changeIdentRE = regexp.MustCompile(`(?m)^Change-Id: (I[0-9a-fA-F]{40})\n?`) |
| |
| // commitMessage returns the text used when creating the squashed commit for pr. |
| // A non-nil cl indicates that pr is associated with an existing Gerrit Change. |
| func commitMessage(pr *github.PullRequest, cl *maintner.GerritCL) (string, error) { |
| prBody := pr.GetBody() |
| var changeID string |
| if cl != nil { |
| changeID = cl.ChangeID() |
| } else { |
| sms := changeIdentRE.FindStringSubmatch(prBody) |
| if sms != nil { |
| changeID = sms[1] |
| prBody = strings.Replace(prBody, sms[0], "", -1) |
| } |
| } |
| if changeID == "" { |
| changeID = genChangeID(pr) |
| } |
| |
| var msg bytes.Buffer |
| fmt.Fprintf(&msg, "%s\n\n%s\n\n", cleanTitle(pr.GetTitle()), prBody) |
| fmt.Fprintf(&msg, "%s %s\n", prefixGitFooterChangeID, changeID) |
| fmt.Fprintf(&msg, "%s %s\n", prefixGitFooterLastRev, pr.Head.GetSHA()) |
| fmt.Fprintf(&msg, "%s %s\n", prefixGitFooterPR, prShortLink(pr)) |
| |
| // Clean the commit message up. |
| cmd := exec.Command("git", "stripspace") |
| cmd.Stdin = &msg |
| out, err := cmd.CombinedOutput() |
| if err != nil { |
| return "", fmt.Errorf("could not execute command %v: %v", cmd.Args, err) |
| } |
| return string(out), nil |
| } |
| |
| var xRemove = regexp.MustCompile(`^x/\w+/`) |
| |
| // cleanTitle removes "x/foo/" from the beginning of t. |
| // It's a common mistake that people make in their PR titles (since we |
| // use that convention for issues, but not PRs) and it's better to just fix |
| // it here rather than ask everybody to fix it manually. |
| func cleanTitle(t string) string { |
| if strings.HasPrefix(t, "x/") { |
| return xRemove.ReplaceAllString(t, "") |
| } |
| return t |
| } |
| |
| // genChangeID returns a new Gerrit Change ID using the Pull Request’s ID. |
| // Change IDs are SHA-1 hashes prefixed by an "I" character. |
| func genChangeID(pr *github.PullRequest) string { |
| var buf bytes.Buffer |
| fmt.Fprintf(&buf, "golang_github_pull_request_id_%d", pr.GetID()) |
| return fmt.Sprintf("I%x", sha1.Sum(buf.Bytes())) |
| } |
| |
| func cmdOut(cmd *exec.Cmd) (string, error) { |
| log.Printf("Executing %v", cmd.Args) |
| out, err := cmd.CombinedOutput() |
| if err != nil { |
| return "", fmt.Errorf("running %v: output: %s; err: %v", cmd.Args, out, err) |
| } |
| return strings.TrimSpace(string(out)), nil |
| } |
| |
| func reposRoot() string { |
| return filepath.Join(*workdir, "repos") |
| } |
| |
| // getFullPR retrieves a Pull Request via GitHub’s API. |
| // b.RWMutex must be Lock'ed. |
| func (b *bot) getFullPR(ctx context.Context, owner, repo string, number int) (*github.PullRequest, error) { |
| shortLink := githubShortLink(owner, repo, number) |
| cpr := b.cachedPRs[shortLink] |
| var etag string |
| if cpr != nil { |
| etag = cpr.etag |
| log.Printf("Retrieving PR %s from GitHub using Etag %q ...", shortLink, etag) |
| } else { |
| log.Printf("Retrieving PR %s from GitHub without an Etag ...", shortLink) |
| } |
| |
| u := fmt.Sprintf("repos/%v/%v/pulls/%d", owner, repo, number) |
| req, err := b.githubClient.NewRequest(http.MethodGet, u, nil) |
| if err != nil { |
| return nil, fmt.Errorf("b.githubClient.NewRequest(%q, %q, nil): %v", http.MethodGet, u, err) |
| } |
| if etag != "" { |
| req.Header.Add("If-None-Match", etag) |
| } |
| |
| pr := new(github.PullRequest) |
| resp, err := b.githubClient.Do(ctx, req, pr) |
| logGitHubRateLimits(resp) |
| if err != nil { |
| if resp.StatusCode == http.StatusNotModified { |
| log.Println("Returning cached version of", shortLink) |
| return cpr.pr, nil |
| } |
| return nil, fmt.Errorf("b.githubClient.Do: %v", err) |
| } |
| |
| b.cachedPRs[shortLink] = &cachedPullRequest{ |
| etag: resp.Header.Get("Etag"), |
| pr: pr, |
| } |
| return pr, nil |
| } |
| |
| func logGitHubRateLimits(resp *github.Response) { |
| if resp == nil { |
| return |
| } |
| log.Printf("GitHub: %d/%d calls remaining; Reset in %v", resp.Rate.Remaining, resp.Rate.Limit, time.Until(resp.Rate.Reset.Time)) |
| } |
| |
| // postGitHubMessageNoDup ensures that the message being posted on an issue does not already have the |
| // same exact content. These comments can be toggled by the user via a slash command /comments {on|off} |
| // at the beginning of a message. |
| // TODO(andybons): This logic is shared by gopherbot. Consolidate it somewhere. |
| func (b *bot) postGitHubMessageNoDup(ctx context.Context, org, repo string, issueNum int, msg string) error { |
| if *dryRun { |
| return errors.New("attempted write operation in dry-run mode") |
| } |
| gr := b.corpus.GitHub().Repo(org, repo) |
| if gr == nil { |
| return fmt.Errorf("unknown github repo %s/%s", org, repo) |
| } |
| var since time.Time |
| var noComment bool |
| var ownerID int64 |
| if gi := gr.Issue(int32(issueNum)); gi != nil { |
| ownerID = gi.User.ID |
| var dup bool |
| gi.ForeachComment(func(c *maintner.GitHubComment) error { |
| since = c.Updated |
| // TODO: check for exact match? |
| if strings.Contains(c.Body, msg) { |
| dup = true |
| return nil |
| } |
| if c.User.ID == ownerID && strings.HasPrefix(c.Body, "/comments ") { |
| if strings.HasPrefix(c.Body, "/comments off") { |
| noComment = true |
| } else if strings.HasPrefix(c.Body, "/comments on") { |
| noComment = false |
| } |
| } |
| return nil |
| }) |
| if dup { |
| // Comment's already been posted. Nothing to do. |
| return nil |
| } |
| } |
| // See if there is a dup comment from when GerritBot last got |
| // its data from maintner. |
| ics, resp, err := b.githubClient.Issues.ListComments(ctx, org, repo, int(issueNum), &github.IssueListCommentsOptions{ |
| Since: since, |
| ListOptions: github.ListOptions{PerPage: 1000}, |
| }) |
| if err != nil { |
| return err |
| } |
| logGitHubRateLimits(resp) |
| for _, ic := range ics { |
| if strings.Contains(ic.GetBody(), msg) { |
| // Dup. |
| return nil |
| } |
| } |
| |
| if ownerID == 0 { |
| issue, resp, err := b.githubClient.Issues.Get(ctx, org, repo, issueNum) |
| if err != nil { |
| return err |
| } |
| logGitHubRateLimits(resp) |
| ownerID = int64(issue.GetUser().GetID()) |
| } |
| for _, ic := range ics { |
| if strings.Contains(ic.GetBody(), msg) { |
| // Dup. |
| return nil |
| } |
| body := ic.GetBody() |
| if int64(ic.GetUser().GetID()) == ownerID && strings.HasPrefix(body, "/comments ") { |
| if strings.HasPrefix(body, "/comments off") { |
| noComment = true |
| } else if strings.HasPrefix(body, "/comments on") { |
| noComment = false |
| } |
| } |
| } |
| if noComment { |
| return nil |
| } |
| _, resp, err = b.githubClient.Issues.CreateComment(ctx, org, repo, int(issueNum), &github.IssueComment{ |
| Body: github.String(msg), |
| }) |
| if err != nil { |
| return err |
| } |
| logGitHubRateLimits(resp) |
| return nil |
| } |