maintner/cmd/maintserve: display Gerrit projects and CLs

This change expands the scope of cmd/maintserve to visualize Gerrit CL
maintner data, in addition to the GitHub repository issue tracker data.

I've needed this recently when investigating golang/go#28318 to check
maintner.GerritHashtags values of various CLs. They are shown as of
https://dmitri.shuralyov.com/service/change/...$commit/e712a6949fbe7fe04b2f49fc22810f827b17f3f8.

maintner doesn't have sufficient information to present Gerrit CLs in
full detail, so this does a best effort and displays the available
information. Inline review comments and diffs are not included.

The downside of this change is that it adds new dependencies.
However, they are actively maintained by me.

Updates golang/go#28318

Change-Id: Ie6fe14f95f107e95371ea820af88563e03a6bb2a
Reviewed-on: https://go-review.googlesource.com/c/145258
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/maintner/cmd/maintserve/maintserve.go b/maintner/cmd/maintserve/maintserve.go
index ef4e286..e33d5eb 100644
--- a/maintner/cmd/maintserve/maintserve.go
+++ b/maintner/cmd/maintserve/maintserve.go
@@ -2,13 +2,16 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// maintserve is a program that serves Go issues over HTTP, so they
-// can be viewed in a browser. It uses x/build/maintner/godata as
-// its backing source of data.
+// maintserve is a program that serves Go issues and CLs over HTTP,
+// so they can be viewed in a browser. It uses x/build/maintner/godata
+// as its backing source of data.
 //
 // It statically embeds all the resources it uses, so it's possible to use
 // it when offline. During that time, the corpus will not be able to update,
 // and GitHub user profile pictures won't load.
