dmitri.shuralyov.com/website/gido

Add support for viewing CLs/PRs of Go packages via gochanges.org.

It's sometimes desirable to see all CLs/PRs for a specific Go package.
This change makes that possible via the https://gochanges.org domain.
dmitshur committed 6 years ago commit 38606cedcd3a0a0c8a9ea329973e40c85a003fef
Showing partial commit. Full Commit
Collapse all
changes.go
@@ -0,0 +1,171 @@
package main

import (
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"os"
	"sort"

	"dmitri.shuralyov.com/app/changes/component"
	"dmitri.shuralyov.com/service/change"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

var changesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html>
	<head>
{{.AnalyticsHTML}}		<title>{{with .PageName}}{{.}} - {{end}}Go Changes</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 style="margin: 0; position: relative;">
		<header style="background-color: hsl(209, 51%, 92%);">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px;">
				<a class="black" href="/"                                      ><strong style="padding: 15px 0 15px 0; display: inline-block;">Go Changes</strong></a>
				<a class="black" href="/-/packages" style="padding-left: 30px;"><span   style="padding: 15px 0 15px 0; display: inline-block;">Packages</span></a>
			</div>
		</header>

		<main style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 120px 15px;">
			{{end}}

			{{define "About"}}<h3 style="margin-top: 30px;">About</h3>

			<p>Go Changes shows changes for Go packages.
			It's just like <a href="https://goissues.org">goissues.org</a>, but for changes (CLs, PRs, etc.).</p>

			<p>To view changes of a Go package with a given import path, navigate to <code>gochanges.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).</p>

			<p>Supported import paths include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
			<li><a href="/-/packages#subrepo">Sub-repositories</a> (i.e., <code>golang.org/x/...</code>).</li>
			</ul>

			<p>Import paths of 3rd party packages (e.g., <code>github.com/...</code>) are not supported at this time.</p>

			<p>It's a simple website with a narrow scope. Enjoy. ʕ◔ϖ◔ʔ</p>
			{{end}}

			{{define "Trailer"}}
		</main>

		<footer style="background-color: hsl(209, 51%, 92%); position: absolute; bottom: 0; left: 0; right: 0;">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px; text-align: right;">
				<span style="padding: 15px 0 15px 0; display: inline-block;"><a href="https://dmitri.shuralyov.com/website/gido/...$issues">Website Issues</a></span>
			</div>
		</footer>
	</body>
