| // Copyright 2012 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 dashboard |
| |
| // This file handles operations on the CL entity kind. |
| |
| import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| "html/template" |
| "io" |
| "io/ioutil" |
| "net/http" |
| "net/url" |
| "regexp" |
| "sort" |
| "strings" |
| "time" |
| |
| "appengine" |
| "appengine/datastore" |
| "appengine/taskqueue" |
| "appengine/urlfetch" |
| "appengine/user" |
| ) |
| |
| func init() { |
| http.HandleFunc("/assign", handleAssign) |
| http.HandleFunc("/update-cl", handleUpdateCL) |
| } |
| |
| const codereviewBase = "http://codereview.appspot.com" |
| const gobotBase = "http://research.swtch.com/gobot_codereview" |
| |
| var clRegexp = regexp.MustCompile(`\d+`) |
| |
| // CL represents a code review. |
| type CL struct { |
| Number string // e.g. "5903061" |
| Closed bool |
| Owner string // email address |
| |
| Created, Modified time.Time |
| |
| Description []byte `datastore:",noindex"` |
| FirstLine string `datastore:",noindex"` |
| LGTMs []string |
| NotLGTMs []string |
| LastUpdateBy string // author of most recent review message |
| LastUpdate string `datastore:",noindex"` // first line of most recent review message |
| |
| // Mail information. |
| Subject string `datastore:",noindex"` |
| Recipients []string `datastore:",noindex"` |
| LastMessageID string `datastore:",noindex"` |
| |
| // These are person IDs (e.g. "rsc"); they may be empty |
| Author string |
| Reviewer string |
| } |
| |
| // Reviewed reports whether the reviewer has replied to the CL. |
| // The heuristic is that the CL has been replied to if it is LGTMed |
| // or if the last CL message was from the reviewer. |
| func (cl *CL) Reviewed() bool { |
| if cl.LastUpdateBy == cl.Reviewer { |
| return true |
| } |
| if person := emailToPerson[cl.LastUpdateBy]; person != "" && person == cl.Reviewer { |
| return true |
| } |
| for _, who := range cl.LGTMs { |
| if who == cl.Reviewer { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // DisplayOwner returns the CL's owner, either as their email address |
| // or the person ID if it's a reviewer. It is for display only. |
| func (cl *CL) DisplayOwner() string { |
| if p, ok := emailToPerson[cl.Owner]; ok { |
| return p |
| } |
| return cl.Owner |
| } |
| |
| func (cl *CL) FirstLineHTML() template.HTML { |
| s := template.HTMLEscapeString(cl.FirstLine) |
| // Embolden the package name. |
| if i := strings.Index(s, ":"); i >= 0 { |
| s = "<b>" + s[:i] + "</b>" + s[i:] |
| } |
| return template.HTML(s) |
| } |
| |
| func formatEmails(e []string) template.HTML { |
| x := make([]string, len(e)) |
| for i, s := range e { |
| s = template.HTMLEscapeString(s) |
| if !strings.Contains(s, "@") { |
| s = "<b>" + s + "</b>" |
| } |
| s = `<span class="email">` + s + "</span>" |
| x[i] = s |
| } |
| return template.HTML(strings.Join(x, ", ")) |
| } |
| |
| func (cl *CL) LGTMHTML() template.HTML { |
| return formatEmails(cl.LGTMs) |
| } |
| |
| func (cl *CL) NotLGTMHTML() template.HTML { |
| return formatEmails(cl.NotLGTMs) |
| } |
| |
| func (cl *CL) ModifiedAgo() string { |
| // Just the first non-zero unit. |
| units := [...]struct { |
| suffix string |
| unit time.Duration |
| }{ |
| {"d", 24 * time.Hour}, |
| {"h", time.Hour}, |
| {"m", time.Minute}, |
| {"s", time.Second}, |
| } |
| d := time.Now().Sub(cl.Modified) |
| for _, u := range units { |
| if d > u.unit { |
| return fmt.Sprintf("%d%s", d/u.unit, u.suffix) |
| } |
| } |
| return "just now" |
| } |
| |
| func handleAssign(w http.ResponseWriter, r *http.Request) { |
| c := appengine.NewContext(r) |
| |
| if r.Method != "POST" { |
| http.Error(w, "Bad method "+r.Method, 400) |
| return |
| } |
| |
| u := user.Current(c) |
| person, ok := emailToPerson[u.Email] |
| if !ok { |
| http.Error(w, "Not allowed", http.StatusUnauthorized) |
| return |
| } |
| |
| n, rev := r.FormValue("cl"), r.FormValue("r") |
| if !clRegexp.MatchString(n) { |
| c.Errorf("Bad CL %q", n) |
| http.Error(w, "Bad CL", 400) |
| return |
| } |
| if _, ok := preferredEmail[rev]; !ok && rev != "" { |
| c.Errorf("Unknown reviewer %q", rev) |
| http.Error(w, "Unknown reviewer", 400) |
| return |
| } |
| |
| key := datastore.NewKey(c, "CL", n, 0, nil) |
| |
| if rev != "" { |
| // Make sure the reviewer is listed in Rietveld as a reviewer. |
| url := codereviewBase + "/" + n + "/fields" |
| resp, err := urlfetch.Client(c).Get(url + "?field=reviewers") |
| if err != nil { |
| c.Errorf("Retrieving CL reviewer list failed: %v", err) |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| defer resp.Body.Close() |
| body, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| c.Errorf("Failed reading body: %v", err) |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| if resp.StatusCode != 200 { |
| c.Errorf("Retrieving CL reviewer list failed: got HTTP response %d\nBody: %s", resp.StatusCode, body) |
| http.Error(w, "Failed contacting Rietveld", 500) |
| return |
| } |
| |
| var apiResp struct { |
| Reviewers []string `json:"reviewers"` |
| } |
| if err := json.Unmarshal(body, &apiResp); err != nil { |
| // probably can't be retried |
| msg := fmt.Sprintf("Malformed JSON from %v: %v", url, err) |
| c.Errorf("%s", msg) |
| http.Error(w, msg, 500) |
| return |
| } |
| found := false |
| for _, r := range apiResp.Reviewers { |
| if emailToPerson[r] == rev { |
| found = true |
| break |
| } |
| } |
| if !found { |
| c.Infof("Adding %v as a reviewer of CL %v", rev, n) |
| |
| url := fmt.Sprintf("%s?cl=%s&r=%s&obo=%s", gobotBase, n, rev, person) |
| resp, err := urlfetch.Client(c).Get(url) |
| if err != nil { |
| c.Errorf("Gobot GET failed: %v", err) |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| defer resp.Body.Close() |
| body, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| c.Errorf("Failed reading Gobot body: %v", err) |
| http.Error(w, err.Error(), 500) |
| return |
| } |
| if resp.StatusCode != 200 { |
| c.Errorf("Gobot GET failed: got HTTP response %d\nBody: %s", resp.StatusCode, body) |
| http.Error(w, "Failed contacting Gobot", 500) |
| return |
| } |
| |
| c.Infof("Gobot said %q", resp.Status) |
| } |
| } |
| |
| // Update our own record. |
| err := datastore.RunInTransaction(c, func(c appengine.Context) error { |
| cl := new(CL) |
| err := datastore.Get(c, key, cl) |
| if err != nil { |
| return err |
| } |
| cl.Reviewer = rev |
| _, err = datastore.Put(c, key, cl) |
| return err |
| }, nil) |
| if err != nil { |
| msg := fmt.Sprintf("Assignment failed: %v", err) |
| c.Errorf("%s", msg) |
| http.Error(w, msg, 500) |
| return |
| } |
| c.Infof("Assigned CL %v to %v", n, rev) |
| } |
| |
| func UpdateCLLater(c appengine.Context, n string, delay time.Duration) { |
| t := taskqueue.NewPOSTTask("/update-cl", url.Values{ |
| "cl": []string{n}, |
| }) |
| t.Delay = delay |
| if _, err := taskqueue.Add(c, t, "update-cl"); err != nil { |
| c.Errorf("Failed adding task: %v", err) |
| } |
| } |
| |
| func handleUpdateCL(w http.ResponseWriter, r *http.Request) { |
| c := appengine.NewContext(r) |
| |
| n := r.FormValue("cl") |
| if !clRegexp.MatchString(n) { |
| c.Errorf("Bad CL %q", n) |
| http.Error(w, "Bad CL", 400) |
| return |
| } |
| |
| if err := updateCL(c, n); err != nil { |
| c.Errorf("Failed updating CL %v: %v", n, err) |
| http.Error(w, "Failed update", 500) |
| return |
| } |
| |
| io.WriteString(w, "OK") |
| } |
| |
| // apiMessage describes the JSON sent back by Rietveld in the CL messages list. |
| type apiMessage struct { |
| Date string `json:"date"` |
| Text string `json:"text"` |
| Sender string `json:"sender"` |
| Recipients []string `json:"recipients"` |
| Approval bool `json:"approval"` |
| } |
| |
| // byDate implements sort.Interface to order the messages by date, earliest first. |
| // The dates are sent in RFC 3339 format, so string comparison matches time value comparison. |
| type byDate []*apiMessage |
| |
| func (x byDate) Len() int { return len(x) } |
| func (x byDate) Swap(i, j int) { x[i], x[j] = x[j], x[i] } |
| func (x byDate) Less(i, j int) bool { return x[i].Date < x[j].Date } |
| |
| // updateCL updates a single CL. If a retryable failure occurs, an error is returned. |
| func updateCL(c appengine.Context, n string) error { |
| c.Debugf("Updating CL %v", n) |
| key := datastore.NewKey(c, "CL", n, 0, nil) |
| |
| url := codereviewBase + "/api/" + n + "?messages=true" |
| resp, err := urlfetch.Client(c).Get(url) |
| if err != nil { |
| return err |
| } |
| defer resp.Body.Close() |
| |
| raw, err := ioutil.ReadAll(resp.Body) |
| if err != nil { |
| return fmt.Errorf("Failed reading HTTP body: %v", err) |
| } |
| |
| // Special case for abandoned CLs. |
| if resp.StatusCode == 404 && bytes.Contains(raw, []byte("No issue exists with that id")) { |
| // Don't bother checking for errors. The CL might never have been saved, for instance. |
| datastore.Delete(c, key) |
| c.Infof("Deleted abandoned CL %v", n) |
| return nil |
| } |
| |
| if resp.StatusCode != 200 { |
| return fmt.Errorf("Update: got HTTP response %d", resp.StatusCode) |
| } |
| |
| var apiResp struct { |
| Description string `json:"description"` |
| Reviewers []string `json:"reviewers"` |
| Created string `json:"created"` |
| OwnerEmail string `json:"owner_email"` |
| Modified string `json:"modified"` |
| Closed bool `json:"closed"` |
| Subject string `json:"subject"` |
| Messages []*apiMessage `json:"messages"` |
| } |
| if err := json.Unmarshal(raw, &apiResp); err != nil { |
| // probably can't be retried |
| c.Errorf("Malformed JSON from %v: %v", url, err) |
| return nil |
| } |
| //c.Infof("RAW: %+v", apiResp) |
| sort.Sort(byDate(apiResp.Messages)) |
| |
| cl := &CL{ |
| Number: n, |
| Closed: apiResp.Closed, |
| Owner: apiResp.OwnerEmail, |
| Description: []byte(apiResp.Description), |
| FirstLine: apiResp.Description, |
| Subject: apiResp.Subject, |
| Author: emailToPerson[apiResp.OwnerEmail], |
| } |
| cl.Created, err = time.Parse("2006-01-02 15:04:05.000000", apiResp.Created) |
| if err != nil { |
| c.Errorf("Bad creation time %q: %v", apiResp.Created, err) |
| } |
| cl.Modified, err = time.Parse("2006-01-02 15:04:05.000000", apiResp.Modified) |
| if err != nil { |
| c.Errorf("Bad modification time %q: %v", apiResp.Modified, err) |
| } |
| if i := strings.Index(cl.FirstLine, "\n"); i >= 0 { |
| cl.FirstLine = cl.FirstLine[:i] |
| } |
| // Treat zero reviewers as a signal that the CL is completed. |
| // This could be after the CL has been submitted, but before the CL author has synced, |
| // but it could also be a CL manually edited to remove reviewers. |
| if len(apiResp.Reviewers) == 0 { |
| cl.Closed = true |
| } |
| |
| lgtm := make(map[string]bool) |
| notLGTM := make(map[string]bool) |
| rcpt := make(map[string]bool) |
| for _, msg := range apiResp.Messages { |
| s, rev := msg.Sender, false |
| if p, ok := emailToPerson[s]; ok { |
| s, rev = p, true |
| } |
| |
| line := firstLine(msg.Text) |
| if line != "" { |
| cl.LastUpdateBy = msg.Sender |
| cl.LastUpdate = line |
| } |
| |
| // CLs submitted by someone other than the CL owner do not immediately |
| // transition to "closed". Let's simulate the intention by treating |
| // messages starting with "*** Submitted as " from a reviewer as a |
| // signal that the CL is now closed. |
| if rev && strings.HasPrefix(msg.Text, "*** Submitted as ") { |
| cl.Closed = true |
| } |
| |
| if msg.Approval { |
| lgtm[s] = true |
| delete(notLGTM, s) // "LGTM" overrules previous "NOT LGTM" |
| } |
| if strings.Contains(line, "NOT LGTM") { |
| notLGTM[s] = true |
| delete(lgtm, s) // "NOT LGTM" overrules previous "LGTM" |
| } |
| |
| for _, r := range msg.Recipients { |
| rcpt[r] = true |
| } |
| } |
| for l := range lgtm { |
| cl.LGTMs = append(cl.LGTMs, l) |
| } |
| for l := range notLGTM { |
| cl.NotLGTMs = append(cl.NotLGTMs, l) |
| } |
| for r := range rcpt { |
| cl.Recipients = append(cl.Recipients, r) |
| } |
| sort.Strings(cl.LGTMs) |
| sort.Strings(cl.NotLGTMs) |
| sort.Strings(cl.Recipients) |
| |
| err = datastore.RunInTransaction(c, func(c appengine.Context) error { |
| ocl := new(CL) |
| err := datastore.Get(c, key, ocl) |
| if err != nil && err != datastore.ErrNoSuchEntity { |
| return err |
| } else if err == nil { |
| // LastMessageID and Reviewer need preserving. |
| cl.LastMessageID = ocl.LastMessageID |
| cl.Reviewer = ocl.Reviewer |
| } |
| _, err = datastore.Put(c, key, cl) |
| return err |
| }, nil) |
| if err != nil { |
| return err |
| } |
| c.Infof("Updated CL %v", n) |
| return nil |
| } |
| |
| // trailingSpaceRE matches trailing spaces. |
| var trailingSpaceRE = regexp.MustCompile(`(?m)[ \t\r]+$`) |
| |
| // removeRE is the list of patterns to skip over at the beginning of a |
| // message when looking for message text. |
| var removeRE = regexp.MustCompile(`(?m-s)\A(` + |
| // Skip leading "Hello so-and-so," generated by codereview plugin. |
| `(Hello(.|\n)*?\n\n)` + |
| |
| // Skip quoted text. |
| `|((On.*|.* writes|.* wrote):\n)` + |
| `|((>.*\n)+)` + |
| |
| // Skip lines with no letters. |
| `|(([^A-Za-z]*\n)+)` + |
| |
| // Skip links to comments and file info. |
| `|(http://codereview.*\n([^ ]+:[0-9]+:.*\n)?)` + |
| `|(File .*:\n)` + |
| |
| `)`, |
| ) |
| |
| // firstLine returns the first interesting line of the message text. |
| func firstLine(text string) string { |
| // Cut trailing spaces. |
| text = trailingSpaceRE.ReplaceAllString(text, "") |
| |
| // Skip uninteresting lines. |
| for { |
| text = strings.TrimSpace(text) |
| m := removeRE.FindStringIndex(text) |
| if m == nil || m[0] != 0 { |
| break |
| } |
| text = text[m[1]:] |
| } |
| |
| // Chop line at newline or else at 74 bytes. |
| i := strings.Index(text, "\n") |
| if i >= 0 { |
| text = text[:i] |
| } |
| if len(text) > 74 { |
| text = text[:70] + "..." |
| } |
| return text |
| } |