From 0175e3f1e0a1604f2e3c7bd8b67c42d066f36fa1 Mon Sep 17 00:00:00 2001 From: David Symonds Date: Fri, 27 Apr 2012 16:36:02 +1000 Subject: [PATCH] misc/dashboard/codereview: new app. This is live at http://gocodereview.appspot.com/. R=golang-dev, r CC=golang-dev https://golang.org/cl/6134043 --- misc/dashboard/codereview/app.yaml | 24 ++ misc/dashboard/codereview/cron.yaml | 4 + misc/dashboard/codereview/dashboard/cl.go | 250 ++++++++++++++++++ misc/dashboard/codereview/dashboard/front.go | 240 +++++++++++++++++ misc/dashboard/codereview/dashboard/gc.go | 43 +++ misc/dashboard/codereview/dashboard/mail.go | 40 +++ misc/dashboard/codereview/dashboard/people.go | 33 +++ misc/dashboard/codereview/index.yaml | 19 ++ misc/dashboard/codereview/queue.yaml | 4 + .../codereview/static/gopherstamp.jpg | Bin 0 -> 16996 bytes misc/dashboard/codereview/static/icon.png | Bin 0 -> 4347 bytes 11 files changed, 657 insertions(+) create mode 100644 misc/dashboard/codereview/app.yaml create mode 100644 misc/dashboard/codereview/cron.yaml create mode 100644 misc/dashboard/codereview/dashboard/cl.go create mode 100644 misc/dashboard/codereview/dashboard/front.go create mode 100644 misc/dashboard/codereview/dashboard/gc.go create mode 100644 misc/dashboard/codereview/dashboard/mail.go create mode 100644 misc/dashboard/codereview/dashboard/people.go create mode 100644 misc/dashboard/codereview/index.yaml create mode 100644 misc/dashboard/codereview/queue.yaml create mode 100644 misc/dashboard/codereview/static/gopherstamp.jpg create mode 100644 misc/dashboard/codereview/static/icon.png diff --git a/misc/dashboard/codereview/app.yaml b/misc/dashboard/codereview/app.yaml new file mode 100644 index 0000000000..33592a45c4 --- /dev/null +++ b/misc/dashboard/codereview/app.yaml @@ -0,0 +1,24 @@ +application: gocodereview +version: 1 +runtime: go +api_version: go1 + +inbound_services: +- mail + +handlers: +- url: /static/(.*) + static_files: static/\1 + upload: static/.* +- url: /_ah/mail/.* + script: _go_app + login: admin +- url: /_ah/queue/go/delay + script: _go_app + login: admin +- url: /update-cl + script: _go_app + login: admin +- url: /.* + script: _go_app + login: required diff --git a/misc/dashboard/codereview/cron.yaml b/misc/dashboard/codereview/cron.yaml new file mode 100644 index 0000000000..3d33d32b57 --- /dev/null +++ b/misc/dashboard/codereview/cron.yaml @@ -0,0 +1,4 @@ +cron: +- description: GC + url: /gc + schedule: every 6 hours diff --git a/misc/dashboard/codereview/dashboard/cl.go b/misc/dashboard/codereview/dashboard/cl.go new file mode 100644 index 0000000000..a023ff6ab8 --- /dev/null +++ b/misc/dashboard/codereview/dashboard/cl.go @@ -0,0 +1,250 @@ +package dashboard + +// This file handles operations on the CL entity kind. + +import ( + "encoding/json" + "fmt" + "html/template" + "io" + "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" + +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 + + // These are person IDs (e.g. "rsc"); they may be empty + Author string + Reviewer string +} + +// ShortOwner 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) ShortOwner() 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 = "" + s[:i] + "" + s[i:] + } + return template.HTML(s) +} + +func (cl *CL) LGTMHTML() template.HTML { + x := make([]string, len(cl.LGTMs)) + for i, s := range cl.LGTMs { + s = template.HTMLEscapeString(s) + if !strings.Contains(s, "@") { + s = "" + s + "" + } + s = `` + s + "" + x[i] = s + } + return template.HTML(strings.Join(x, ", ")) +} + +func (cl *CL) ModifiedAgo() string { + d := time.Now().Sub(cl.Modified) + d -= d % time.Minute // truncate to minute resolution + s := d.String() + if strings.HasSuffix(s, "0s") { + s = s[:len(s)-2] + } + return s +} + +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 + } + + if _, ok := emailToPerson[user.Current(c).Email]; !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 + } + + key := datastore.NewKey(c, "CL", n, 0, nil) + 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") +} + +// 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) + + url := codereviewBase + "/api/" + n + "?messages=true" + resp, err := urlfetch.Client(c).Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("Update: got HTTP response %d", resp.StatusCode) + } + + var apiResp struct { + Description string `json:"description"` + Created string `json:"created"` + OwnerEmail string `json:"owner_email"` + Modified string `json:"modified"` + Closed bool `json:"closed"` + Messages []struct { + Text string `json:"text"` + Sender string `json:"sender"` + Approval bool `json:"approval"` + } `json:"messages"` + } + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + // probably can't be retried + c.Errorf("Malformed JSON from %v: %v", url, err) + return nil + } + //c.Infof("RAW: %+v", apiResp) + + cl := &CL{ + Number: n, + Closed: apiResp.Closed, + Owner: apiResp.OwnerEmail, + Description: []byte(apiResp.Description), + FirstLine: apiResp.Description, + 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] + } + for _, msg := range apiResp.Messages { + s, rev := msg.Sender, false + if p, ok := emailToPerson[s]; ok { + s, rev = p, true + } + + // 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 { + cl.LGTMs = append(cl.LGTMs, s) + } + } + sort.Strings(cl.LGTMs) + + key := datastore.NewKey(c, "CL", n, 0, nil) + 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 { + // Reviewer is the only field that needs preserving. + 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 +} diff --git a/misc/dashboard/codereview/dashboard/front.go b/misc/dashboard/codereview/dashboard/front.go new file mode 100644 index 0000000000..efdfe29467 --- /dev/null +++ b/misc/dashboard/codereview/dashboard/front.go @@ -0,0 +1,240 @@ +package dashboard + +// This file handles the front page. + +import ( + "bytes" + "html/template" + "io" + "net/http" + "sync" + + "appengine" + "appengine/datastore" + "appengine/user" +) + +func init() { + http.HandleFunc("/", handleFront) + http.HandleFunc("/favicon.ico", http.NotFound) +} + +func handleFront(w http.ResponseWriter, r *http.Request) { + c := appengine.NewContext(r) + + data := &frontPageData{ + Reviewers: personList, + } + var currentPerson string + currentPerson, data.UserIsReviewer = emailToPerson[user.Current(c).Email] + + var wg sync.WaitGroup + errc := make(chan error, 10) + activeCLs := datastore.NewQuery("CL"). + Filter("Closed =", false). + Order("-Modified") + + if data.UserIsReviewer { + wg.Add(1) + go func() { + defer wg.Done() + tbl := &data.Tables[0] + q := activeCLs.Filter("Reviewer =", currentPerson).Limit(10) + tbl.Title = "CLs assigned to you for review" + tbl.Assignable = true + if _, err := q.GetAll(c, &tbl.CLs); err != nil { + errc <- err + } + }() + } + + wg.Add(1) + go func() { + defer wg.Done() + tbl := &data.Tables[1] + q := activeCLs.Filter("Author =", currentPerson).Limit(10) + tbl.Title = "CLs sent by you" + tbl.Assignable = true + if _, err := q.GetAll(c, &tbl.CLs); err != nil { + errc <- err + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + tbl := &data.Tables[2] + q := activeCLs.Limit(50) + tbl.Title = "Other active CLs" + tbl.Assignable = true + if _, err := q.GetAll(c, &tbl.CLs); err != nil { + errc <- err + return + } + // filter + if data.UserIsReviewer { + for i := len(tbl.CLs) - 1; i >= 0; i-- { + cl := tbl.CLs[i] + if cl.Author == currentPerson || cl.Reviewer == currentPerson { + tbl.CLs[i] = tbl.CLs[len(tbl.CLs)-1] + tbl.CLs = tbl.CLs[:len(tbl.CLs)-1] + } + } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + tbl := &data.Tables[3] + q := datastore.NewQuery("CL"). + Filter("Closed =", true). + Order("-Modified"). + Limit(10) + tbl.Title = "Recently closed CLs" + tbl.Assignable = false + if _, err := q.GetAll(c, &tbl.CLs); err != nil { + errc <- err + } + }() + + wg.Wait() + + select { + case err := <-errc: + c.Errorf("%v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + default: + } + + var b bytes.Buffer + if err := frontPage.ExecuteTemplate(&b, "front", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + io.Copy(w, &b) +} + +type frontPageData struct { + Tables [4]clTable + + Reviewers []string + UserIsReviewer bool +} + +type clTable struct { + Title string + Assignable bool + CLs []*CL +} + +var frontPage = template.Must(template.New("front").Funcs(template.FuncMap{ + "selected": func(a, b string) string { + if a == b { + return "selected" + } + return "" + }, +}).Parse(` + + + + Go code reviews + + + + + + + +