</html>
{{end}}`))

// serveChanges serves a list of changes for the package with import path pkg.
func (h *handler) serveChanges(w http.ResponseWriter, req *http.Request, pkg string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := changeStateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	h.s.IssuesAndChangesMu.RLock()
	ic, ok := h.s.IssuesAndChanges[pkg]
	h.s.IssuesAndChangesMu.RUnlock()
	if !ok {
		return os.ErrNotExist
	}
	var cs []change.Change
	switch {
	case filter == change.FilterOpen:
		cs = ic.OpenChanges
	case filter == change.FilterClosedMerged:
		cs = ic.ClosedChanges
	case filter == change.FilterAll:
		cs = append(ic.OpenChanges, ic.ClosedChanges...) // TODO: Measure if slow, optimize if needed.
		sort.Slice(cs, func(i, j int) bool { return cs[i].ID > cs[j].ID })
	}
	openCount := uint64(len(ic.OpenChanges))
	closedCount := uint64(len(ic.ClosedChanges))

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pkg,
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}
	heading := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.H2.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-top: 30px;"}},
		FirstChild: htmlg.Text(pkg),
	}
	if pkg == otherPackages {
		heading.Data, heading.FirstChild = atom.H3.String(), htmlg.Text("Other Go Issues/Changes")
	}
	tabnav := tabnav{
		Tabs: []tab{
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.IssueOpened, Text: "Issues"},
					Count:   len(ic.OpenIssues),
				},
				URL: h.rtr.IssuesURL(pkg),
			},
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.GitPullRequest, Text: "Changes"},
					Count:   len(ic.OpenChanges),
				},
				URL:      h.rtr.ChangesURL(pkg),
				Selected: true,
			},
		},
	}
	var es []component.ChangeEntry
	for _, c := range cs {
		es = append(es, component.ChangeEntry{Change: c, BaseURI: "https://golang.org/cl"})
	}
	changes := component.Changes{
		ChangesNav: component.ChangesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          req.URL.Path,
			Query:         req.URL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	err = htmlg.RenderComponents(w, heading, subheading{pkg}, tabnav, changes)
	if err != nil {
		return err
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// changeStateFilter parses the change state filter from query,
// returning an error if the value is unsupported.
func changeStateFilter(query url.Values) (change.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
		return change.FilterOpen, nil
	case "closed":
		return change.FilterClosedMerged, nil
	case "all":
		return change.FilterAll, nil
	default:
		return "", fmt.Errorf("unsupported state filter value: %q", selectedTabName)
	}
}
html.go
@@ -0,0 +1,80 @@
package main

import (
	"fmt"

	"github.com/shurcooL/htmlg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// TODO: Dedup with .../app/changes/display.go and elsewhere.

// tabnav is a left-aligned horizontal row of tabs Primer CSS component.
//
// http://primercss.io/nav/#tabnav
type tabnav struct {
	Tabs []tab
}

func (t tabnav) Render() []*html.Node {
	nav := &html.Node{
		Type: html.ElementNode, Data: atom.Nav.String(),
		Attr: []html.Attribute{{Key: atom.Class.String(), Val: "tabnav-tabs"}},
	}
	for _, t := range t.Tabs {
		htmlg.AppendChildren(nav, t.Render()...)
	}
	return []*html.Node{htmlg.DivClass("tabnav", nav)}
}

// tab is a single tab entry within a tabnav.
type tab struct {
	Content  htmlg.Component
	URL      string
	Selected bool
}

func (t tab) Render() []*html.Node {
	aClass := "tabnav-tab"
	if t.Selected {
		aClass += " selected"
	}
	a := &html.Node{
		Type: html.ElementNode, Data: atom.A.String(),
		Attr: []html.Attribute{
			{Key: atom.Href.String(), Val: t.URL},
			{Key: atom.Class.String(), Val: aClass},
		},
	}
	htmlg.AppendChildren(a, t.Content.Render()...)
	return []*html.Node{a}
}

type contentCounter struct {
	Content htmlg.Component
	Count   int
}

func (cc contentCounter) Render() []*html.Node {
	var ns []*html.Node
	ns = append(ns, cc.Content.Render()...)
	ns = append(ns, htmlg.SpanClass("counter", htmlg.Text(fmt.Sprint(cc.Count))))
	return ns
}

// iconText is an icon with text on the right.
// Icon must be not nil.
type iconText struct {
	Icon func() *html.Node // Must be not nil.
	Text string
}

func (it iconText) Render() []*html.Node {
	icon := htmlg.Span(it.Icon())
	icon.Attr = append(icon.Attr, html.Attribute{
		Key: atom.Style.String(), Val: "margin-right: 4px;",
	})
	text := htmlg.Text(it.Text)
	return []*html.Node{icon, text}
}
issues.go
@@ -0,0 +1,193 @@
package main

import (
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"os"
	"sort"

	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/issuesapp/component"
	"github.com/shurcooL/octiconssvg"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

var issuesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html>
	<head>
{{.AnalyticsHTML}}		<title>{{with .PageName}}{{.}} - {{end}}Go Issues</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 style="margin: 0; position: relative;">
		<header style="background-color: hsl(209, 51%, 92%);">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px;">
				<a class="black" href="/"                                      ><strong style="padding: 15px 0 15px 0; display: inline-block;">Go Issues</strong></a>
				<a class="black" href="/-/packages" style="padding-left: 30px;"><span   style="padding: 15px 0 15px 0; display: inline-block;">Packages</span></a>
			</div>
		</header>

		<main style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 120px 15px;">
			{{end}}

			{{define "About"}}<h3 style="margin-top: 30px;">About</h3>

			<p>Go Issues shows issues for Go packages.
			It's just like <a href="https://godoc.org">godoc.org</a>, but for issues.</p>

			<p>To view issues of a Go package with a given import path, navigate to <code>goissues.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).</p>

			<p>Supported import paths include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
			<li><a href="/-/packages#subrepo">Sub-repositories</a> (i.e., <code>golang.org/x/...</code>).</li>
			</ul>

			<p>Import paths of 3rd party packages (e.g., <code>github.com/...</code>) are not supported at this time.</p>

			<p>It's a simple website with a narrow scope. Enjoy. ʕ◔ϖ◔ʔ</p>
			{{end}}

			{{define "Trailer"}}
		</main>

		<footer style="background-color: hsl(209, 51%, 92%); position: absolute; bottom: 0; left: 0; right: 0;">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px; text-align: right;">
				<span style="padding: 15px 0 15px 0; display: inline-block;"><a href="https://dmitri.shuralyov.com/website/gido/...$issues">Website Issues</a></span>
			</div>
		</footer>
	</body>
</html>
{{end}}`))

