--- /dev/null
+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 = "<b>" + s[:i] + "</b>" + 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 = "<b>" + s + "</b>"
+ }
+ s = `<span class="email">` + s + "</span>"
+ 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
+}
--- /dev/null
+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(`
+<!doctype html>
+<html>
+ <head>
+ <title>Go code reviews</title>
+ <link rel="icon" type="image/png" href="/static/icon.png" />
+ <style type="text/css">
+ body {
+ font-family: Helvetica, sans-serif;
+ }
+ img#gopherstamp {
+ float: right;
+ height: auto;
+ width: 250px;
+ }
+ h1, h2, h3 {
+ color: #777;
+ margin-bottom: 0;
+ }
+ td {
+ padding: 2px 5px;
+ }
+ tr.pending td {
+ background: #fc8;
+ }
+ tr.failed td {
+ background: #f88;
+ }
+ tr.saved td {
+ background: #8f8;
+ }
+ .cls {
+ margin-top: 0;
+ }
+ a {
+ color: blue;
+ text-decoration: none; /* no link underline */
+ }
+ .email {
+ font-family: monospace;
+ }
+ </style>
+ <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
+ <head>
+ <body>
+
+<img id="gopherstamp" src="/static/gopherstamp.jpg" />
+<h1>Go code reviews</h1>
+
+{{range $tbl := .Tables}}
+<h3>{{$tbl.Title}}</h3>
+{{if .CLs}}
+<table class="cls">
+{{range $cl := .CLs}}
+ <tr id="cl-{{$cl.Number}}">
+ <td class="email">{{$cl.ShortOwner}}</td>
+ {{if $tbl.Assignable}}
+ <td>
+ <select id="cl-rev-{{$cl.Number}}" {{if not $.UserIsReviewer}}disabled{{end}}>
+ <option></option>
+ {{range $.Reviewers}}
+ <option {{selected . $cl.Reviewer}}>{{.}}</option>
+ {{end}}
+ </select>
+ <script type="text/javascript">
+ $(function() {
+ $('#cl-rev-{{$cl.Number}}').change(function() {
+ var r = $(this).val();
+ var row = $('tr#cl-{{$cl.Number}}');
+ row.addClass('pending');
+ $.post('/assign', {
+ 'cl': '{{$cl.Number}}',
+ 'r': r
+ }).success(function() {
+ row.removeClass('pending');
+ row.addClass('saved');
+ }).error(function() {
+ row.removeClass('pending');
+ row.addClass('failed');
+ });
+ });
+ });
+ </script>
+ </td>
+ {{end}}
+ <td>
+ <a href="http://codereview.appspot.com/{{.Number}}/" title="{{ printf "%s" .Description}}">{{.Number}}: {{.FirstLineHTML}}</a>
+ {{if and .LGTMs $tbl.Assignable}}<br /><span style="font-size: smaller;">LGTMs: {{.LGTMHTML}}{{end}}</span>
+ </td>
+ <td title="Last modified">{{.ModifiedAgo}}</td>
+ </tr>
+{{end}}
+</table>
+{{else}}
+<em>none</em>
+{{end}}
+{{end}}
+
+ </body>
+</html>
+`))