From 2ba85b6ee17b1e964408f8318bc7e386737c5e69 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Tue, 9 Feb 2016 21:28:52 +0000 Subject: [PATCH] Start of a bot. --- LICENSE | 202 +------------------------- bot.go | 441 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 443 insertions(+), 200 deletions(-) create mode 100644 bot.go diff --git a/LICENSE b/LICENSE index 8dada3e..e8764dd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,3 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Licensed under the same terms as Go itself: - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +https://golang.org/LICENSE diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..e86ab2d --- /dev/null +++ b/bot.go @@ -0,0 +1,441 @@ +// Copyright 2016 The Go Authors: https://golang.org/AUTHORS +// Licensed under the same terms as Go itself: https://golang.org/LICENSE + +// The gerritbot command is the the start of a Github Pull Request to +// Gerrit code review bot. +// +// It is incomplete. +// +// The idea is that users won't need to use Gerrit for all changes. +// Github can continue to be the canonical Git repo for projects +// but users sending PRs (or more likely: the people reviewing the PRs) +// can use Gerrit selectively for some reviews. This bot mirrors the PR +// to Gerrit for review there. +// +// Again, it is incomplete. +package main + +import ( + "bytes" + "crypto/sha1" + "crypto/tls" + "errors" + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + + "github.com/google/go-github/github" + "golang.org/x/build/gerrit" + "golang.org/x/net/context" + "golang.org/x/oauth2" +) + +var githubUser, githubToken string + +type Repo struct { + Owner string + Repo string +} + +func (r Repo) String() string { return r.Owner + "/" + r.Repo } + +type PullRequest struct { + Repo + Number int +} + +func (pr PullRequest) ChangeID() string { + d := sha1.New() + fmt.Fprintf(d, "%s/%d", pr.Repo, pr.Number) + return fmt.Sprintf("I%x", d.Sum(nil)) +} + +type logConn struct { + net.Conn +} + +func (c logConn) Write(p []byte) (n int, err error) { + log.Printf("Write: %q", p) + return c.Conn.Write(p) +} + +func (c logConn) Read(p []byte) (n int, err error) { + n, err = c.Conn.Read(p) + log.Printf("Read: %q, %v", p[:n], err) + return +} + +var logClient = &http.Client{ + Transport: &http.Transport{ + DialTLS: func(netw, addr string) (net.Conn, error) { + log.Printf("need to dial %s, %s", netw, addr) + c, err := tls.Dial(netw, addr, &tls.Config{ServerName: "api.github.com"}) + if err != nil { + return nil, err + } + return logConn{c}, nil + }, + }, +} + +var verboseHTTP = flag.Bool("verbose_http", false, "Verbose HTTP debugging") + +type Bot struct { + gh *github.Client + gr *gerrit.Client +} + +func NewBot() *Bot { + baseHTTP := http.DefaultClient + if *verboseHTTP { + baseHTTP = logClient + } + gh := github.NewClient(oauth2.NewClient( + context.WithValue(context.Background(), oauth2.HTTPClient, baseHTTP), + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: githubToken}), + )) + + cookieFile := filepath.Join(homeDir(), "keys", "gerrit-letsusegerrit.cookies") + if _, err := os.Stat(cookieFile); err != nil { + log.Fatalf("Can't stat cookie file for Gerrit: %v", cookieFile) + } + gr := gerrit.NewClient("https://camlistore-review.googlesource.com", gerrit.GitCookieFileAuth(cookieFile)) + + return &Bot{ + gh: gh, + gr: gr, + } +} + +func closeRes(res *github.Response) { + if res != nil && res.Body != nil { + res.Body.Close() + } +} + +func (b *Bot) CheckNotifications() error { + notifs, res, err := b.gh.Activity.ListNotifications(&github.NotificationListOptions{All: true}) + defer closeRes(res) + if err != nil { + return err + } + + log.Printf("Notifs: %d", len(notifs)) + for _, n := range notifs { + log.Printf("Notif: %v, repo:%v, %+v", fs(n.ID), fs(n.Repository.FullName), fs(n.Subject.Title)) + } + return nil +} + +func (b *Bot) CheckPulls(owner, repo string) error { + pulls, res, err := b.gh.PullRequests.List(owner, repo, nil) + defer closeRes(res) + if err != nil { + return err + } + log.Printf("%d pulls", len(pulls)) + for _, pr := range pulls { + log.Printf("PR: %v", github.Stringify(pr)) + } + return nil +} + +func (b *Bot) CommentGithub(owner, repo string, number int, comment string) error { + prc, res, err := b.gh.Issues.CreateComment(owner, repo, number, &github.IssueComment{ + Body: &comment, + }) + defer closeRes(res) + if err != nil { + return err + } + log.Printf("Got: %v, %v, %v", github.Stringify(prc), res, err) + return nil +} + +func (b *Bot) CommentGithubNoDup(owner, repo string, number int, comment string) error { + comments, res, err := b.gh.Issues.ListComments(owner, repo, number, &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 1000, + }, + }) + defer closeRes(res) + if err != nil { + return err + } + for _, ic := range comments { + if ic.Body != nil && *ic.Body == comment { + return nil + } + } + return b.CommentGithub(owner, repo, number, comment) +} + +func (b *Bot) CommentGerrit(number int, comment string) error { + return b.gr.SetReview(fmt.Sprint(number), "current", gerrit.ReviewInput{ + Message: comment, + }) +} + +var ( + changeIdRx = regexp.MustCompile(`(?m)^Change-Id: (I\w+)\b`) + + // parses out: + // remote: New Changes: + // remote: https://camlistore-review.googlesource.com/5991 README: whitespace cleanup + gitNewChangeRx = regexp.MustCompile(`New Changes:.+\n.+https://\w+-review\.googlesource\.com/(\d+)`) +) + +func (b *Bot) Sync(pr PullRequest) error { + prd, res, err := b.gh.PullRequests.Get(pr.Owner, pr.Repo.Repo, pr.Number) + defer closeRes(res) + if err != nil { + return err + } + if prd.Head == nil || prd.Base == nil || prd.State == nil || prd.Title == nil || prd.Commits == nil { + return errors.New("nil fields") + } + if *prd.Commits == 0 { + // Um, nothing to do? + return nil + } + if *prd.Commits > 1 { + return b.CommentGithubNoDup(pr.Owner, pr.Repo.Repo, pr.Number, + fmt.Sprintf("Head %v has %d commits. Please squash your commits into one. @LetsUseGerrit only supports syncing a pull request with a single commit, as that is how Gerrit is typically used.", + *prd.Head.SHA, *prd.Commits)) + } + + state := *prd.State + title := *prd.Title + log.Printf("State %s, title %q, commits %d", state, title, *prd.Commits) + + baseSHA := *prd.Base.SHA + headSHA := *prd.Head.SHA + log.Printf("Base: %s Head: %s", baseSHA, headSHA) + + // TODO: don't hardcode these. + grInst := "camlistore" + proj := "review-github-camlistore-go4-dev13" + + pi, err := b.gr.GetProjectInfo(proj) + if err != nil { + log.Printf("gerrit project %s: %v", proj, err) + if err == gerrit.ErrProjectNotExist { + pi, err = b.gr.CreateProject(proj) + if err != nil { + return fmt.Errorf("error creating gerrit project %s: %v", proj, err) + } + } + } + log.Printf("Gerrit project: %v", pi) + + gitDir := filepath.Join(homeDir(), "var", "letsusegerrit", "git-tmp-"+proj) + if err := os.MkdirAll(gitDir, 0700); err != nil { + return err + } + + git := func(args ...string) *exec.Cmd { + args = append([]string{ + "-c", "http.cookiefile=/home/bradfitz/keys/gerrit-letsusegerrit.cookies", + }, args...) + cmd := exec.Command("git", args...) + cmd.Dir = gitDir + return cmd + } + + if _, err := os.Stat(filepath.Join(gitDir, ".git")); os.IsNotExist(err) { + if err := git("init").Run(); err != nil { + return fmt.Errorf("git init: %v", err) + } + } + + // Fetch head + { + fetch := func(br *github.PullRequestBranch) error { + log.Printf("Fetching %s refs/heads/%s", *br.Repo.CloneURL, *br.Ref) + if out, err := git("fetch", "--update-head-ok", *br.Repo.CloneURL, "refs/heads/"+*br.Ref).CombinedOutput(); err != nil { + return fmt.Errorf("git fetch from %s: %v, %s", *br.Repo.CloneURL, err, out) + } + log.Printf("Fetched.") + return nil + } + if err := fetch(prd.Head); err != nil { + return err + } + if err := fetch(prd.Base); err != nil { + return err + } + } + + cid := pr.ChangeID() + var hdrs map[string][]string + var body string + var parent string + + // Get raw commit, both to verify that we got it above, and to verify it + // has exactly 1 parent, and that if it has a Change-Id line at all, it + // is at least the one we expect. + { + cat := git("cat-file", "-p", *prd.Head.SHA) + var errbuf bytes.Buffer + cat.Stderr = &errbuf + out, err := cat.Output() + if err != nil { + return fmt.Errorf("git cat-file %s: %v, %s", *prd.Head.SHA, err, errbuf.Bytes()) + } + hdrs, body = parseRawGitCommit(out) + log.Printf("Raw: %v, %s", hdrs, body) + m := changeIdRx.FindStringSubmatch(body) + if m != nil && m[1] != cid { + return fmt.Errorf("Head git commit %v contains Gerrit Change-Id line in commit message, but of unexpected value. Delete, or change it to %v", *prd.Head.SHA, cid) + } + parents := hdrs["parent"] + if len(parents) != 1 { + return fmt.Errorf("Head git commit %v has %d parents. LetsUseGerrit does not support reviewing merge commits.", + *prd.Head.SHA, len(parents)) + } + parent = parents[0] + } + + log.Printf("Does %v exist?", cid) + + q := "change:" + cid + " project:" + proj + log.Printf("Running search query: %q", q) + cis, err := b.gr.QueryChanges(q, gerrit.QueryChangesOpt{Fields: []string{"CURRENT_REVISION"}}) + log.Printf("Query %q = %d results, %v", q, len(cis), err) + if err != nil { + return err + } + var changeNum int + if len(cis) == 1 { + changeNum = cis[0].ChangeNumber + log.Printf("Exists: %#v", cis[0]) + if cis[0].CurrentRevision == headSHA { + log.Printf("Gerrit is up-to-date.") + return b.CommentGithubNoDup(pr.Owner, pr.Repo.Repo, pr.Number, + fmt.Sprintf("Gerrit code review: https://%s-review.googlesource.com/%d", grInst, changeNum)) + } + } + log.Printf("matches: %v", len(cis)) + if len(cis) == 0 { + log.Printf("Need to make a commit.") + + if err := git("reset", "--hard", parent).Run(); err != nil { + return fmt.Errorf("git reset making dummy base commit: %v", err) + } + if err := git("commit", "--allow-empty", "-m", + body+"\n\n"+ + "(This is a dummy commit for @LetsUseGerrit to create a Gerrit Change-Id)\n\n"+ + "Change-Id: "+cid+"\n").Run(); err != nil { + return fmt.Errorf("git commit making dummy base commit: %v", err) + } + + branch := fmt.Sprintf("PR/%d", pr.Number) + log.Printf("Setting refs/heads/%s", branch) + if out, err := git("push", "-f", + "https://camlistore-review.googlesource.com/"+proj, + parent+":refs/heads/"+branch).Output(); err != nil { + return fmt.Errorf("git push of parent %s to refs/heads/%s: %v, %s", + parent, branch, err, out) + } + + log.Printf("Pushing dummy commit.") + out, err := git( + "push", + "https://camlistore-review.googlesource.com/"+proj, + "HEAD:refs/for/"+branch).CombinedOutput() + log.Printf("Push of dummy commit: %v", err) + if err != nil { + return fmt.Errorf("git push making dummy base commit: %v, %s", err, out) + } + m := gitNewChangeRx.FindStringSubmatch(string(out)) + if m == nil { + return fmt.Errorf("git push making dummy base commit: unexpected output: %s", out) + } + changeNum, err = strconv.Atoi(m[1]) + if err != nil { + return fmt.Errorf("Atoi(%q) after git push of new change: %v", m[1], err) + } + } else if len(cis) != 1 { + return fmt.Errorf("unexpected %d matches looking for change-id %s in project %s", len(cis), cid, proj) + } + + log.Printf("Pushing %v to refs/changes/%d ...", headSHA, changeNum) + // Push again + { + push := git("push", + "https://camlistore-review.googlesource.com/"+proj, + headSHA+":refs/changes/"+strconv.Itoa(changeNum)) + push.Stdout = os.Stdout + push.Stderr = os.Stderr + if err := push.Run(); err != nil { + return fmt.Errorf("git push of head commit %s: %v", headSHA, err) + } + } + cd, err := b.gr.GetChangeDetail(proj + "~master~" + cid) + log.Printf("Change detail = %+v, %v", cd, err) + return nil +} + +func main() { + flag.Parse() + readGithubConfig() + + bot := NewBot() + + log.Printf("Sync = %v", bot.Sync(PullRequest{Repo{Owner: "camlistore", Repo: "go4"}, 13})) +} + +func fs(s *string) string { + if s == nil { + return "" + } + return *s +} + +func readGithubConfig() { + file := filepath.Join(homeDir(), "keys", "github-letsusegerrit.token") + slurp, err := ioutil.ReadFile(file) + if err != nil { + log.Fatal(err) + } + f := strings.Fields(strings.TrimSpace(string(slurp))) + if len(f) != 2 { + log.Fatalf("expected two fields (user and token) in %v; got %d fields", file, len(f)) + } + githubUser, githubToken = f[0], f[1] +} + +func homeDir() string { + if runtime.GOOS == "windows" { + return os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + } + return os.Getenv("HOME") +} + +func parseRawGitCommit(raw []byte) (hdrs map[string][]string, body string) { + f := strings.SplitN(string(raw), "\n\n", 2) + if len(f) != 2 { + return + } + body = f[1] + hdrs = make(map[string][]string) + for _, line := range strings.Split(strings.TrimSpace(f[0]), "\n") { + sp := strings.IndexByte(line, ' ') + if sp == -1 { + continue + } + k, v := line[:sp], line[sp+1:] + hdrs[k] = append(hdrs[k], v) + } + return +}