+//
+// maintserve displays partial Gerrit CL data that is available within the
+// maintner corpus. Code diffs and inline review comments are not included.
 package main
 
 import (
@@ -24,6 +27,8 @@
 	"strings"
 	"time"
 
+	"dmitri.shuralyov.com/app/changes"
+	maintnerchange "dmitri.shuralyov.com/service/change/maintner"
 	"github.com/shurcooL/gofontwoff"
 	"github.com/shurcooL/httpgzip"
 	"github.com/shurcooL/issues"
@@ -53,6 +58,7 @@
 	if err != nil {
 		return err
 	}
+
 	issuesService := maintnerissues.NewService(corpus)
 	issuesApp := issuesapp.New(issuesService, nil, issuesapp.Options{
 		HeadPre: `<meta name="viewport" content="width=device-width">
@@ -71,17 +77,29 @@
 		DisableReactions: true,
 	})
 
-	// TODO: Implement background updates for corpus while the appliation is running.
+	changeService := maintnerchange.NewService(corpus)
+	changesApp := changes.New(changeService, nil, changes.Options{
+		HeadPre: `<meta name="viewport" content="width=device-width">
+<link href="/assets/fonts/fonts.css" rel="stylesheet" type="text/css">
+<link href="/assets/style.css" rel="stylesheet" type="text/css">`,
+		HeadPost: `<style type="text/css">
+	.markdown-body { font-family: Go; }
+	tt, code, pre  { font-family: "Go Mono"; }
+</style>`,
+		BodyPre:          `<div style="max-width: 800px; margin: 0 auto 100px auto;">`,
+		DisableReactions: true,
+	})
+
+	// TODO: Implement background updates for corpus while the application is running.
 	//       Right now, it only updates at startup.
-	//       It's likely just a matter of calling RLock/RUnlock before all read operations,
-	//       and launching a background goroutine that occasionally calls corpus.Update()
-	//       or corpus.Sync() or something.
+	//       See gido source code for an example of how to do this.
 
 	printServingAt(*httpFlag)
 	err = http.ListenAndServe(*httpFlag, &handler{
-		c:             corpus,
-		fontsHandler:  httpgzip.FileServer(gofontwoff.Assets, httpgzip.FileServerOptions{}),
-		issuesHandler: issuesApp,
+		c:              corpus,
+		fontsHandler:   httpgzip.FileServer(gofontwoff.Assets, httpgzip.FileServerOptions{}),
+		issuesHandler:  issuesApp,
+		changesHandler: changesApp,
 	})
 	return err
 }
@@ -97,9 +115,10 @@
 // handler handles all requests to maintserve. It acts like a request multiplexer,
 // choosing from various endpoints and parsing the repository ID from URL.
 type handler struct {
-	c             *maintner.Corpus
-	fontsHandler  http.Handler
-	issuesHandler http.Handler
+	c              *maintner.Corpus
+	fontsHandler   http.Handler
+	issuesHandler  http.Handler
+	changesHandler http.Handler
 }
 
 func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
@@ -122,24 +141,42 @@
 		return
 	}
 
-	// Handle "/owner/repo/..." URLs.
 	elems := strings.SplitN(req.URL.Path[1:], "/", 3)
 	if len(elems) < 2 {
 		http.Error(w, "404 Not Found", http.StatusNotFound)
 		return
 	}
-	owner, repo := elems[0], elems[1]
-	baseURLLen := 1 + len(owner) + 1 + len(repo) // Base URL is "/owner/repo".
-	if baseURL := req.URL.Path[:baseURLLen]; req.URL.Path == baseURL+"/" {
-		// Redirect "/owner/repo/" to "/owner/repo".
-		if req.URL.RawQuery != "" {
-			baseURL += "?" + req.URL.RawQuery
+	switch strings.HasSuffix(elems[0], ".googlesource.com") {
+	case false:
+		// Handle "/owner/repo/..." GitHub repository URLs.
+		owner, repo := elems[0], elems[1]
+		baseURLLen := 1 + len(owner) + 1 + len(repo) // Base URL is "/owner/repo".
+		if baseURL := req.URL.Path[:baseURLLen]; req.URL.Path == baseURL+"/" {
+			// Redirect "/owner/repo/" to "/owner/repo".
+			if req.URL.RawQuery != "" {
+				baseURL += "?" + req.URL.RawQuery
+			}
+			http.Redirect(w, req, baseURL, http.StatusFound)
+			return
 		}
-		http.Redirect(w, req, baseURL, http.StatusFound)
-		return
+		req = stripPrefix(req, baseURLLen)
+		h.serveIssues(w, req, maintner.GitHubRepoID{Owner: owner, Repo: repo})
+
+	case true:
+		// Handle "/server/project/..." Gerrit project URLs.
+		server, project := elems[0], elems[1]
+		baseURLLen := 1 + len(server) + 1 + len(project) // Base URL is "/server/project".
+		if baseURL := req.URL.Path[:baseURLLen]; req.URL.Path == baseURL+"/" {
+			// Redirect "/server/project/" to "/server/project".
+			if req.URL.RawQuery != "" {
+				baseURL += "?" + req.URL.RawQuery
+			}
+			http.Redirect(w, req, baseURL, http.StatusFound)
+			return
+		}
+		req = stripPrefix(req, baseURLLen)
+		h.serveChanges(w, req, server, project)
 	}
-	req = stripPrefix(req, baseURLLen)
-	h.serveIssues(w, req, maintner.GitHubRepoID{Owner: owner, Repo: repo})
 }
 
 var indexHTML = template.Must(template.New("").Parse(`<html>
@@ -152,17 +189,27 @@
 	<body>
 		<div style="max-width: 800px; margin: 0 auto 100px auto;">
 			<h2>maintserve</h2>
-			<h3>Repos</h3>
-			<ul>{{range .}}
-				<li><a href="/{{.RepoID}}">{{.RepoID}}</a> ({{.Count}} issues)</li>
-				{{- end}}
-			</ul>
+			<div style="display: inline-block; width: 50%;">
+				<h3>GitHub Repos</h3>
+				<ul>{{range .Repos}}
+					<li><a href="/{{.RepoID}}">{{.RepoID}}</a> ({{.Count}} issues)</li>
+					{{- end}}
+				</ul>
+			</div><div style="display: inline-block; width: 50%; vertical-align: top;">
+				<h3>Gerrit Projects</h3>
+				<ul>{{range .Projects}}
+					<li><a href="/{{.ServProj}}">{{.ServProj}}</a> ({{.Count}} changes)</li>
+					{{- end}}
+				</ul>
+			</div>
 		</div>
 	<body>
 </html>`))
 
-// serveIndex serves the index page, which lists all available repositories.
+// serveIndex serves the index page, which lists all available
+// GitHub repositories and Gerrit projects.
 func (h *handler) serveIndex(w http.ResponseWriter, req *http.Request) {
+	// Enumerate all GitHub repositories.
 	type repo struct {
 		RepoID maintner.GitHubRepoID
 		Count  uint64 // Issues count.
@@ -187,8 +234,36 @@
 		return repos[i].RepoID.String() < repos[j].RepoID.String()
 	})
 
+	// Enumerate all Gerrit projects.
+	type project struct {
+		ServProj string
+		Count    uint64 // Changes count.
+	}
+	var projects []project
+	err = h.c.Gerrit().ForeachProjectUnsorted(func(r *maintner.GerritProject) error {
+		changes, err := countChanges(r)
+		if err != nil {
+			return err
+		}
+		projects = append(projects, project{
+			ServProj: r.ServerSlashProject(),
+			Count:    changes,
+		})
+		return nil
+	})
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	sort.Slice(projects, func(i, j int) bool {
+		return projects[i].ServProj < projects[j].ServProj
+	})
+
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
-	err = indexHTML.Execute(w, repos)
+	err = indexHTML.Execute(w, map[string]interface{}{
+		"Repos":    repos,
+		"Projects": projects,
+	})
 	if err != nil {
 		log.Println(err)
 	}
@@ -207,10 +282,23 @@
 	return issues, err
 }
 
-// serveIssues serves issues for repository id.
+// countChanges reports the number of changes in a GerritProject p.
+func countChanges(p *maintner.GerritProject) (uint64, error) {
+	var changes uint64
+	err := p.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
+		if cl.Private {
+			return nil
+		}
+		changes++
+		return nil
+	})
+	return changes, err
+}
+
+// serveIssues serves issues for GitHub repository id.
 func (h *handler) serveIssues(w http.ResponseWriter, req *http.Request, id maintner.GitHubRepoID) {
 	if h.c.GitHub().Repo(id.Owner, id.Repo) == nil {
-		http.Error(w, fmt.Sprintf("404 Not Found\n\nrepository %q not found", id), http.StatusNotFound)
+		http.Error(w, fmt.Sprintf("404 Not Found\n\nGitHub repository %q not found", id), http.StatusNotFound)
 		return
 	}
 
@@ -221,6 +309,20 @@
 	h.issuesHandler.ServeHTTP(w, req)
 }
 
+// serveChanges serves changes for Gerrit project server/project.
+func (h *handler) serveChanges(w http.ResponseWriter, req *http.Request, server, project string) {
+	if h.c.Gerrit().Project(server, project) == nil {
+		http.Error(w, fmt.Sprintf("404 Not Found\n\nGerrit project %s/%s not found", server, project), http.StatusNotFound)
+		return
+	}
+
+	req = req.WithContext(context.WithValue(req.Context(),
+		changes.RepoSpecContextKey, fmt.Sprintf("%s/%s", server, project)))
+	req = req.WithContext(context.WithValue(req.Context(),
+		changes.BaseURIContextKey, fmt.Sprintf("/%s/%s", server, project)))
+	h.changesHandler.ServeHTTP(w, req)
+}
+
 // stripPrefix returns request r with prefix of length prefixLen stripped from r.URL.Path.
 // prefixLen must not be longer than len(r.URL.Path), otherwise stripPrefix panics.
 // If r.URL.Path is empty after the prefix is stripped, the path is changed to "/".