// serveIssues serves a list of issues for the package with import path pkg.
func (h *handler) serveIssues(w http.ResponseWriter, req *http.Request, pkg string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := issueStateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	h.s.IssuesAndChangesMu.RLock()
	ic, ok := h.s.IssuesAndChanges[pkg]
	h.s.IssuesAndChangesMu.RUnlock()
	if !ok {
		return os.ErrNotExist
	}
	var is []issues.Issue
	switch {
	case filter == issues.StateFilter(issues.OpenState):
		is = ic.OpenIssues
	case filter == issues.StateFilter(issues.ClosedState):
		is = ic.ClosedIssues
	case filter == issues.AllStates:
		is = append(ic.OpenIssues, ic.ClosedIssues...) // TODO: Measure if slow, optimize if needed.
		sort.Slice(is, func(i, j int) bool { return is[i].ID > is[j].ID })
	}
	openCount := uint64(len(ic.OpenIssues))
	closedCount := uint64(len(ic.ClosedIssues))

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pkg,
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}
	heading := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.H2.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-top: 30px;"}},
		FirstChild: htmlg.Text(pkg),
	}
	if pkg == otherPackages {
		heading.Data, heading.FirstChild = atom.H3.String(), htmlg.Text("Other Go Issues/Changes")
	}
	tabnav := tabnav{
		Tabs: []tab{
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.IssueOpened, Text: "Issues"},
					Count:   len(ic.OpenIssues),
				},
				URL:      h.rtr.IssuesURL(pkg),
				Selected: true,
			},
			{
				Content: contentCounter{
					Content: iconText{Icon: octiconssvg.GitPullRequest, Text: "Changes"},
					Count:   len(ic.OpenChanges),
				},
				URL: h.rtr.ChangesURL(pkg),
			},
		},
	}
	title := ImportPathToFullPrefix(pkg)
	newIssue := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "text-align: right;"}},
		FirstChild: htmlg.A("New Issue", "https://golang.org/issue/new?title="+url.QueryEscape(title)),
	}
	var es []component.IssueEntry
	for _, i := range is {
		es = append(es, component.IssueEntry{Issue: i, BaseURI: "https://golang.org/issue"})
	}
	issues := component.Issues{
		IssuesNav: component.IssuesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          req.URL.Path,
			Query:         req.URL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	err = htmlg.RenderComponents(w, heading, subheading{pkg}, tabnav, newIssue, issues)
	if err != nil {
		return err
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

type subheading struct{ Pkg string }

func (s subheading) Render() []*html.Node {
	switch s.Pkg {
	case otherPackages:
		return []*html.Node{htmlg.P(htmlg.Text("Issues and changes that don't fit into any existing Go package."))}
	default:
		return nil
	}
}

const (
	// stateQueryKey is name of query key for controlling issue/change state filter.
	stateQueryKey = "state"
)

// issueStateFilter parses the issue state filter from query,
// returning an error if the value is unsupported.
func issueStateFilter(query url.Values) (issues.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
		return issues.StateFilter(issues.OpenState), nil
	case "closed":
		return issues.StateFilter(issues.ClosedState), nil
	case "all":
		return issues.AllStates, nil
	default:
		return "", fmt.Errorf("unsupported state filter value: %q", selectedTabName)
	}
}
main.go
@@ -1,6 +1,6 @@
// gido is the command that powers the https://goissues.org website.
// gido is the command that powers the https://goissues.org and https://gochanges.org websites.
package main

import (
	"context"
	"encoding/json"
@@ -10,64 +10,74 @@ import (
	"io"
	"io/ioutil"
	"log"
	"mime"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path"
	"sort"
	"strings"

	"dmitri.shuralyov.com/website/gido/assets"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/httpgzip"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/issuesapp/component"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

var (
	httpFlag          = flag.String("http", ":8080", "Listen for HTTP connections on this address.")
	routerFlag        = flag.String("router", "dev", `Routing system to use ("dot-org" for production use, "dev" for localhost development).`)
	analyticsFileFlag = flag.String("analytics-file", "", "Optional path to file containing analytics HTML to insert at the beginning of <head>.")
)

func main() {
	flag.Parse()

	var router Router
	switch *routerFlag {
	case "dot-org":
		router = dotOrgRouter{}
	case "dev":
		router = devRouter{}
	default:
		fmt.Fprintf(os.Stderr, "invalid -router flag value %q\n", *routerFlag)
		flag.Usage()
		os.Exit(2)
	}

	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		sigint := make(chan os.Signal, 1)
		signal.Notify(sigint, os.Interrupt)
		<-sigint
		cancel()
	}()

	err := run(ctx)
	err := run(ctx, router, *analyticsFileFlag)
	if err != nil {
		log.Fatalln(err)
	}
}

func run(ctx context.Context) error {
func run(ctx context.Context, router Router, analyticsFile string) error {
	if err := mime.AddExtensionType(".woff2", "font/woff2"); err != nil {
		return err
	}

	var analyticsHTML []byte
	if *analyticsFileFlag != "" {
	if analyticsFile != "" {
		var err error
		analyticsHTML, err = ioutil.ReadFile(*analyticsFileFlag)
		analyticsHTML, err = ioutil.ReadFile(analyticsFile)
		if err != nil {
			return err
		}
	}

	server := &http.Server{Addr: *httpFlag, Handler: top{&errorHandler{handler: (&handler{
		rtr:           router,
		analyticsHTML: analyticsHTML,
		fontsHandler:  httpgzip.FileServer(assets.Fonts, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}),
		assetsHandler: httpgzip.FileServer(assets.Assets, httpgzip.FileServerOptions{ServeError: httpgzip.Detailed}),
		s:             newService(ctx),
	}).ServeHTTP}}}
@@ -93,10 +103,11 @@ func run(ctx context.Context) error {
}

// handler handles all goissues requests. It acts like a request multiplexer,
// choosing from various endpoints and parsing the import path from URL.
type handler struct {
	rtr           Router
	analyticsHTML []byte
	fontsHandler  http.Handler
	assetsHandler http.Handler
	s             *service
}
@@ -133,132 +144,89 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
		}
		http.Redirect(w, req, canonicalPath, http.StatusFound)
		return nil
	}
	pkg := req.URL.Path[1:]
	return h.ServeIssues(w, req, pkg)
	return h.ServeIssuesOrChanges(w, req, pkg)
}

var pageHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html>
	<head>
{{.AnalyticsHTML}}		<title>{{with .PageName}}{{.}} - {{end}}Go Issues</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 style="margin: 0; position: relative;">
		<header style="background-color: hsl(209, 51%, 92%);">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px;">
				<a class="black" href="/"                                      ><strong style="padding: 15px 0 15px 0; display: inline-block;">Go Issues</strong></a>
				<a class="black" href="/-/packages" style="padding-left: 30px;"><span   style="padding: 15px 0 15px 0; display: inline-block;">Packages</span></a>
			</div>
		</header>

		<main style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 120px 15px;">
			{{end}}

			{{define "Trailer"}}
		</main>

		<footer style="background-color: hsl(209, 51%, 92%); position: absolute; bottom: 0; left: 0; right: 0;">
			<div style="max-width: 800px; margin: 0 auto 0 auto; padding: 0 15px 0 15px; text-align: right;">
				<span style="padding: 15px 0 15px 0; display: inline-block;"><a href="https://dmitri.shuralyov.com/website/gido/...$issues">Website Issues</a></span>
			</div>
		</footer>
	</body>
</html>
{{end}}`))

// ServeIndex serves the index page.
func (h *handler) ServeIndex(w http.ResponseWriter, req *http.Request) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err := pageHTML.ExecuteTemplate(w, "Header", map[string]interface{}{
	err := h.executeTemplate(w, req, "Header", map[string]interface{}{
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}

	// Write the About section.
	_, err = io.WriteString(w, `<h3 style="margin-top: 30px;">About</h3>

			<p>Go Issues shows issues for Go packages.
			It's just like <a href="https://godoc.org">godoc.org</a>, but for issues.</p>

			<p>To view issues of a Go package with a given import path, navigate to <code>goissues.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).</p>

			<p>Supported import paths include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
			<li><a href="/-/packages#subrepo">Sub-repositories</a> (i.e., <code>golang.org/x/...</code>).</li>
			</ul>

			<p>Import paths of 3rd party packages (e.g., <code>github.com/...</code>) are not supported at this time.</p>

			<p>It's a simple website with a narrow scope. Enjoy. ʕ◔ϖ◔ʔ</p>
			`)
	err = h.executeTemplate(w, req, "About", nil)
	if err != nil {
		return err
	}

	_, err = io.WriteString(w, `<h3 style="margin-top: 30px;">Popular Packages</h3>`)
	if err != nil {
		return err
	}

	// Find some popular packages to display.
	h.s.PackageIssuesMu.RLock()
	pis := h.s.PackageIssues
	h.s.PackageIssuesMu.RUnlock()
	h.s.IssuesAndChangesMu.RLock()
	ics := h.s.IssuesAndChanges
	h.s.IssuesAndChangesMu.RUnlock()
	var popular []pkg
	for _, p := range h.s.Packages {
		popular = append(popular, pkg{
			Path: p,
			Open: len(pis[p].Open),
			Path:        p,
			OpenIssues:  len(ics[p].OpenIssues),
			OpenChanges: len(ics[p].OpenChanges),
		})
	}
	sort.SliceStable(popular, func(i, j int) bool { return popular[i].Open > popular[j].Open })
	sort.SliceStable(popular, func(i, j int) bool {
		return popular[i].OpenIssues+popular[i].OpenChanges > popular[j].OpenIssues+popular[j].OpenChanges
	})
	popular = popular[:15]

	// Render the table.
	err = renderTable(w, popular)
	if err != nil {
		return err
	}

	err = pageHTML.ExecuteTemplate(w, "Trailer", nil)
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// ServePackages serves a list of all known packages.
func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}

	// Gather all packages in sorted order.
	h.s.PackageIssuesMu.RLock()
	pis := h.s.PackageIssues
	h.s.PackageIssuesMu.RUnlock()
	h.s.IssuesAndChangesMu.RLock()
	ics := h.s.IssuesAndChanges
	h.s.IssuesAndChangesMu.RUnlock()
	var stdlib, subrepo []pkg
	for _, p := range h.s.Packages {
		switch isStandard(p) {
		case true:
			stdlib = append(stdlib, pkg{
				Path: p,
				Open: len(pis[p].Open),
				Path:        p,
				OpenIssues:  len(ics[p].OpenIssues),
				OpenChanges: len(ics[p].OpenChanges),
			})
		case false:
			subrepo = append(subrepo, pkg{
				Path: p,
				Open: len(pis[p].Open),
				Path:        p,
				OpenIssues:  len(ics[p].OpenIssues),
				OpenChanges: len(ics[p].OpenChanges),
			})
		}
	}

	if req.Header.Get("Accept") == "application/json" {
@@ -268,11 +236,11 @@ func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error
		err := e.Encode(append(stdlib, subrepo...)) // TODO: Measure if slow, optimize if needed.
		return err
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err := pageHTML.ExecuteTemplate(w, "Header", map[string]interface{}{
	err := h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      "Packages",
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
@@ -308,161 +276,66 @@ func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error
			</p>`)
	if err != nil {
		return err
	}

	err = pageHTML.ExecuteTemplate(w, "Trailer", nil)
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

