blob: 11021ac2e08f0dffe49095f64e47fe2b19663f62 [file] [log] [blame]
// Copyright 2015 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.
/*
CL prints a list of open Go code reviews (also known as change lists, or CLs).
Usage:
cl [-closed] [-dnr] [-json] [-r] [-url] [query ...]
CL searches Gerrit for CLs matching the query and then
prints a line for each CL that is waiting for review
(as opposed to waiting for revisions by the author).
The output line looks like:
CL 9225 0/ 2d go rsc austin* cmd/internal/gc: emit write barrier
From left to right, the columns show the CL number,
the number of days the CL has been in the current waiting state
(waiting for author or waiting for review),
the number of days since the CL was created,
the project name ("go" or the name of a subrepository),
the author, the reviewer, and the subject.
If the query restricts results to a single project
(for example, "cl project:go"), the project column is elided.
If the CL is waiting for revisions by the author,
the author column has an asterisk.
If the CL is waiting for a reviewer, the reviewer column
has an asterisk.
If the CL has been reviewed by the reviewer,
the reviewer column shows the current score.
By default, CL omits closed CLs, those with an R=close reply
and no subsequent upload of a new patch set.
If the -closed flag is specified, CL adds closed CLs to the output.
By default, CL omits CLs containing ``DO NOT REVIEW'' in the
latest patch's commit message.
If the -dnr flag is specified, CL includes those CLs in its output.
If the -r flag is specified, CL shows only CLs that need review,
not those waiting for the author. In this mode, the
redundant ``waiting for reviewer'' asterisk is elided.
If the -url flag is specified, CL replaces "CL 1234" at the beginning
of each output line with a full URL, "https://golang.org/cl/1234".
By default, CL sorts the output first by the combination of
project name and change subject.
The -sort flag changes the sort order. The choices are
"delay", to sort by the time the change has been in the current
waiting state, and "age", to sort by creation time.
When sorting, ties are broken by CL number.
If the -json flag is specified, CL does not print the usual listing.
Instead, it prints a JSON array holding CL objects, one for each
matched CL. Each of the CL objects is generated by this Go struct:
type CL struct {
Number int // CL number
Subject string // subject (first line of commit message)
Project string // "go" or a subrepository name
Author string // author, short form or else full email
AuthorEmail string // author, full email
Reviewer string // expected reviewer, short form or else full email
ReviewerEmail string // expected reviewer, full email
Start time.Time // time CL was first uploaded
NeedsReview bool // CL is waiting for reviewer (otherwise author)
NeedsReviewChanged time.Time // time NeedsReview last changed
Closed bool // CL closed with R=close
DoNotReview bool // CL marked DO NOT REVIEW
Issues []int // issues referenced by commit message
Scores map[string]int // current review scores
Files []string // files changed in CL
}
*/
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/build/gerrit"
)
var (
flagClosed = flag.Bool("closed", false, "include CLs that are closed or DO NOT REVIEW")
flagDoNotReview = flag.Bool("dnr", false, "print only CLs in need of review")
flagNeedsReview = flag.Bool("r", false, "print only CLs in need of review")
flagJSON = flag.Bool("json", false, "print CLs in JSON format")
flagURL = flag.Bool("url", false, "print full URLs for CLs")
flagSort = flag.String("sort", "", "sort by `order` (age or delay) instead of project+subject")
)
func usage() {
fmt.Fprintf(os.Stderr, "usage: cl [query]\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
flag.PrintDefaults()
os.Exit(2)
}
var now = time.Now() // so time stays the same during computations
func main() {
log.SetFlags(0)
log.SetPrefix("cl: ")
flag.Usage = usage
flag.Parse()
switch *flagSort {
case "", "age", "delay":
// ok
default:
log.Fatal("unknown sort order")
}
c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
query := strings.Join(flag.Args(), " ")
open := "is:open"
if strings.Contains(query, " is:") || strings.HasPrefix(query, "is:") {
open = ""
}
cis, err := c.QueryChanges(open+" -project:scratch -message:do-not-review "+query, gerrit.QueryChangesOpt{
N: 5000,
Fields: []string{
"LABELS",
"CURRENT_FILES",
"CURRENT_REVISION",
"CURRENT_COMMIT",
"MESSAGES",
"DETAILED_ACCOUNTS", // fill out Owner.AuthorInfo, etc
"DETAILED_LABELS",
},
})
if err != nil {
log.Fatalf("error querying changes: %v", err)
}
cls := []*CL{} // non-nil for json
for _, ci := range cis {
cl := parseCL(ci)
if *flagNeedsReview && !cl.NeedsReview || !*flagClosed && (cl.Closed || cl.DoNotReview) {
continue
}
cls = append(cls, cl)
}
switch *flagSort {
case "":
sort.Sort(byRepoAndSubject(cls))
case "age":
sort.Sort(byAge(cls))
case "delay":
sort.Sort(byDelay(cls))
}
if *flagJSON {
data, err := json.MarshalIndent(cls, "", "\t")
if err != nil {
log.Fatal(err)
}
data = append(data, '\n')
os.Stdout.Write(data)
return
}
clPrefix := "CL "
if *flagURL {
clPrefix = "https://golang.org/cl/"
}
var projectLen, authorLen, reviewerLen int
for _, cl := range cls {
projectLen = max(projectLen, len(cl.Project))
authorLen = max(authorLen, len(cl.Author))
reviewerLen = max(reviewerLen, len(cl.Reviewer))
}
if authorLen > 12 {
authorLen = 12
}
if reviewerLen > 12 {
reviewerLen = 12
}
authorLen += 1 // for *
reviewerLen += 3 // for +2*
var buf bytes.Buffer
for _, cl := range cls {
fmt.Fprintf(&buf, "%s%-5d %3.0f/%3.0fd %-*s %-*s %-*s %s%s\n",
clPrefix, cl.Number,
cl.Delay().Seconds()/86400, cl.Age().Seconds()/86400,
projectLen, cl.Project,
authorLen, authorString(cl, authorLen),
reviewerLen, reviewerString(cl, reviewerLen),
cl.Subject, issuesString(cl))
}
os.Stdout.Write(buf.Bytes())
}
func max(i, j int) int {
if i < j {
return j
}
return i
}
// CL records information about a single CL.
type CL struct {
Number int // CL number
Subject string // subject (first line of commit message)
Project string // "go" or a subrepository name
Author string // author, short form or else full email
AuthorEmail string // author, full email
Reviewer string // expected reviewer, short form or else full email
ReviewerEmail string // expected reviewer, full email
Start time.Time // time CL was first uploaded
NeedsReview bool // CL is waiting for reviewer (otherwise author)
NeedsReviewChanged time.Time // time NeedsReview last changed
Closed bool // CL closed with R=close
DoNotReview bool // CL marked DO NOT REVIEW
Issues []int // issues referenced by commit message
Scores map[string]int // current review scores
Files []string // files changed in CL
Status string // "new", "submitted", "merged", ...
}
func (cl *CL) Age() time.Duration {
return now.Sub(cl.Start)
}
func (cl *CL) Delay() time.Duration {
return now.Sub(cl.NeedsReviewChanged)
}
// authorString returns the author column, limited to n bytes.
func authorString(cl *CL, n int) string {
suffix := ""
if !cl.NeedsReview {
suffix = "*"
}
return truncate(cl.Author, n-len(suffix)) + suffix
}
// reviewerString returns the reviewer column, limited to n bytes.
func reviewerString(cl *CL, n int) string {
suffix := ""
if cl.NeedsReview && !*flagNeedsReview {
suffix = "*"
}
if score := cl.Scores[cl.ReviewerEmail]; score != 0 {
suffix = fmt.Sprintf("%+d", score) + suffix
}
return truncate(cl.Reviewer, n-len(suffix)) + suffix
}
// truncate returns the name truncated to n bytes.
func truncate(text string, n int) string {
if len(text) <= n {
return text
}
return text[:n-3] + "..."
}
func issuesString(cl *CL) string {
s := ""
for _, id := range cl.Issues {
s += fmt.Sprintf(" #%d", id)
}
return s
}
// Parsing of Gerrit information and review messages to produce CL structure.
var (
reviewerRE = regexp.MustCompile(`(?m)^R=([\w\-.@]+)\b`)
scoreRE = regexp.MustCompile(`\APatch Set \d+: Code-Review([+-][0-9]+)`)
removedScoreRE = regexp.MustCompile(`\ARemoved the following votes:\n\n\* Code-Review([+-][0-9]+) by [^\n]* <([^\n]*)>`)
issueRefRE = regexp.MustCompile(`#\d+\b`)
goIssueRefRE = regexp.MustCompile(`\bgolang/go#\d+\b`)
)
const goReleaseCycle = 6 // working on Go 1.x
func parseCL(ci *gerrit.ChangeInfo) *CL {
loadReviewers()
// Gather information.
var (
scores = make(map[string]int)
initialReviewer = ""
firstResponder = ""
explicitReviewer = ""
closeReason = ""
)
for _, msg := range ci.Messages {
if msg.Author == nil { // happens for Gerrit-generated messages
continue
}
if strings.HasPrefix(msg.Message, "Uploaded patch set ") {
if explicitReviewer == "close" && !strings.HasPrefix(closeReason, "Go") {
explicitReviewer = ""
closeReason = ""
}
for who, score := range scores {
if score == +1 || score == -1 {
delete(scores, who)
}
}
}
if m := reviewerRE.FindStringSubmatch(msg.Message); m != nil {
if m[1] == "close" {
explicitReviewer = "close"
closeReason = "Closed"
} else if strings.HasPrefix(m[1], "go1.") {
n, _ := strconv.Atoi(m[1][len("go1."):])
if n > goReleaseCycle {
explicitReviewer = "close"
closeReason = "Go" + m[1][2:]
}
} else if m[1] == "golang-dev" || m[1] == "golang-codereviews" {
explicitReviewer = "golang-dev"
} else if x := mailLookup[m[1]]; x != "" {
explicitReviewer = x
}
}
if m := scoreRE.FindStringSubmatch(msg.Message); m != nil {
n, _ := strconv.Atoi(m[1])
scores[msg.Author.Email] = n
}
if m := removedScoreRE.FindStringSubmatch(msg.Message); m != nil {
delete(scores, m[1])
}
if firstResponder == "" && isReviewer[msg.Author.Email] && msg.Author.Email != ci.Owner.Email {
firstResponder = msg.Author.Email
}
}
cl := &CL{
Number: ci.ChangeNumber,
Subject: ci.Subject,
Project: ci.Project,
Author: shorten(ci.Owner.Email),
AuthorEmail: ci.Owner.Email,
Scores: scores,
Status: strings.ToLower(ci.Status),
}
// Determine reviewer, in priorty order.
// When breaking ties, give preference to R= setting.
// Otherwise compare by email address.
maybe := func(who string) {
if cl.ReviewerEmail == "" || who == explicitReviewer || cl.ReviewerEmail != explicitReviewer && cl.ReviewerEmail > who {
cl.ReviewerEmail = who
}
}
// 1. Anyone who -2'ed the CL.
if cl.ReviewerEmail == "" {
for who, score := range scores {
if score == -2 {
maybe(who)
}
}
}
// 2. Anyone who +2'ed the CL.
if cl.ReviewerEmail == "" {
for who, score := range scores {
if score == +2 {
maybe(who)
}
}
}
// 2½. Even if a CL is +2 or -2, R=closed wins,
// so that it doesn't appear in listings by default.
if explicitReviewer == "close" {
cl.ReviewerEmail = "close"
}
// 3. Last explicit R= in review message.
if cl.ReviewerEmail == "" {
cl.ReviewerEmail = explicitReviewer
}
// 4. Initial target of review requecl.
// TODO: If there's some way to figure this out, do so.
_ = initialReviewer
// 5. Whoever responds first and looks like a reviewer.
if cl.ReviewerEmail == "" {
cl.ReviewerEmail = firstResponder
}
// Allow R=golang-dev in #2 as "unassign".
if cl.ReviewerEmail == "golang-dev" {
cl.ReviewerEmail = ""
}
cl.Reviewer = shorten(cl.ReviewerEmail)
// Now that we know who the reviewer is,
// figure out whether the CL is in need of review
// (or else is waiting for the author to do more work).
for _, msg := range ci.Messages {
if msg.Author == nil { // happens for Gerrit-generated messages
continue
}
if cl.Start.IsZero() {
cl.Start = msg.Time.Time()
}
if strings.HasPrefix(msg.Message, "Uploaded patch set ") {
cl.NeedsReview = true
cl.NeedsReviewChanged = msg.Time.Time()
}
if msg.Author.Email == cl.ReviewerEmail {
cl.NeedsReview = false
cl.NeedsReviewChanged = msg.Time.Time()
}
}
if cl.ReviewerEmail == "close" {
cl.Reviewer = closeReason
cl.ReviewerEmail = ""
cl.Closed = true
cl.NeedsReview = false
}
// DO NOT REVIEW overrides anything in the CL state.
// We shouldn't see these, because the query always
// contains -message:do-not-review, but check anyway.
if _, ok := ci.Labels["Do-Not-Review"]; ok {
cl.Reviewer = "DoNotReview"
cl.ReviewerEmail = ""
cl.DoNotReview = true
cl.NeedsReview = false
}
// Find issue numbers.
cl.Issues = []int{} // non-nil for json
refRE := issueRefRE
if cl.Project != "go" {
refRE = goIssueRefRE
}
rev := ci.Revisions[ci.CurrentRevision]
for file := range rev.Files {
cl.Files = append(cl.Files, file)
}
sort.Strings(cl.Files)
if rev.Commit != nil {
for _, ref := range refRE.FindAllString(rev.Commit.Message, -1) {
n, _ := strconv.Atoi(ref[strings.Index(ref, "#")+1:])
if n != 0 {
cl.Issues = append(cl.Issues, n)
}
}
}
cl.Issues = uniq(cl.Issues)
return cl
}
func uniq(x []int) []int {
sort.Ints(x)
out := x[:0]
for _, v := range x {
if len(out) == 0 || out[len(out)-1] != v {
out = append(out, v)
}
}
return out
}
func shorten(email string) string {
if i := strings.Index(email, "@"); i >= 0 {
if mailLookup[email[:i]] == email {
return email[:i]
}
}
return email
}
// Sort interfaces.
type byRepoAndSubject []*CL
func (x byRepoAndSubject) Len() int { return len(x) }
func (x byRepoAndSubject) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
func (x byRepoAndSubject) Less(i, j int) bool {
if x[i].Project != x[j].Project {
return projectOrder(x[i].Project) < projectOrder(x[j].Project)
}
if x[i].Subject != x[j].Subject {
return x[i].Subject < x[j].Subject
}
return x[i].Number < x[j].Number
}
type byAge []*CL
func (x byAge) Len() int { return len(x) }
func (x byAge) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
func (x byAge) Less(i, j int) bool {
if !x[i].Start.Equal(x[j].Start) {
return x[i].Start.Before(x[j].Start)
}
return x[i].Number > x[j].Number
}
type byDelay []*CL
func (x byDelay) Len() int { return len(x) }
func (x byDelay) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
func (x byDelay) Less(i, j int) bool {
if !x[i].NeedsReviewChanged.Equal(x[j].NeedsReviewChanged) {
return x[i].NeedsReviewChanged.Before(x[j].NeedsReviewChanged)
}
return x[i].Number < x[j].Number
}
func projectOrder(name string) string {
if name == "go" {
return "\x00" // sort before everything except empty string
}
return name
}