x/build/devapp: implement /reviews endpoint

Initial add of a page that shows all open reviews, grouped by
project, in chronological order. One can click on a name to
filter to that user alone.

Change-Id: I521b69c65e0629689d2792d990dd2b07ef2fb9ec
Reviewed-on: https://go-review.googlesource.com/65072
Reviewed-by: Alan Donovan <adonovan@google.com>
diff --git a/devapp/release.go b/devapp/release.go
index 45af513..182d5ef 100644
--- a/devapp/release.go
+++ b/devapp/release.go
@@ -165,13 +165,13 @@
 		return nil
 	})
 
-	s.data.Sections = nil
+	s.data.release.Sections = nil
 	s.appendOpenIssues(dirToIssues, issueToCLs)
 	s.appendPendingCLs(dirToCLs)
 	s.appendPendingProposals(issueToCLs)
 	s.appendClosedIssues()
-	s.data.LastUpdated = time.Now().UTC().Format(time.UnixDate)
-	s.data.dirty = false
+	s.data.release.LastUpdated = time.Now().UTC().Format(time.UnixDate)
+	s.data.release.dirty = false
 }
 
 // requires s.cMu be locked.
@@ -213,7 +213,7 @@
 				Items: items,
 			})
 		}
-		s.data.Sections = append(s.data.Sections, section{
+		s.data.release.Sections = append(s.data.release.Sections, section{
 			Title:  m.title,
 			Count:  issueCount,
 			Groups: issueGroups,
@@ -241,7 +241,7 @@
 			clGroups = append(clGroups, g)
 		}
 	}
-	s.data.Sections = append(s.data.Sections, section{
+	s.data.release.Sections = append(s.data.release.Sections, section{
 		Title:  "Pending CLs",
 		Count:  clCount,
 		Groups: clGroups,
@@ -264,7 +264,7 @@
 		return nil
 	})
 	sort.Sort(itemsBySummary(proposals.Items))
-	s.data.Sections = append(s.data.Sections, section{
+	s.data.release.Sections = append(s.data.release.Sections, section{
 		Title:  "Pending Proposals",
 		Count:  len(proposals.Items),
 		Groups: []group{proposals},
@@ -287,7 +287,7 @@
 		return nil
 	})
 	sort.Sort(itemsBySummary(closed.Items))
-	s.data.Sections = append(s.data.Sections, section{
+	s.data.release.Sections = append(s.data.release.Sections, section{
 		Title:  "Closed Last Week",
 		Count:  len(closed.Items),
 		Groups: []group{closed},
@@ -322,7 +322,7 @@
 func (s *server) handleRelease(t *template.Template, w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 	s.cMu.RLock()
-	dirty := s.data.dirty
+	dirty := s.data.release.dirty
 	s.cMu.RUnlock()
 	if dirty {
 		s.updateReleaseData()
@@ -330,7 +330,7 @@
 
 	s.cMu.RLock()
 	defer s.cMu.RUnlock()
-	if err := t.Execute(w, s.data); err != nil {
+	if err := t.Execute(w, s.data.release); err != nil {
 		log.Printf("t.Execute(w, nil) = %v", err)
 		return
 	}
diff --git a/devapp/reviews.go b/devapp/reviews.go
new file mode 100644
index 0000000..7859fd0
--- /dev/null
+++ b/devapp/reviews.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+	"bytes"
+	"html/template"
+	"io"
+	"log"
+	"net/http"
+	"sort"
+	"time"
+
+	"golang.org/x/build/maintner"
+)
+
+type project struct {
+	*maintner.GerritProject
+	Changes []*change
+}
+
+type change struct {
+	*maintner.GerritCL
+	LastUpdate          time.Time
+	FormattedLastUpdate string
+}
+
+type reviewsData struct {
+	Projects []*project
+
+	// dirty is set if this data needs to be updated due to a corpus change.
+	dirty bool
+}
+
+// handleReviews serves dev.golang.org/reviews.
+func (s *server) handleReviews(t *template.Template, w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	s.cMu.RLock()
+	dirty := s.data.reviews.dirty
+	s.cMu.RUnlock()
+	if dirty {
+		s.updateReviewsData()
+	}
+
+	s.cMu.RLock()
+	defer s.cMu.RUnlock()
+
+	ownerFilter := r.FormValue("owner")
+	var projects []*project
+	if len(ownerFilter) > 0 {
+		for _, p := range s.data.reviews.Projects {
+			var cs []*change
+			for _, c := range p.Changes {
+				if c.OwnerName() == ownerFilter {
+					cs = append(cs, c)
+				}
+			}
+			if len(cs) > 0 {
+				projects = append(projects, &project{GerritProject: p.GerritProject, Changes: cs})
+			}
+		}
+	} else {
+		projects = s.data.reviews.Projects
+	}
+
+	var buf bytes.Buffer
+	if err := t.Execute(&buf, struct{ Projects []*project }{projects}); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if _, err := io.Copy(w, &buf); err != nil {
+		log.Printf("io.Copy(w, %+v) = %v", buf, err)
+		return
+	}
+}
+
+func (s *server) updateReviewsData() {
+	log.Println("Updating reviews data ...")
+	s.cMu.Lock()
+	defer s.cMu.Unlock()
+
+	var projects []*project
+	s.corpus.Gerrit().ForeachProjectUnsorted(func(p *maintner.GerritProject) error {
+		proj := &project{GerritProject: p}
+		p.ForeachOpenCL(func(cl *maintner.GerritCL) error {
+			c := &change{GerritCL: cl}
+			c.LastUpdate = cl.Commit.CommitTime
+			if len(cl.Messages) > 0 {
+				c.LastUpdate = cl.Messages[len(cl.Messages)-1].Date
+			}
+			c.FormattedLastUpdate = c.LastUpdate.Format("2006-01-02")
+			proj.Changes = append(proj.Changes, c)
+			return nil
+		})
+		sort.Slice(proj.Changes, func(i, j int) bool {
+			return proj.Changes[i].LastUpdate.Before(proj.Changes[j].LastUpdate)
+		})
+		projects = append(projects, proj)
+		return nil
+	})
+	sort.Slice(projects, func(i, j int) bool {
+		return projects[i].Project() < projects[j].Project()
+	})
+	s.data.reviews.Projects = projects
+	s.data.reviews.dirty = false
+}
+
+func (c *change) OwnerName() string {
+	m := c.firstMetaCommit()
+	if m == nil {
+		return ""
+	}
+	return m.Author.Name()
+}
+
+func (c *change) firstMetaCommit() *maintner.GitCommit {
+	m := c.Meta
+	for m != nil && len(m.Parents) > 0 {
+		m = m.Parents[0] // Meta commits don’t have more than one parent.
+	}
+	return m
+}
diff --git a/devapp/server.go b/devapp/server.go
index 4ddfabd..6b62213 100644
--- a/devapp/server.go
+++ b/devapp/server.go
@@ -32,7 +32,7 @@
 	corpus           *maintner.Corpus
 	repo             *maintner.GitHubRepo
 	helpWantedIssues []int32
-	data             releaseData
+	data             pageData
 
 	// GopherCon-specific fields. Must still hold cMu when reading/writing these.
 	userMapping map[int]*maintner.GitHubUser // Gerrit Owner ID => GitHub user
@@ -40,6 +40,11 @@
 	totalPoints int
 }
 
+type pageData struct {
+	release releaseData
+	reviews reviewsData
+}
+
 func newServer(mux *http.ServeMux, staticDir, templateDir string) *server {
 	s := &server{
 		mux:         mux,
@@ -50,6 +55,7 @@
 	s.mux.Handle("/", http.FileServer(http.Dir(s.staticDir)))
 	s.mux.HandleFunc("/favicon.ico", s.handleFavicon)
 	s.mux.HandleFunc("/release", s.withTemplate("/release.tmpl", s.handleRelease))
+	s.mux.HandleFunc("/reviews", s.withTemplate("/reviews.tmpl", s.handleReviews))
 	s.mux.HandleFunc("/dir/", handleDirRedirect)
 	for _, p := range []string{"/imfeelinghelpful", "/imfeelinglucky"} {
 		s.mux.HandleFunc(p, s.handleRandomHelpWantedIssue)
@@ -89,7 +95,8 @@
 		log.Println("Updating activities ...")
 		s.updateActivities()
 		s.cMu.Lock()
-		s.data.dirty = true
+		s.data.release.dirty = true
+		s.data.reviews.dirty = true
 		s.cMu.Unlock()
 		err := s.corpus.UpdateWithLocker(ctx, &s.cMu)
 		if err != nil {
diff --git a/devapp/static/index.html b/devapp/static/index.html
index b2a81d0..d6b7c90 100644
--- a/devapp/static/index.html
+++ b/devapp/static/index.html
@@ -2,11 +2,13 @@
 <meta charset="utf-8">
 <title>Go Development Dashboard</title>
 <pre>
-<a href="/release">Go release dashboard</a>
+<a href="/release">Releases</a>
+<a href="/reviews">Open reviews</a>
 
 <b>About the Dashboards</b>
 
-These dashboards are generated by <a href="https://godoc.org/golang.org/x/build/devapp">golang.org/x/build/devapp</a>.
+These dashboards are generated by
+<a href="https://godoc.org/golang.org/x/build/devapp">golang.org/x/build/devapp</a>.
 
 Issue information comes directly from GitHub.
 To change something about an issue here, go to GitHub.
@@ -14,22 +16,5 @@
 CL information comes directly from Gerrit.
 To change something about a CL here, go to Gerrit.
 
-The dashboard refreshes periodically.
-
-<b>Release Dashboard</b>
-
-The Go release dashboard shows
-all open issues in the milestones for the upcoming release,
-plus all open CLs mentioning those issues,
-plus any other open CLs in the main repository.
-
-The release dashboard is ordered primarily around issues in the
-release milestone and the release-maybe milestone (for example,
-Go1.5 and Go1.5Maybe). The maybe issues are shown in gray and
-have [maybe] tags.
-
-If a CL refers to a release issue in its description, the CL is shown on the
-dashboard below that issue, with an arrow prefix (&#x2937;).
-If a CL refers to multiple release issues, the CL is shown under each issue.
-
-If a CL refers to no release issues, it is shown on its own, without an arrow.
\ No newline at end of file
+The UI typically reflects data changes from both sources within a few tens of
+milliseconds.
\ No newline at end of file
diff --git a/devapp/templates/reviews.tmpl b/devapp/templates/reviews.tmpl
new file mode 100644
index 0000000..a22cad3
--- /dev/null
+++ b/devapp/templates/reviews.tmpl
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<meta name=viewport content="width=device-width,minimum-scale=1,maximum-scale=1">
+<title>Open Go Code Reviews</title>
+<style>
+* {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+body {
+  font: 13px monospace;
+  padding: 1rem;
+}
+h3 {
+  padding: 10px 0 0;
+}
+h3:first-of-type {
+  padding: 0
+}
+a:link,
+a:visited {
+  color: #00c;
+}
+.row {
+  display: flex;
+  white-space: nowrap;
+}
+.date {
+  min-width: 6rem;
+}
+.owner {
+  min-width: 10rem;
+  max-width: 10rem;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  padding-right: 1em;
+}
+.change {
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>
+<body>
+{{range .Projects}}
+  {{if .Changes}}
+		<h3>{{.Project}}</h3>
+		{{range .Changes}}
+		<div class="row">
+			<div class="date">{{.FormattedLastUpdate}}</div>
+      <a class="owner" href="?owner={{.OwnerName}}">{{.OwnerName}}</a>
+      <a class="change" href="https://golang.org/cl/{{.Number}}" target="_blank">{{.Number}} {{.Subject}}</a>
+    </div>
+		{{end}}
+	{{end}}
+{{end}}
\ No newline at end of file