type pkg struct {
	Path string `json:"ImportPath"`
	Open int    `json:"OpenIssues"`
	Path        string `json:"ImportPath"`
	OpenIssues  int
	OpenChanges int
}

func renderTable(w io.Writer, pkgs []pkg) error {
	_, err := io.WriteString(w, `
			<table class="table table-sm">
				<thead>
					<tr>
						<th>Path</th>
						<th>Open Issues</th>
						<th>Open Changes</th>
					</tr>
				</thead>
				<tbody>`)
	if err != nil {
		return err
	}
	for _, p := range pkgs {
		err := html.Render(w, htmlg.TR(
			htmlg.TD(htmlg.A(p.Path, "/"+p.Path)),
			htmlg.TD(htmlg.Text(fmt.Sprint(p.Open))),
			htmlg.TD(htmlg.Text(fmt.Sprint(p.OpenIssues))),
			htmlg.TD(htmlg.Text(fmt.Sprint(p.OpenChanges))),
		))
		if err != nil {
			return err
		}
	}
	_, err = io.WriteString(w, `</tbody>
			</table>`)
	return err
}

// ServeIssues serves a list of issues for the package with import path pkg.
func (h *handler) ServeIssues(w http.ResponseWriter, req *http.Request, pkg string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := stateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	h.s.PackageIssuesMu.RLock()
	pi, ok := h.s.PackageIssues[pkg]
	h.s.PackageIssuesMu.RUnlock()
	if !ok {
		return os.ErrNotExist
	}
	var is []issues.Issue
	switch {
	case filter == issues.StateFilter(issues.OpenState):
		is = pi.Open
	case filter == issues.StateFilter(issues.ClosedState):
		is = pi.Closed
	case filter == issues.AllStates:
		is = append(pi.Open, pi.Closed...) // TODO: Measure if slow, optimize if needed.
		sort.Slice(is, func(i, j int) bool { return is[i].ID > is[j].ID })
	}
	openCount := uint64(len(pi.Open))
	closedCount := uint64(len(pi.Closed))

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = pageHTML.ExecuteTemplate(w, "Header", map[string]interface{}{
		"PageName":      pkg,
		"AnalyticsHTML": template.HTML(h.analyticsHTML),
	})
	if err != nil {
		return err
	}
	heading := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.H2.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-top: 30px;"}},
		FirstChild: htmlg.Text(pkg),
	}
	if pkg == otherPackages {
		heading.Data, heading.FirstChild = atom.H3.String(), htmlg.Text("Other Go Issues")
	}
	title := ImportPathToFullPrefix(pkg)
	newIssue := htmlg.NodeComponent{
		Type: html.ElementNode, Data: atom.Div.String(),
		Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "text-align: right;"}},
		FirstChild: htmlg.A("New Issue", "https://golang.org/issue/new?title="+url.QueryEscape(title)),
	}
	var es []component.IssueEntry
	for _, i := range is {
		es = append(es, component.IssueEntry{Issue: i, BaseURI: "https://golang.org/issue"})
	}
	issues := component.Issues{
		IssuesNav: component.IssuesNav{
			OpenCount:     openCount,
			ClosedCount:   closedCount,
			Path:          req.URL.Path,
			Query:         req.URL.Query(),
			StateQueryKey: stateQueryKey,
		},
		Filter:  filter,
		Entries: es,
	}
	err = htmlg.RenderComponents(w, heading, subheading{pkg}, newIssue, issues)
	if err != nil {
		return err
	}
	err = pageHTML.ExecuteTemplate(w, "Trailer", nil)
	return err
}

