Skip to content

Commit

Permalink
maintner/cmd/maintserve: add maintserve command
Browse files Browse the repository at this point in the history
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.

Note that 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.

This is an iteration of an existing command named servegoissues,
with import path github.com/bradfitz/go-issue-mirror/cmd/servegoissues.
That program served the same purpose, but was located in another
repository. It used a "previous generation" approach to having
a corpus of GitHub issues data for Go projects, namely the
github.com/bradfitz/go-issue-mirror/issues package, which used
github.com/bradfitz/issuemirror technology.

The intent of maintserve is to replace servegoissues. It uses
the golang.org/x/build/maintner/godata package as its source of
data, which is built on top of the golang.org/x/build/maintner
technology.

Currently, maintserve has 2 types of pages:

-	Index page, which lists repositories.
-	Issues pages, which display a read-only version of issues.
	This functionality is implemented in external issuesapp package.

By default, maintserve starts as an HTTP server at on port 8080,
so you should visit http://localhost:8080/ in a browser after
running the command. Note that the first run may take a while,
since godata.Get will need to download the entire corprus:

	The initial call to Get will download approximately 350-400 MB
	of data into a directory "golang-maintner" under your operating
	system's user cache directory. Subsequent calls will only
	download what's changed since the previous call.

Helps bradfitz/go-issue-mirror#7.
Depends on shurcooL/issues#5.

Change-Id: I421147df08c6f664afff0e70abb5d6aa6a42b2d5
Reviewed-on: https://go-review.googlesource.com/52932
Reviewed-by: Brad Fitzpatrick <[email protected]>
  • Loading branch information
dmitshur committed Aug 3, 2017
1 parent f9342a3 commit 19c1169
Show file tree
Hide file tree
Showing 2 changed files with 274 additions and 0 deletions.
11 changes: 11 additions & 0 deletions maintner/cmd/maintserve/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
[![GoDoc](https://godoc.org/golang.org/x/build/maintner/cmd/maintserve?status.svg)](https://godoc.org/golang.org/x/build/maintner/cmd/maintserve)

# golang.org/x/build/maintner/cmd/maintserve

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.

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.
263 changes: 263 additions & 0 deletions maintner/cmd/maintserve/maintserve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// 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 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.
package main

import (
"context"
"flag"
"fmt"
"html/template"
"log"
"mime"
"net/http"
"net/url"
"sort"
"strings"
"time"

"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", "application/font-woff"); 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,
})

// TODO: Implement background updates for corpus while the appliation 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.

printServingAt(*httpFlag)
err = http.ListenAndServe(*httpFlag, &handler{
c: corpus,
fontsHandler: httpgzip.FileServer(gofontwoff.Assets, httpgzip.FileServerOptions{}),
issuesHandler: issuesApp,
})
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
}

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
}

// 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
}
http.Redirect(w, req, baseURL, http.StatusFound)
return
}
req = stripPrefix(req, baseURLLen)
h.serveIssues(w, req, maintner.GithubRepoID{Owner: owner, Repo: repo})
}

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>
<h3>Repos</h3>
<ul>{{range .}}
<li><a href="/{{.RepoID}}">{{.RepoID}}</a> ({{.Count}} issues)</li>
{{- end}}
</ul>
</div>
<body>
</html>`))

// serveIndex serves the index page, which lists all available repositories.
func (h *handler) serveIndex(w http.ResponseWriter, req *http.Request) {
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()
})

w.Header().Set("Content-Type", "text/html; charset=utf-8")
err = indexHTML.Execute(w, repos)
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 {
return nil
}
issues++
return nil
})
return issues, err
}

// serveIssues serves issues for 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)
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)
}

// 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);
}`

0 comments on commit 19c1169

Please sign in to comment.