| // Copyright 2016 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. |
| |
| package godash |
| |
| import ( |
| "bytes" |
| "fmt" |
| "regexp" |
| "sort" |
| "strconv" |
| "strings" |
| "time" |
| |
| "golang.org/x/build/gerrit" |
| ) |
| |
| // Parsing of Gerrit information and review messages to produce CL structure. |
| |
| var ( |
| reviewerRE = regexp.MustCompile(`(?m)^R=([\w\-.@]+)\b`) |
| issueRefRE = regexp.MustCompile(`#\d+\b`) |
| goIssueRefRE = regexp.MustCompile(`\bgolang/go#\d+\b`) |
| ) |
| |
| // ParseCL takes a ChangeInfo as returned from the Gerrit API and |
| // applies Go-project-specific logic to turn it into a CL struct. The |
| // primary information that is added is the CL's state in the Go |
| // review process, based on parsing R= lines in the comments and the |
| // most recent commit message of the CL. |
| func ParseCL(ci *gerrit.ChangeInfo, reviewers *Reviewers, goReleaseCycle int) *CL { |
| // Gather information. |
| var ( |
| scores = make(map[string]int) |
| initialReviewer = "" |
| firstResponder = "" |
| explicitReviewer = "" |
| closeReason = "" |
| ) |
| parseReviewer := func(msg string) { |
| if m := reviewerRE.FindStringSubmatch(msg); 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 := reviewers.Resolve(m[1]); x != "" { |
| explicitReviewer = x |
| } |
| } |
| } |
| |
| rev := ci.Revisions[ci.CurrentRevision] |
| |
| 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 = "" |
| } |
| if msg.RevisionNumber == rev.PatchSetNumber && rev.Commit != nil { |
| parseReviewer(rev.Commit.Message) |
| } |
| } |
| parseReviewer(msg.Message) |
| if firstResponder == "" && reviewers.IsReviewer(msg.Author.Email) && msg.Author.Email != ci.Owner.Email { |
| firstResponder = msg.Author.Email |
| } |
| } |
| for _, score := range ci.Labels["Code-Review"].All { |
| scores[score.Email] = score.Value |
| } |
| |
| cl := &CL{ |
| Number: ci.ChangeNumber, |
| Subject: ci.Subject, |
| Project: ci.Project, |
| Author: reviewers.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 = reviewers.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 |
| } |
| |
| 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 |
| } |
| |
| // CL records information about a single CL. |
| // This is also used by golang.org/x/build/cmd/cl and any changes need |
| // to reflected in its doc comment. |
| 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(now time.Time) time.Duration { |
| return now.Sub(cl.Start) |
| } |
| |
| func (cl *CL) Delay(now time.Time) time.Duration { |
| return now.Sub(cl.NeedsReviewChanged) |
| } |
| |
| func (cl *CL) Summary(now time.Time) string { |
| var buf bytes.Buffer |
| who := "author" |
| if cl.NeedsReview { |
| who = "reviewer" |
| } |
| rev := cl.Reviewer |
| if rev == "" { |
| rev = "???" |
| } |
| score := "" |
| if x := cl.Scores[cl.ReviewerEmail]; x != 0 { |
| score = fmt.Sprintf("%+d", x) |
| } |
| fmt.Fprintf(&buf, "%s → %s%s, %d/%d days, waiting for %s", cl.Author, rev, score, int(now.Sub(cl.NeedsReviewChanged).Seconds()/86400), int(now.Sub(cl.Start).Seconds()/86400), who) |
| for _, id := range cl.Issues { |
| fmt.Fprintf(&buf, " #%d", id) |
| } |
| return buf.String() |
| } |