type subheading struct{ Pkg string }

func (s subheading) Render() []*html.Node {
	switch s.Pkg {
	case otherPackages:
		return []*html.Node{htmlg.P(htmlg.Text("Issues that don't fit into any existing Go package."))}
// ServeIssuesOrChanges serves a list of issues or changes for the package with import path pkg.
func (h *handler) ServeIssuesOrChanges(w http.ResponseWriter, req *http.Request, pkg string) error {
	switch changes := h.rtr.WantChanges(req); {
	case !changes:
		return h.serveIssues(w, req, pkg)
	case changes:
		return h.serveChanges(w, req, pkg)
	default:
		return nil
		panic("unreachable")
	}
}

const (
	// stateQueryKey is name of query key for controlling issue state filter.
	stateQueryKey = "state"
)

// stateFilter parses the issue state filter from query,
// returning an error if the value is unsupported.
func stateFilter(query url.Values) (issues.StateFilter, error) {
	selectedTabName := query.Get(stateQueryKey)
	switch selectedTabName {
	case "":
		return issues.StateFilter(issues.OpenState), nil
	case "closed":
		return issues.StateFilter(issues.ClosedState), nil
	case "all":
		return issues.AllStates, nil
func (h *handler) executeTemplate(w io.Writer, req *http.Request, name string, data interface{}) error {
	switch changes := h.rtr.WantChanges(req); {
	case !changes:
		return issuesHTML.ExecuteTemplate(w, name, data)
	case changes:
		return changesHTML.ExecuteTemplate(w, name, data)
	default:
		return "", fmt.Errorf("unsupported state filter value: %q", selectedTabName)
	}
}

// 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 = "/"
		panic("unreachable")
	}
	return r2
}
route.go
@@ -0,0 +1,52 @@
package main

import (
	"net/http"
	"strconv"
)

// Router provides a routing system.
type Router interface {
	// WantChanges reports whether the request req is for changes
	// rather than issues.
	WantChanges(req *http.Request) bool

	// IssuesURL returns the URL of the issues page for package pkg.
	IssuesURL(pkg string) string

	// ChangesURL returns the URL of the changes page for package pkg.
	ChangesURL(pkg string) string
}

// dotOrgRouter provides a routing system for go{issues,changes}.org.
// Pages for issues/changes are selected based on host.
type dotOrgRouter struct{}

func (dotOrgRouter) WantChanges(req *http.Request) bool {
	return req.Host == "gochanges.org"
}

func (dotOrgRouter) IssuesURL(pkg string) string {
	return "//goissues.org/" + pkg
}

func (dotOrgRouter) ChangesURL(pkg string) string {
	return "//gochanges.org/" + pkg
}

// devRouter provides routing system for local development.
// Pages for issues/changes are selected based on ?changes=1 query parameter.
type devRouter struct{}

func (devRouter) WantChanges(req *http.Request) bool {
	ok, _ := strconv.ParseBool(req.URL.Query().Get("changes"))
	return ok
}

func (devRouter) IssuesURL(pkg string) string {
	return "/" + pkg
}

func (devRouter) ChangesURL(pkg string) string {
	return "/" + pkg + "?changes=1"
}
service.go
@@ -8,37 +8,39 @@ import (
	"sort"
	"strings"
	"sync"
	"time"

	"dmitri.shuralyov.com/service/change"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/users"
	"golang.org/x/build/maintner"
	"golang.org/x/build/maintner/godata"
)

type service struct {
	// PackageIssues contains issues for all packages. Map key is import path.
	// An additional entry with key otherPackages is for issues that don't fit
	// IssuesAndChanges contains issues and changes for all packages. Map key is import path.
	// An additional entry with key otherPackages is for issues and changes that don't fit
	// into any existing Go package.
	PackageIssuesMu sync.RWMutex
	PackageIssues   map[string]*pkgIssues
	IssuesAndChangesMu sync.RWMutex
	IssuesAndChanges   map[string]*Directory

	// Packages is a list of all packages. Sorted by import path, standard library first.
	Packages []string
}

type pkgIssues struct {
	Open, Closed []issues.Issue
type Directory struct {
	OpenIssues, ClosedIssues   []issues.Issue
	OpenChanges, ClosedChanges []change.Change
}

func newService(ctx context.Context) *service {
	packageIssues := emptyPackages()
	issuesAndChanges := emptyDirectories()

	// Initialize list of packages sorted by import path, standard library first.
	var packages []string
	for p := range packageIssues {
	for p := range issuesAndChanges {
		if p == otherPackages { // Don't include "other", it's not a real package.
			continue
		}
		packages = append(packages, p)
	}
@@ -48,33 +50,33 @@ func newService(ctx context.Context) *service {
		}
		return packages[i] < packages[j]
	})

	s := &service{
		PackageIssues: packageIssues,
		Packages:      packages,
		IssuesAndChanges: issuesAndChanges,
		Packages:         packages,
	}
	go s.poll(ctx)
	return s
}

func emptyPackages() map[string]*pkgIssues {
	// Initialize places for issues, using existing packages
func emptyDirectories() map[string]*Directory {
	// Initialize places for issues and changes, using existing packages
	// and their parent directories.
	packageIssues := make(map[string]*pkgIssues)
	issuesAndChanges := make(map[string]*Directory)
	for p := range existingPackages {
		elems := strings.Split(p, "/")
		for i := len(elems); i >= 1; i-- { // Iterate in reverse order so we can break out early.
			p := path.Join(elems[:i]...)
			if _, ok := packageIssues[p]; ok {
			if _, ok := issuesAndChanges[p]; ok {
				break
			}
			packageIssues[p] = new(pkgIssues)
			issuesAndChanges[p] = new(Directory)
		}
	}
	packageIssues[otherPackages] = new(pkgIssues)
	return packageIssues
	issuesAndChanges[otherPackages] = new(Directory)
	return issuesAndChanges
}

func category(importPath string) int {
	switch isStandard(importPath) {
	case true:
@@ -100,14 +102,14 @@ func (s *service) poll(ctx context.Context) {
	if err != nil {
		log.Fatalln("poll: initial initCorpus failed:", err)
	}

	for {
		packageIssues := packageIssues(repo)
		s.PackageIssuesMu.Lock()
		s.PackageIssues = packageIssues
		s.PackageIssuesMu.Unlock()
		issuesAndChanges := issuesAndChanges(repo, corpus.Gerrit())
		s.IssuesAndChangesMu.Lock()
		s.IssuesAndChanges = issuesAndChanges
		s.IssuesAndChangesMu.Unlock()
		for {
			updateError := corpus.Update(ctx)
			if updateError == maintner.ErrSplit {
				log.Println("corpus.Update: Corpus out of sync. Re-fetching corpus.")
				corpus, repo, err = initCorpus(ctx)
@@ -129,17 +131,22 @@ func initCorpus(ctx context.Context) (*maintner.Corpus, *maintner.GitHubRepo, er
	if err != nil {
		return nil, nil, fmt.Errorf("godata.Get: %v", err)
	}
	repo := corpus.GitHub().Repo("golang", "go")
	if repo == nil {
		return nil, nil, fmt.Errorf("golang/go repo not found")
		return nil, nil, fmt.Errorf("golang/go GitHub repo not found")
	}
	if corpus.Gerrit().Project("go.googlesource.com", "go") == nil {
		return nil, nil, fmt.Errorf("go.googlesource.com/go Gerrit project not found")
	}
	return corpus, repo, nil
}

func packageIssues(repo *maintner.GitHubRepo) map[string]*pkgIssues {
	packageIssues := emptyPackages()
func issuesAndChanges(repo *maintner.GitHubRepo, gerrit *maintner.Gerrit) map[string]*Directory {
	issuesAndChanges := emptyDirectories()

	// Collect issues.
	err := repo.ForeachIssue(func(i *maintner.GitHubIssue) error {
		if i.NotExist || i.PullRequest {
			return nil
		}

@@ -173,44 +180,140 @@ func packageIssues(repo *maintner.GitHubRepo) map[string]*pkgIssues {
			Replies: replies,
		}

		var added bool
		for _, p := range pkgs {
			pi := packageIssues[p]
			if pi == nil {
			ic := issuesAndChanges[p]
			if ic == nil {
				continue
			}
			switch issue.State {
			case issues.OpenState:
				pi.Open = append(pi.Open, issue)
				ic.OpenIssues = append(ic.OpenIssues, issue)
			case issues.ClosedState:
				pi.Closed = append(pi.Closed, issue)
				ic.ClosedIssues = append(ic.ClosedIssues, issue)
			}
			added = true
		}
		if !added {
			pi := packageIssues[otherPackages]
			ic := issuesAndChanges[otherPackages]
			issue.Title = i.Title
			switch issue.State {
			case issues.OpenState:
				pi.Open = append(pi.Open, issue)
				ic.OpenIssues = append(ic.OpenIssues, issue)
			case issues.ClosedState:
				pi.Closed = append(pi.Closed, issue)
				ic.ClosedIssues = append(ic.ClosedIssues, issue)
			}
		}

		return nil
	})
	if err != nil {
		panic(fmt.Errorf("internal error: ForeachIssue returned non-nil error: %v", err))
	}
	// Sort issues by ID (newest first).
	for _, p := range packageIssues {
		sort.Slice(p.Open, func(i, j int) bool { return p.Open[i].ID > p.Open[j].ID })
		sort.Slice(p.Closed, func(i, j int) bool { return p.Closed[i].ID > p.Closed[j].ID })

	// Collect changes.
	err = gerrit.ForeachProjectUnsorted(func(proj *maintner.GerritProject) error {
		root, ok := gerritProjects[proj.ServerSlashProject()]
		if !ok {
			return nil
		}
		err := proj.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
			if cl.Private || cl.Status == "" {
				return nil
			}
			state, ok := clState(cl.Status)
			if !ok {
				return nil
			}

			prefixedTitle := firstParagraph(cl.Commit.Msg)
			pkgs, title := ParsePrefixedChangeTitle(root, prefixedTitle)
			c := change.Change{
				ID:        uint64(cl.Number),
				State:     state,
				Title:     title,
				Author:    gitUser(cl.Commit.Author),
				CreatedAt: cl.Created,
				Replies:   len(cl.Messages),
			}

			var added bool
			for _, p := range pkgs {
				ic := issuesAndChanges[p]
				if ic == nil {
					continue
				}
				switch c.State {
				case change.OpenState:
					ic.OpenChanges = append(ic.OpenChanges, c)
				case change.ClosedState, change.MergedState:
					ic.ClosedChanges = append(ic.ClosedChanges, c)
				}
				added = true
			}
			if !added {
				ic := issuesAndChanges[root]
				if ic == nil {
					ic = issuesAndChanges[otherPackages]
				}
				c.Title = prefixedTitle
				switch c.State {
				case change.OpenState:
					ic.OpenChanges = append(ic.OpenChanges, c)
				case change.ClosedState, change.MergedState:
					ic.ClosedChanges = append(ic.ClosedChanges, c)
				}
			}

			return nil
		})
		return err
	})
	if err != nil {
		panic(fmt.Errorf("internal error: ForeachProjectUnsorted returned non-nil error: %v", err))
	}

	// Sort issues and changes by ID (newest first).
	for _, p := range issuesAndChanges {
		sort.Slice(p.OpenIssues, func(i, j int) bool { return p.OpenIssues[i].ID > p.OpenIssues[j].ID })
		sort.Slice(p.ClosedIssues, func(i, j int) bool { return p.ClosedIssues[i].ID > p.ClosedIssues[j].ID })
		sort.Slice(p.OpenChanges, func(i, j int) bool { return p.OpenChanges[i].ID > p.OpenChanges[j].ID })
		sort.Slice(p.ClosedChanges, func(i, j int) bool { return p.ClosedChanges[i].ID > p.ClosedChanges[j].ID })
	}
	return packageIssues

	return issuesAndChanges
}

// gerritProjects maps each supported Gerrit "server/project" to
// the import path that corresponds to the root of that project.
var gerritProjects = map[string]string{
	"go.googlesource.com/go":         "",
	"go.googlesource.com/arch":       "golang.org/x/arch",
	"go.googlesource.com/benchmarks": "golang.org/x/benchmarks",
	"go.googlesource.com/blog":       "golang.org/x/blog",
	"go.googlesource.com/build":      "golang.org/x/build",
	"go.googlesource.com/crypto":     "golang.org/x/crypto",
	"go.googlesource.com/debug":      "golang.org/x/debug",
	"go.googlesource.com/exp":        "golang.org/x/exp",
	"go.googlesource.com/image":      "golang.org/x/image",
	"go.googlesource.com/lint":       "golang.org/x/lint",
	"go.googlesource.com/mobile":     "golang.org/x/mobile",
	"go.googlesource.com/net":        "golang.org/x/net",
	"go.googlesource.com/oauth2":     "golang.org/x/oauth2",
	"go.googlesource.com/perf":       "golang.org/x/perf",
	"go.googlesource.com/playground": "golang.org/x/playground",
	"go.googlesource.com/review":     "golang.org/x/review",
	"go.googlesource.com/sync":       "golang.org/x/sync",
	"go.googlesource.com/sys":        "golang.org/x/sys",
	"go.googlesource.com/talks":      "golang.org/x/talks",
	"go.googlesource.com/term":       "golang.org/x/term",
	"go.googlesource.com/text":       "golang.org/x/text",
	"go.googlesource.com/time":       "golang.org/x/time",
	"go.googlesource.com/tools":      "golang.org/x/tools",
	"go.googlesource.com/tour":       "golang.org/x/tour",
	"go.googlesource.com/vgo":        "golang.org/x/vgo",
}

const otherPackages = "other"

// ParsePrefixedTitle parses a prefixed issue title.
@@ -237,12 +340,12 @@ func ParsePrefixedTitle(prefixedTitle string) (paths []string, title string) {
	if idx == -1 {
		return nil, prefixedTitle
	}
	prefix, title := prefixedTitle[:idx], prefixedTitle[idx+len(": "):]
	if strings.ContainsAny(prefix, "{}") {
		// TODO: Parse "x/image/{tiff,bmp}" as ["x/image/tiff", "x/image/bmp"], maybe?
		return []string{prefix}, title
		// TODO: Parse "image/{png,jpeg}" as ["image/png", "image/jpeg"], maybe?
		return []string{strings.TrimSpace(prefix)}, title
	}
	paths = strings.Split(prefix, ",")
	for i := range paths {
		paths[i] = strings.TrimSpace(paths[i])
		if strings.HasPrefix(paths[i], "x/") || paths[i] == "x" { // Map "x/..." to "golang.org/x/...".
@@ -250,10 +353,41 @@ func ParsePrefixedTitle(prefixedTitle string) (paths []string, title string) {
		}
	}
	return paths, title
}

// ParsePrefixedChangeTitle parses a prefixed change title.
// It returns a list of paths from the prefix joined with root, and the remaining change title.
// It does not try to verify whether each path is an existing Go package.
//
// Supported forms include:
//
// 	"root", "import/path: Change title."  -> ["root/import/path"],         "Change title."
// 	"root", "path1, path2: Change title." -> ["root/path1", "root/path2"], "Change title."  # Multiple comma-separated paths.
//
// If there's no path prefix (preceded by ": "), title is returned unmodified
// with a paths list containing root:
//
// 	"root", "Change title."               -> ["root"], "Change title."
//
func ParsePrefixedChangeTitle(root, prefixedTitle string) (paths []string, title string) {
	idx := strings.Index(prefixedTitle, ": ")
	if idx == -1 {
		return []string{root}, prefixedTitle
	}
	prefix, title := prefixedTitle[:idx], prefixedTitle[idx+len(": "):]
	if strings.ContainsAny(prefix, "{}") {
		// TODO: Parse "image/{png,jpeg}" as ["image/png", "image/jpeg"], maybe?
		return []string{path.Join(root, strings.TrimSpace(prefix))}, title
	}
	paths = strings.Split(prefix, ",")
	for i := range paths {
		paths[i] = path.Join(root, strings.TrimSpace(paths[i]))
	}
	return paths, title
}

// ImportPathToFullPrefix returns the an issue title prefix (including ": ") for the given import path.
// If path equals to otherPackages, an empty prefix is returned.
func ImportPathToFullPrefix(path string) string {
	switch {
	default:
@@ -278,10 +412,35 @@ func ghState(issue *maintner.GitHubIssue) issues.State {
	default:
		panic("unreachable")
	}
}

// firstParagraph returns the first paragraph of text s.
func firstParagraph(s string) string {
	i := strings.Index(s, "\n\n")
	if i == -1 {
		return s
	}
	return s[:i]
}

func clState(status string) (_ change.State, ok bool) {
	switch status {
	case "new":
		return change.OpenState, true
	case "abandoned":
		return change.ClosedState, true
	case "merged":
		return change.MergedState, true
	case "draft":
		// Treat draft CL as one that doesn't exist.
		return "", false
	default:
		panic(fmt.Errorf("unrecognized CL status %q", status))
	}
}

// ghUser converts a GitHub user into a users.User.
func ghUser(user *maintner.GitHubUser) users.User {
	return users.User{
		UserSpec: users.UserSpec{
			ID:     uint64(user.ID),
@@ -290,5 +449,18 @@ func ghUser(user *maintner.GitHubUser) users.User {
		Login:     user.Login,
		AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/%d?v=4&s=96", user.ID),
		HTMLURL:   fmt.Sprintf("https://github.com/%v", user.Login),
	}
}

func gitUser(user *maintner.GitPerson) users.User {
	return users.User{
		UserSpec: users.UserSpec{
			ID:     0,  // TODO.
			Domain: "", // TODO.
		},
		Login: user.Name(), //user.Username, // TODO.
		Name:  user.Name(),
		Email: user.Email(),
		//AvatarURL: fmt.Sprintf("https://%s/accounts/%d/avatar?s=96", s.domain, user.AccountID),
	}
}
service_test.go
@@ -40,17 +40,63 @@ func TestParsePrefixedTitle(t *testing.T) {
		{ // No path prefix.
			in:        "Issue title.",
			wantPaths: nil, wantTitle: "Issue title.",
		},
	}
	for _, tc := range tests {
	for i, tc := range tests {
		gotPaths, gotTitle := gido.ParsePrefixedTitle(tc.in)
		if !reflect.DeepEqual(gotPaths, tc.wantPaths) {
			t.Errorf("got paths: %q, want: %q", gotPaths, tc.wantPaths)
			t.Errorf("%d: got paths: %q, want: %q", i, gotPaths, tc.wantPaths)
		}
		if gotTitle != tc.wantTitle {
			t.Errorf("%d: got title: %q, want: %q", i, gotTitle, tc.wantTitle)
		}
	}
}

func TestParsePrefixedChangeTitle(t *testing.T) {
	tests := []struct {
		inRoot    string
		in        string
		wantPaths []string
		wantTitle string
	}{
		{
			in:        "import/path: Change title.",
			wantPaths: []string{"import/path"}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "import/path: Change title.",
			wantPaths: []string{"root/import/path"}, wantTitle: "Change title.",
		},
		{ // Multiple comma-separated paths.
			in:        "path1, path2: Change title.",
			wantPaths: []string{"path1", "path2"}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "path1, path2: Change title.",
			wantPaths: []string{"root/path1", "root/path2"}, wantTitle: "Change title.",
		},
		{ // No path prefix.
			in:        "Change title.",
			wantPaths: []string{""}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "Change title.",
			wantPaths: []string{"root"}, wantTitle: "Change title.",
		},
	}
	for i, tc := range tests {
		gotPaths, gotTitle := gido.ParsePrefixedChangeTitle(tc.inRoot, tc.in)
		if !reflect.DeepEqual(gotPaths, tc.wantPaths) {
			t.Errorf("%d: got paths: %q, want: %q", i, gotPaths, tc.wantPaths)
		}
		if gotTitle != tc.wantTitle {
			t.Errorf("got title: %q, want: %q", gotTitle, tc.wantTitle)
			t.Errorf("%d: got title: %q, want: %q", i, gotTitle, tc.wantTitle)
		}
	}
}

func TestImportPathToFullPrefix(t *testing.T) {
util.go
@@ -4,10 +4,11 @@ import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"

	"github.com/shurcooL/httperror"
	"github.com/shurcooL/users"
@@ -141,5 +142,20 @@ func (rw *responseWriterBytes) Write(p []byte) (n int, err error) {
	if len(p) > 0 {
		rw.WroteBytes = true
	}
	return rw.ResponseWriter.Write(p)
}

// 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
}