| // Copyright 2017 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. |
| |
| // 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 ( |
| "context" |
| "flag" |
| "fmt" |
| "html/template" |
| "log" |
| "mime" |
| "net/http" |
| "net/url" |
| "sort" |
| "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" |
| maintnerissues "github.com/shurcooL/issues/maintner" |
| "github.com/shurcooL/issuesapp" |
| "golang.org/x/build/maintner" |
| "golang.org/x/build/maintner/godata" |
| ) |
| |
| var httpFlag = flag.String("http", ":8080", "Listen for HTTP connections on this address.") |
| |
| func main() { |
| flag.Parse() |
| |
| err := run() |
| if err != nil { |
| log.Fatalln(err) |
| } |
| } |
| |
| func run() error { |
| if err := mime.AddExtensionType(".woff2", "font/woff2"); err != nil { |
| return err |
| } |
| |
| corpus, err := godata.Get(context.Background()) |
| if err != nil { |
| return err |
| } |
| |
| issuesService := maintnerissues.NewService(corpus) |
| issuesApp := issuesapp.New(issuesService, nil, issuesapp.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;"> |
| |
| {{/* Override new comment component to link to original issue for leaving comments. */}} |
| {{define "new-comment"}}<div class="event" style="margin-top: 20px; margin-bottom: 100px;"> |
| View <a href="https://github.com/{{.RepoSpec}}/issues/{{.Issue.ID}}#new_comment_field">original issue</a> to comment. |
| </div>{{end}}`, |
| DisableReactions: true, |
| }) |
| |
| 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. |
| // 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, |
| changesHandler: changesApp, |
| }) |
| return err |
| } |
| |
| func printServingAt(addr string) { |
| hostPort := addr |
| if strings.HasPrefix(hostPort, ":") { |
| hostPort = "localhost" + hostPort |
| } |
| fmt.Printf("serving at http://%s/\n", hostPort) |
| } |
| |
| // 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 |
| changesHandler http.Handler |
| } |
| |
| func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { |
| // Handle "/". |
| if req.URL.Path == "/" { |
| h.serveIndex(w, req) |
| return |
| } |
| |
| // Handle "/assets/fonts/...". |
| if strings.HasPrefix(req.URL.Path, "/assets/fonts") { |
| req = stripPrefix(req, len("/assets/fonts")) |
| h.fontsHandler.ServeHTTP(w, req) |
| return |
| } |
| |
| // Handle "/assets/style.css". |
| if req.URL.Path == "/assets/style.css" { |
| http.ServeContent(w, req, "style.css", time.Time{}, strings.NewReader(styleCSS)) |
| return |
| } |
| |
| elems := strings.SplitN(req.URL.Path[1:], "/", 3) |
| if len(elems) < 2 { |
| http.Error(w, "404 Not Found", http.StatusNotFound) |
| return |
| } |
| 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 |
| } |
| 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) |
| } |
| } |
| |
| var indexHTML = template.Must(template.New("").Parse(`<html> |
| <head> |
| <title>maintserve</title> |
| <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"> |
| </head> |
| <body> |
| <div style="max-width: 800px; margin: 0 auto 100px auto;"> |
| <h2>maintserve</h2> |
| <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 |
| // 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. |
| } |
| var repos []repo |
| err := h.c.GitHub().ForeachRepo(func(r *maintner.GitHubRepo) error { |
| issues, err := countIssues(r) |
| if err != nil { |
| return err |
| } |
| repos = append(repos, repo{ |
| RepoID: r.ID(), |
| Count: issues, |
| }) |
| return nil |
| }) |
| if err != nil { |
| http.Error(w, err.Error(), http.StatusInternalServerError) |
| return |
| } |
| sort.Slice(repos, func(i, j int) bool { |
| 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, map[string]interface{}{ |
| "Repos": repos, |
| "Projects": projects, |
| }) |
| if err != nil { |
| log.Println(err) |
| } |
| } |
| |
| // countIssues reports the number of issues in a GitHubRepo r. |
| func countIssues(r *maintner.GitHubRepo) (uint64, error) { |
| var issues uint64 |
| err := r.ForeachIssue(func(i *maintner.GitHubIssue) error { |
| if i.NotExist || i.PullRequest { |
| return nil |
| } |
| issues++ |
| return nil |
| }) |
| return issues, err |
| } |
| |
| // 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\nGitHub repository %q not found", id), http.StatusNotFound) |
| return |
| } |
| |
| req = req.WithContext(context.WithValue(req.Context(), |
| issuesapp.RepoSpecContextKey, issues.RepoSpec{URI: fmt.Sprintf("%s/%s", id.Owner, id.Repo)})) |
| req = req.WithContext(context.WithValue(req.Context(), |
| issuesapp.BaseURIContextKey, fmt.Sprintf("/%s/%s", id.Owner, id.Repo))) |
| 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 "/". |
| func stripPrefix(r *http.Request, prefixLen int) *http.Request { |
| r2 := new(http.Request) |
| *r2 = *r |
| r2.URL = new(url.URL) |
| *r2.URL = *r.URL |
| r2.URL.Path = r.URL.Path[prefixLen:] |
| if r2.URL.Path == "" { |
| r2.URL.Path = "/" |
| } |
| return r2 |
| } |
| |
| const styleCSS = `body { |
| margin: 20px; |
| font-family: Go; |
| font-size: 14px; |
| line-height: initial; |
| color: #373a3c; |
| } |
| a { |
| color: #0275d8; |
| text-decoration: none; |
| } |
| a:focus, a:hover { |
| color: #014c8c; |
| text-decoration: underline; |
| } |
| .btn { |
| font-family: inherit; |
| font-size: 11px; |
| line-height: 11px; |
| height: 18px; |
| border-radius: 4px; |
| border: solid #d2d2d2 1px; |
| background-color: #fff; |
| box-shadow: 0 1px 1px rgba(0, 0, 0, .05); |
| }` |