Go code reviews

+ +{{range $tbl := .Tables}} +

{{$tbl.Title}}

+{{if .CLs}} + +{{range $cl := .CLs}} + + + {{if $tbl.Assignable}} + + {{end}} + + + +{{end}} +
+ + + + {{.Number}}: {{.FirstLineHTML}} + {{if and .LGTMs $tbl.Assignable}}
LGTMs: {{.LGTMHTML}}{{end}} +
{{.ModifiedAgo}}
+{{else}} +none +{{end}} +{{end}} + + + +`)) diff --git a/misc/dashboard/codereview/dashboard/gc.go b/misc/dashboard/codereview/dashboard/gc.go new file mode 100644 index 0000000000..f8cb7fae76 --- /dev/null +++ b/misc/dashboard/codereview/dashboard/gc.go @@ -0,0 +1,43 @@ +package dashboard + +// This file handles garbage collection of old CLs. + +import ( + "net/http" + + "appengine" + "appengine/datastore" + "time" +) + +func init() { + http.HandleFunc("/gc", handleGC) +} + +func handleGC(w http.ResponseWriter, r *http.Request) { + c := appengine.NewContext(r) + + // Delete closed CLs that haven't been modified in 168 hours (7 days). + cutoff := time.Now().Add(-168 * time.Hour) + q := datastore.NewQuery("CL"). + Filter("Closed =", true). + Filter("Modified <", cutoff). + Limit(100). + KeysOnly() + keys, err := q.GetAll(c, nil) + if err != nil { + c.Errorf("GetAll failed for old CLs: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if len(keys) == 0 { + return + } + + if err := datastore.DeleteMulti(c, keys); err != nil { + c.Errorf("DeleteMulti failed for old CLs: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.Infof("Deleted %d old CLs", len(keys)) +} diff --git a/misc/dashboard/codereview/dashboard/mail.go b/misc/dashboard/codereview/dashboard/mail.go new file mode 100644 index 0000000000..bd9ca19d48 --- /dev/null +++ b/misc/dashboard/codereview/dashboard/mail.go @@ -0,0 +1,40 @@ +package dashboard + +// This file handles receiving mail. + +import ( + "net/http" + "net/mail" + "regexp" + "time" + + "appengine" +) + +func init() { + http.HandleFunc("/_ah/mail/", handleMail) +} + +var subjectRegexp = regexp.MustCompile(`.*code review (\d+):.*`) + +func handleMail(w http.ResponseWriter, r *http.Request) { + c := appengine.NewContext(r) + + defer r.Body.Close() + msg, err := mail.ReadMessage(r.Body) + if err != nil { + c.Errorf("mail.ReadMessage: %v", err) + return + } + + subj := msg.Header.Get("Subject") + m := subjectRegexp.FindStringSubmatch(subj) + if len(m) != 2 { + c.Debugf("Subject %q did not match /%v/", subj, subjectRegexp) + return + } + + c.Infof("Found issue %q", m[1]) + // Update the CL after a delay to give Rietveld a chance to catch up. + UpdateCLLater(c, m[1], 10*time.Second) +} diff --git a/misc/dashboard/codereview/dashboard/people.go b/misc/dashboard/codereview/dashboard/people.go new file mode 100644 index 0000000000..a9a40c34d6 --- /dev/null +++ b/misc/dashboard/codereview/dashboard/people.go @@ -0,0 +1,33 @@ +package dashboard + +// This file handles identities of people. + +import ( + "sort" +) + +var ( + emailToPerson = make(map[string]string) + personList []string +) + +func init() { + // People we assume have golang.org and google.com accounts. + gophers := [...]string{ + "adg", + "bradfitz", + "dsymonds", + "gri", + "iant", + "nigeltao", + "r", + "rsc", + } + for _, p := range gophers { + personList = append(personList, p) + emailToPerson[p+"@golang.org"] = p + emailToPerson[p+"@google.com"] = p + } + + sort.Strings(personList) +} diff --git a/misc/dashboard/codereview/index.yaml b/misc/dashboard/codereview/index.yaml new file mode 100644 index 0000000000..d47dd0829f --- /dev/null +++ b/misc/dashboard/codereview/index.yaml @@ -0,0 +1,19 @@ +indexes: + +- kind: CL + properties: + - name: Author + - name: Modified + direction: desc + +- kind: CL + properties: + - name: Closed + - name: Modified + direction: desc + +- kind: CL + properties: + - name: Reviewer + - name: Modified + direction: desc diff --git a/misc/dashboard/codereview/queue.yaml b/misc/dashboard/codereview/queue.yaml new file mode 100644 index 0000000000..1a35facaf1 --- /dev/null +++ b/misc/dashboard/codereview/queue.yaml @@ -0,0 +1,4 @@ +queue: +- name: update-cl + rate: 12/m + bucket_size: 1 diff --git a/misc/dashboard/codereview/static/gopherstamp.jpg b/misc/dashboard/codereview/static/gopherstamp.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b17f3c82a6f0e4cbb7d23b5efac8d9d08f1b705e GIT binary patch literal 16996 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 PHU3le3`_dBPiOxxtHjCs literal 0 HcmV?d00001 diff --git a/misc/dashboard/codereview/static/icon.png b/misc/dashboard/codereview/static/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8e0998f6e4e8d5c913714c8fb52afc1fcb0b4d74 GIT binary patch literal 4347 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 literal 0 HcmV?d00001 -- 2.48.1