├── LICENSE ├── README.md └── bot.go /LICENSE: -------------------------------------------------------------------------------- 1 | Licensed under the same terms as Go itself: 2 | 3 | https://golang.org/LICENSE 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gerritbot -------------------------------------------------------------------------------- /bot.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 The Go Authors: https://golang.org/AUTHORS 2 | // Licensed under the same terms as Go itself: https://golang.org/LICENSE 3 | 4 | // The gerritbot command is the the start of a Github Pull Request to 5 | // Gerrit code review bot. 6 | // 7 | // It is incomplete. 8 | // 9 | // The idea is that users won't need to use Gerrit for all changes. 10 | // Github can continue to be the canonical Git repo for projects 11 | // but users sending PRs (or more likely: the people reviewing the PRs) 12 | // can use Gerrit selectively for some reviews. This bot mirrors the PR 13 | // to Gerrit for review there. 14 | // 15 | // Again, it is incomplete. 16 | package main 17 | 18 | import ( 19 | "bytes" 20 | "crypto/sha1" 21 | "crypto/tls" 22 | "errors" 23 | "flag" 24 | "fmt" 25 | "io/ioutil" 26 | "log" 27 | "net" 28 | "net/http" 29 | "os" 30 | "os/exec" 31 | "path/filepath" 32 | "regexp" 33 | "runtime" 34 | "strconv" 35 | "strings" 36 | 37 | "github.com/google/go-github/github" 38 | "golang.org/x/build/gerrit" 39 | "golang.org/x/net/context" 40 | "golang.org/x/oauth2" 41 | ) 42 | 43 | var githubUser, githubToken string 44 | 45 | type Repo struct { 46 | Owner string 47 | Repo string 48 | } 49 | 50 | func (r Repo) String() string { return r.Owner + "/" + r.Repo } 51 | 52 | type PullRequest struct { 53 | Repo 54 | Number int 55 | } 56 | 57 | func (pr PullRequest) ChangeID() string { 58 | d := sha1.New() 59 | fmt.Fprintf(d, "%s/%d", pr.Repo, pr.Number) 60 | return fmt.Sprintf("I%x", d.Sum(nil)) 61 | } 62 | 63 | type GerritProject struct { 64 | // Instance is the googlesource.com Gerrit instance 65 | // hostname. ("go", "code", "camlistore", etc.) 66 | Instance string 67 | 68 | // Project is the Gerrit project name on Instance. 69 | Project string 70 | } 71 | 72 | func (gp GerritProject) Client() *gerrit.Client { 73 | cookieFile := filepath.Join(homeDir(), "keys", "gerrit-letsusegerrit.cookies") 74 | if _, err := os.Stat(cookieFile); err != nil { 75 | log.Fatalf("Can't stat cookie file for Gerrit: %v", cookieFile) 76 | } 77 | return gerrit.NewClient("https://"+gp.Instance+"-review.googlesource.com", gerrit.GitCookieFileAuth(cookieFile)) 78 | } 79 | 80 | func (pr PullRequest) GerritProject() GerritProject { 81 | if pr.Repo.Owner == "camlistore" && pr.Repo.Repo == "go4" { 82 | return GerritProject{"camlistore", "review-go4"} 83 | } 84 | if pr.Repo.Owner == "grpc" && pr.Repo.Repo == "grpc-go" { 85 | return GerritProject{"go", "grpc-review"} 86 | } 87 | return GerritProject{"code", strings.ToLower(fmt.Sprintf("review-github-%s-%s", pr.Repo.Owner, pr.Repo.Repo))} 88 | } 89 | 90 | type logConn struct { 91 | net.Conn 92 | } 93 | 94 | func (c logConn) Write(p []byte) (n int, err error) { 95 | log.Printf("Write: %q", p) 96 | return c.Conn.Write(p) 97 | } 98 | 99 | func (c logConn) Read(p []byte) (n int, err error) { 100 | n, err = c.Conn.Read(p) 101 | log.Printf("Read: %q, %v", p[:n], err) 102 | return 103 | } 104 | 105 | var logClient = &http.Client{ 106 | Transport: &http.Transport{ 107 | DialTLS: func(netw, addr string) (net.Conn, error) { 108 | log.Printf("need to dial %s, %s", netw, addr) 109 | c, err := tls.Dial(netw, addr, &tls.Config{ServerName: "api.github.com"}) 110 | if err != nil { 111 | return nil, err 112 | } 113 | return logConn{c}, nil 114 | }, 115 | }, 116 | } 117 | 118 | var verboseHTTP = flag.Bool("verbose_http", false, "Verbose HTTP debugging") 119 | 120 | type Bot struct { 121 | gh *github.Client 122 | } 123 | 124 | func NewBot() *Bot { 125 | baseHTTP := http.DefaultClient 126 | if *verboseHTTP { 127 | baseHTTP = logClient 128 | } 129 | gh := github.NewClient(oauth2.NewClient( 130 | context.WithValue(context.Background(), oauth2.HTTPClient, baseHTTP), 131 | oauth2.StaticTokenSource(&oauth2.Token{AccessToken: githubToken}), 132 | )) 133 | 134 | return &Bot{ 135 | gh: gh, 136 | } 137 | } 138 | 139 | func closeRes(res *github.Response) { 140 | if res != nil && res.Body != nil { 141 | res.Body.Close() 142 | } 143 | } 144 | 145 | func (b *Bot) CheckNotifications() error { 146 | notifs, res, err := b.gh.Activity.ListNotifications(&github.NotificationListOptions{All: true}) 147 | defer closeRes(res) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | log.Printf("Notifs: %d", len(notifs)) 153 | for _, n := range notifs { 154 | log.Printf("Notif: %v, repo:%v, %+v", fs(n.ID), fs(n.Repository.FullName), fs(n.Subject.Title)) 155 | } 156 | return nil 157 | } 158 | 159 | func (b *Bot) CheckPulls(owner, repo string) error { 160 | pulls, res, err := b.gh.PullRequests.List(owner, repo, nil) 161 | defer closeRes(res) 162 | if err != nil { 163 | return err 164 | } 165 | log.Printf("%d pulls", len(pulls)) 166 | for _, pr := range pulls { 167 | log.Printf("PR: %v", github.Stringify(pr)) 168 | } 169 | return nil 170 | } 171 | 172 | func (b *Bot) CommentGithub(owner, repo string, number int, comment string) error { 173 | prc, res, err := b.gh.Issues.CreateComment(owner, repo, number, &github.IssueComment{ 174 | Body: &comment, 175 | }) 176 | defer closeRes(res) 177 | if err != nil { 178 | return err 179 | } 180 | log.Printf("Got: %v, %v, %v", github.Stringify(prc), res, err) 181 | return nil 182 | } 183 | 184 | func (b *Bot) CommentGithubNoDup(owner, repo string, number int, comment string) error { 185 | comments, res, err := b.gh.Issues.ListComments(owner, repo, number, &github.IssueListCommentsOptions{ 186 | ListOptions: github.ListOptions{ 187 | PerPage: 1000, 188 | }, 189 | }) 190 | defer closeRes(res) 191 | if err != nil { 192 | return err 193 | } 194 | for _, ic := range comments { 195 | if ic.Body != nil && *ic.Body == comment { 196 | return nil 197 | } 198 | } 199 | return b.CommentGithub(owner, repo, number, comment) 200 | } 201 | 202 | func (b *Bot) CommentGerrit(gr *gerrit.Client, number int, comment string) error { 203 | return gr.SetReview(fmt.Sprint(number), "current", gerrit.ReviewInput{ 204 | Message: comment, 205 | }) 206 | } 207 | 208 | var ( 209 | changeIdRx = regexp.MustCompile(`(?m)^Change-Id: (I\w+)\b`) 210 | 211 | // parses out: 212 | // remote: New Changes: 213 | // remote: https://camlistore-review.googlesource.com/5991 README: whitespace cleanup 214 | gitNewChangeRx = regexp.MustCompile(`New Changes:.+\n.+https://\w+-review\.googlesource\.com/(\d+)`) 215 | ) 216 | 217 | func (b *Bot) Sync(pr PullRequest) error { 218 | prd, res, err := b.gh.PullRequests.Get(pr.Owner, pr.Repo.Repo, pr.Number) 219 | defer closeRes(res) 220 | if err != nil { 221 | return err 222 | } 223 | if prd.Head == nil || prd.Base == nil || prd.State == nil || prd.Title == nil || prd.Commits == nil { 224 | return errors.New("nil fields from github API") 225 | } 226 | if *prd.Commits == 0 { 227 | // Um, nothing to do? 228 | return nil 229 | } 230 | if *prd.Commits > 1 { 231 | return b.CommentGithubNoDup(pr.Owner, pr.Repo.Repo, pr.Number, 232 | 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.", 233 | *prd.Head.SHA, *prd.Commits)) 234 | } 235 | 236 | state := *prd.State 237 | title := *prd.Title 238 | log.Printf("State %s, title %q, commits %d", state, title, *prd.Commits) 239 | 240 | baseSHA := *prd.Base.SHA 241 | headSHA := *prd.Head.SHA 242 | log.Printf("Base: %s Head: %s", baseSHA, headSHA) 243 | 244 | gp := pr.GerritProject() 245 | grInst := gp.Instance 246 | proj := gp.Project 247 | //log.Fatalf("Got: %+v %v, %v", gp, grInst, proj) 248 | 249 | gr := gp.Client() 250 | 251 | pi, err := gr.GetProjectInfo(proj) 252 | if err != nil { 253 | log.Printf("gerrit project %s: %v", proj, err) 254 | if err == gerrit.ErrProjectNotExist { 255 | pi, err = gr.CreateProject(proj) 256 | if err != nil { 257 | return fmt.Errorf("error creating gerrit project %s: %v", proj, err) 258 | } 259 | } 260 | } 261 | log.Printf("Gerrit project: %v", pi) 262 | 263 | gitDir := filepath.Join(homeDir(), "var", "letsusegerrit", "git-tmp-"+proj) 264 | if err := os.MkdirAll(gitDir, 0700); err != nil { 265 | return err 266 | } 267 | 268 | branch := fmt.Sprintf("PR/%d", pr.Number) 269 | 270 | q := "project:" + proj + " branch:" + branch 271 | log.Printf("Running search query: %q", q) 272 | cis, err := gr.QueryChanges(q, gerrit.QueryChangesOpt{Fields: []string{"CURRENT_REVISION"}}) 273 | log.Printf("Query %q = %d results, %v", q, len(cis), err) 274 | if err != nil { 275 | return fmt.Errorf("Gerrit.QueryChanges = %v", err) 276 | } 277 | 278 | var changeNum int 279 | 280 | updateGithubComment := func() error { 281 | return b.CommentGithubNoDup(pr.Owner, pr.Repo.Repo, pr.Number, 282 | fmt.Sprintf("Gerrit code review: https://%s-review.googlesource.com/%d (at git rev %s)", grInst, changeNum, headSHA[:7])) 283 | } 284 | 285 | if len(cis) > 0 { 286 | // If it's more than 1, we pick the lowest. 287 | var changeInfo *gerrit.ChangeInfo 288 | for _, ci := range cis { 289 | if changeNum == 0 || ci.ChangeNumber < changeNum { 290 | changeNum = ci.ChangeNumber 291 | changeInfo = ci 292 | } 293 | } 294 | if changeInfo.CurrentRevision == headSHA { 295 | log.Printf("Gerrit is up-to-date.") 296 | return updateGithubComment() 297 | } 298 | } 299 | 300 | // Hard-code an ongoing review which predates some of the conventions in this tool. 301 | if pr.Number == 514 && pr.Owner == "grpc" { 302 | changeNum = 19272 303 | branch = "master" 304 | } 305 | 306 | git := func(args ...string) *exec.Cmd { 307 | args = append([]string{ 308 | "-c", "http.cookiefile=/home/bradfitz/keys/gerrit-letsusegerrit.cookies", 309 | }, args...) 310 | cmd := exec.Command("git", args...) 311 | cmd.Dir = gitDir 312 | return cmd 313 | } 314 | 315 | if _, err := os.Stat(filepath.Join(gitDir, ".git")); os.IsNotExist(err) { 316 | if err := git("init").Run(); err != nil { 317 | return fmt.Errorf("git init: %v", err) 318 | } 319 | } 320 | 321 | // Fetch head 322 | { 323 | fetch := func(br *github.PullRequestBranch) error { 324 | log.Printf("Fetching %s refs/heads/%s", *br.Repo.CloneURL, *br.Ref) 325 | if out, err := git("fetch", "--update-head-ok", *br.Repo.CloneURL, "refs/heads/"+*br.Ref).CombinedOutput(); err != nil { 326 | return fmt.Errorf("git fetch from %s: %v, %s", *br.Repo.CloneURL, err, out) 327 | } 328 | log.Printf("Fetched.") 329 | return nil 330 | } 331 | if err := fetch(prd.Head); err != nil { 332 | return err 333 | } 334 | if err := fetch(prd.Base); err != nil { 335 | return err 336 | } 337 | } 338 | 339 | var parent string 340 | 341 | // Get raw commit, both to verify that we got it above, and to verify it 342 | // has exactly 1 parent, and that if it has a Change-Id line at all, it 343 | // is at least the one we expect. 344 | { 345 | cat := git("cat-file", "-p", *prd.Head.SHA) 346 | var errbuf bytes.Buffer 347 | cat.Stderr = &errbuf 348 | out, err := cat.Output() 349 | if err != nil { 350 | return fmt.Errorf("git cat-file %s: %v, %s", *prd.Head.SHA, err, errbuf.Bytes()) 351 | } 352 | hdrs, _ := parseRawGitCommit(out) 353 | parents := hdrs["parent"] 354 | if len(parents) != 1 { 355 | return fmt.Errorf("Head git commit %v has %d parents. LetsUseGerrit does not support reviewing merge commits.", 356 | *prd.Head.SHA, len(parents)) 357 | } 358 | parent = parents[0] 359 | } 360 | 361 | if changeNum == 0 { 362 | log.Printf("Need to make first commit in refs/heads/%s; pushing parent %s", branch, parent) 363 | if out, err := git("push", "-f", 364 | "https://"+grInst+"-review.googlesource.com/"+proj, 365 | parent+":refs/heads/"+branch).Output(); err != nil { 366 | return fmt.Errorf("git push of parent %s to refs/heads/%s: %v, %s", 367 | parent, branch, err, out) 368 | } 369 | 370 | log.Printf("Pushing PR's head commit %s to refs/for/%s", headSHA, branch) 371 | out, err := git( 372 | "push", 373 | "https://"+grInst+"-review.googlesource.com/"+proj, 374 | headSHA+":refs/for/"+branch).CombinedOutput() 375 | if err != nil { 376 | return fmt.Errorf("git push: %v, %s", err, out) 377 | } 378 | m := gitNewChangeRx.FindStringSubmatch(string(out)) 379 | if m == nil { 380 | return fmt.Errorf("git push expected a change number in output; got: %s", out) 381 | } 382 | changeNum, err = strconv.Atoi(m[1]) 383 | if err != nil { 384 | return fmt.Errorf("Atoi(%q) after git push of new change: %v", m[1], err) 385 | } 386 | } else { 387 | log.Printf("Pushing %v to refs/changes/%d", headSHA, changeNum) 388 | if out, err := git("push", 389 | "https://"+grInst+"-review.googlesource.com/"+proj, 390 | headSHA+":refs/changes/"+strconv.Itoa(changeNum)).Output(); err != nil { 391 | return fmt.Errorf("git push of head commit %s: %v, %s", headSHA, err, out) 392 | } 393 | } 394 | return updateGithubComment() 395 | } 396 | 397 | func main() { 398 | flag.Parse() 399 | readGithubConfig() 400 | 401 | if flag.NArg() != 1 { 402 | log.Fatalf("Usage: bot.go ") 403 | } 404 | 405 | m := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)`).FindStringSubmatch(flag.Arg(0)) 406 | if m == nil { 407 | log.Fatalf("Unrecognized pull request URL %q", flag.Arg(0)) 408 | } 409 | n, _ := strconv.Atoi(m[3]) 410 | pr := PullRequest{ 411 | Repo: Repo{Owner: m[1], Repo: m[2]}, 412 | Number: n, 413 | } 414 | 415 | bot := NewBot() 416 | log.Printf("Sync of %s = %v", flag.Arg(0), bot.Sync(pr)) 417 | } 418 | 419 | func fs(s *string) string { 420 | if s == nil { 421 | return "" 422 | } 423 | return *s 424 | } 425 | 426 | func readGithubConfig() { 427 | file := filepath.Join(homeDir(), "keys", "github-letsusegerrit.token") 428 | slurp, err := ioutil.ReadFile(file) 429 | if err != nil { 430 | log.Fatal(err) 431 | } 432 | f := strings.Fields(strings.TrimSpace(string(slurp))) 433 | if len(f) != 2 { 434 | log.Fatalf("expected two fields (user and token) in %v; got %d fields", file, len(f)) 435 | } 436 | githubUser, githubToken = f[0], f[1] 437 | } 438 | 439 | func homeDir() string { 440 | if runtime.GOOS == "windows" { 441 | return os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") 442 | } 443 | return os.Getenv("HOME") 444 | } 445 | 446 | func parseRawGitCommit(raw []byte) (hdrs map[string][]string, body string) { 447 | f := strings.SplitN(string(raw), "\n\n", 2) 448 | if len(f) != 2 { 449 | return 450 | } 451 | body = f[1] 452 | hdrs = make(map[string][]string) 453 | for _, line := range strings.Split(strings.TrimSpace(f[0]), "\n") { 454 | sp := strings.IndexByte(line, ' ') 455 | if sp == -1 { 456 | continue 457 | } 458 | k, v := line[:sp], line[sp+1:] 459 | hdrs[k] = append(hdrs[k], v) 460 | } 461 | return 462 | } 463 | --------------------------------------------------------------------------------