dmitri.shuralyov.com/website/gido/...

add support for import path patterns

This change adds support for displaying issues/changes not just for
a single package at a time, but an arbitrary amount of packages that
match the specified import path pattern.

Reserved names "all", "std", and "cmd" are supported, and expand as
expected.
dmitshur committed 3 years ago commit b24489fd4afe39909a4c80487cc711ef50c496d7
changes.go
@@ -35,20 +35,24 @@ var changesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html

			<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>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).
			You may specify an
			<a href="https://golang.org/cmd/go/#hdr-Package_lists_and_patterns">import path pattern</a>
			to view changes for all matching packages
			(e.g., <a href="/image/..."><code>gochanges.org/image/...</code></a>).</p>

			<p>Supported import paths include:</p>
			<p>Supported packages 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>Third 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"}}
@@ -61,12 +65,12 @@ var changesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html
		</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 {
// serveChangesPkg serves a list of changes for the package with import path pkg.
func (h *handler) serveChangesPkg(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 {
@@ -84,11 +88,11 @@ func (h *handler) serveChanges(w http.ResponseWriter, req *http.Request, pkg str
	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.
		cs = append(ic.OpenChanges, ic.ClosedChanges...)
		sort.Slice(cs, func(i, j int) bool { return cs[i].ID > cs[j].ID })
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
@@ -97,22 +101,106 @@ func (h *handler) serveChanges(w http.ResponseWriter, req *http.Request, pkg str
	})
	if err != nil {
		return err
	}
	err = htmlg.RenderComponents(w,
		heading{Pkg: pkg},
		subheading{Pkg: pkg},
		heading{PkgOrPattern: pkg},
		subheadingPkg{Pkg: pkg},
		renderTabnav(changesTab, len(ic.OpenIssues), len(ic.OpenChanges), pkg, h.rtr),
		renderChanges(cs, len(ic.OpenChanges), len(ic.ClosedChanges), req.URL, filter),
	)
	if err != nil {
		return err
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// serveChangesPattern serves a list of changes for packages matching import path pattern.
func (h *handler) serveChangesPattern(w http.ResponseWriter, req *http.Request, pattern 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}
	}

	var pkgs []string
	switch pattern {
	case "all", "...":
		// "all" expands to all packages found in all the GOPATH trees.
		pkgs = h.s.AllPackages
	case "std":
		// "std" is like all but expands to just the packages in the standard Go library.
		pkgs = h.s.StdPackages
	case "cmd":
		// "cmd" expands to the Go repository's commands and their internal libraries.
		pkgs = h.s.CmdPackages
	default:
		pkgs = expandPattern(h.s.AllPackages, pattern)
	}

	ics := make(map[string]*Directory) // Import path -> Directory.
	h.s.IssuesAndChangesMu.RLock()
	for _, p := range pkgs {
		if ic, ok := h.s.IssuesAndChanges[p]; ok {
			ics[p] = ic
		}
	}
	h.s.IssuesAndChangesMu.RUnlock()
	var cs []change.Change
	var openChanges, closedChanges, openIssues int
	for p, ic := range ics {
		switch {
		case filter == change.FilterOpen:
			for _, c := range ic.OpenChanges {
				c.Title = ImportPathToFullPrefix(p) + c.Title
				cs = append(cs, c)
			}
		case filter == change.FilterClosedMerged:
			for _, c := range ic.ClosedChanges {
				c.Title = ImportPathToFullPrefix(p) + c.Title
				cs = append(cs, c)
			}
		case filter == change.FilterAll:
			for _, c := range ic.OpenChanges {
				c.Title = ImportPathToFullPrefix(p) + c.Title
				cs = append(cs, c)
			}
			for _, c := range ic.ClosedChanges {
				c.Title = ImportPathToFullPrefix(p) + c.Title
				cs = append(cs, c)
			}
		}
		openChanges += len(ic.OpenChanges)
		closedChanges += len(ic.ClosedChanges)
		openIssues += len(ic.OpenIssues)
	}
	sort.Slice(cs, func(i, j int) bool { return cs[i].CreatedAt.After(cs[j].CreatedAt) })

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pattern,
		"AnalyticsHTML": h.analyticsHTML,
	})
	if err != nil {
		return err
	}
	err = htmlg.RenderComponents(w,
		heading{PkgOrPattern: pattern},
		subheadingPattern{Pattern: pattern, Pkgs: pkgs, pkgURL: h.rtr.ChangesURL},
		renderTabnav(changesTab, openIssues, openChanges, pattern, h.rtr),
		renderChanges(cs, openChanges, closedChanges, req.URL, filter),
	)
	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 {
issues.go
@@ -35,20 +35,24 @@ var issuesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html

			<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>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).
			You may specify an
			<a href="https://golang.org/cmd/go/#hdr-Package_lists_and_patterns">import path pattern</a>
			to view issues for all matching packages
			(e.g., <a href="/image/..."><code>goissues.org/image/...</code></a>).</p>

			<p>Supported import paths include:</p>
			<p>Supported packages 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>Third 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"}}
@@ -61,12 +65,12 @@ var issuesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html
		</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 {
// serveIssuesPkg serves a list of issues for the package with import path pkg.
func (h *handler) serveIssuesPkg(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 {
@@ -84,11 +88,11 @@ func (h *handler) serveIssues(w http.ResponseWriter, req *http.Request, pkg stri
	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.
		is = append(ic.OpenIssues, ic.ClosedIssues...)
		sort.Slice(is, func(i, j int) bool { return is[i].ID > is[j].ID })
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
@@ -97,12 +101,12 @@ func (h *handler) serveIssues(w http.ResponseWriter, req *http.Request, pkg stri
	})
	if err != nil {
		return err
	}
	err = htmlg.RenderComponents(w,
		heading{Pkg: pkg},
		subheading{Pkg: pkg},
		heading{PkgOrPattern: pkg},
		subheadingPkg{Pkg: pkg},
		renderTabnav(issuesTab, len(ic.OpenIssues), len(ic.OpenChanges), pkg, h.rtr),
		renderNewIssue(pkg),
		renderIssues(is, len(ic.OpenIssues), len(ic.ClosedIssues), req.URL, filter),
	)
	if err != nil {
@@ -110,10 +114,94 @@ func (h *handler) serveIssues(w http.ResponseWriter, req *http.Request, pkg stri
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// serveIssuesPattern serves a list of issues for packages matching import path pattern.
func (h *handler) serveIssuesPattern(w http.ResponseWriter, req *http.Request, pattern 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}
	}

	var pkgs []string
	switch pattern {
	case "all", "...":
		// "all" expands to all packages found in all the GOPATH trees.
		pkgs = h.s.AllPackages
	case "std":
		// "std" is like all but expands to just the packages in the standard Go library.
		pkgs = h.s.StdPackages
	case "cmd":
		// "cmd" expands to the Go repository's commands and their internal libraries.
		pkgs = h.s.CmdPackages
	default:
		pkgs = expandPattern(h.s.AllPackages, pattern)
	}

	ics := make(map[string]*Directory) // Import path -> Directory.
	h.s.IssuesAndChangesMu.RLock()
	for _, p := range pkgs {
		if ic, ok := h.s.IssuesAndChanges[p]; ok {
			ics[p] = ic
		}
	}
	h.s.IssuesAndChangesMu.RUnlock()
	var is []issues.Issue
	var openIssues, closedIssues, openChanges int
	for p, ic := range ics {
		switch {
		case filter == issues.StateFilter(issues.OpenState):
			for _, i := range ic.OpenIssues {
				i.Title = ImportPathToFullPrefix(p) + i.Title
				is = append(is, i)
			}
		case filter == issues.StateFilter(issues.ClosedState):
			for _, i := range ic.ClosedIssues {
				i.Title = ImportPathToFullPrefix(p) + i.Title
				is = append(is, i)
			}
		case filter == issues.AllStates:
			for _, i := range ic.OpenIssues {
				i.Title = ImportPathToFullPrefix(p) + i.Title
				is = append(is, i)
			}
			for _, i := range ic.ClosedIssues {
				i.Title = ImportPathToFullPrefix(p) + i.Title
				is = append(is, i)
			}
		}
		openIssues += len(ic.OpenIssues)
		closedIssues += len(ic.ClosedIssues)
		openChanges += len(ic.OpenChanges)
	}
	sort.Slice(is, func(i, j int) bool { return is[i].CreatedAt.After(is[j].CreatedAt) })

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pattern,
		"AnalyticsHTML": h.analyticsHTML,
	})
	if err != nil {
		return err
	}
	err = htmlg.RenderComponents(w,
		heading{PkgOrPattern: pattern},
		subheadingPattern{Pattern: pattern, Pkgs: pkgs, pkgURL: h.rtr.IssuesURL},
		renderTabnav(issuesTab, openIssues, openChanges, pattern, h.rtr),
		renderIssues(is, openIssues, closedIssues, req.URL, filter),
	)
	if err != nil {
		return err
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

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

main.go
@@ -111,10 +111,19 @@ type handler struct {
	assetsHandler http.Handler
	s             *service
}

func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
	// Redirect to canonical path (no trailing slash, etc.) if needed.
	if canonicalPath := path.Clean(req.URL.Path); req.URL.Path != canonicalPath {
		if req.URL.RawQuery != "" {
			canonicalPath += "?" + req.URL.RawQuery
		}
		http.Redirect(w, req, canonicalPath, http.StatusFound)
		return nil
	}

	// Handle "/".
	if req.URL.Path == "/" {
		return h.ServeIndex(w, req)
	}

@@ -135,20 +144,26 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
	if req.URL.Path == "/-/packages" {
		return h.ServePackages(w, req)
	}

	// Handle "/..." URLs.
	if canonicalPath := path.Clean(req.URL.Path); req.URL.Path != canonicalPath {
		// Redirect to canonical path (no trailing slash, etc.).
		if req.URL.RawQuery != "" {
			canonicalPath += "?" + req.URL.RawQuery
		}
		http.Redirect(w, req, canonicalPath, http.StatusFound)
		return nil
	switch isImportPathPattern(req.URL.Path[1:]) {
	case false:
		pkg := req.URL.Path[1:]
		return h.ServeIssuesOrChangesPkg(w, req, pkg)
	case true:
		pattern := req.URL.Path[1:]
		return h.ServeIssuesOrChangesPattern(w, req, pattern)
	default:
		panic("unreachable")
	}
	pkg := req.URL.Path[1:]
	return h.ServeIssuesOrChanges(w, req, pkg)
}

// isImportPathPattern reports whether path p is an import path pattern.
func isImportPathPattern(p string) bool {
	return p == "all" || p == "std" || p == "cmd" ||
		strings.Contains(p, "...")
}

// ServeIndex serves the index page.
func (h *handler) ServeIndex(w http.ResponseWriter, req *http.Request) error {
	if req.Method != http.MethodGet {
@@ -177,11 +192,11 @@ func (h *handler) ServeIndex(w http.ResponseWriter, req *http.Request) error {
	// Find some popular packages to display.
	h.s.IssuesAndChangesMu.RLock()
	ics := h.s.IssuesAndChanges
	h.s.IssuesAndChangesMu.RUnlock()
	var popular []pkg
	for _, p := range h.s.Packages {
	for _, p := range h.s.AllPackages {
		popular = append(popular, pkg{
			Path:        p,
			OpenIssues:  len(ics[p].OpenIssues),
			OpenChanges: len(ics[p].OpenChanges),
		})
@@ -210,11 +225,11 @@ func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error
	// Gather all packages in sorted order.
	h.s.IssuesAndChangesMu.RLock()
	ics := h.s.IssuesAndChanges
	h.s.IssuesAndChangesMu.RUnlock()
	var stdlib, subrepo []pkg
	for _, p := range h.s.Packages {
	for _, p := range h.s.AllPackages {
		switch isStandard(p) {
		case true:
			stdlib = append(stdlib, pkg{
				Path:        p,
				OpenIssues:  len(ics[p].OpenIssues),
@@ -231,11 +246,11 @@ func (h *handler) ServePackages(w http.ResponseWriter, req *http.Request) error

	if req.Header.Get("Accept") == "application/json" {
		w.Header().Set("Content-Type", "application/json")
		e := json.NewEncoder(w)
		e.SetIndent("", "\t")
		err := e.Encode(append(stdlib, subrepo...)) // TODO: Measure if slow, optimize if needed.
		err := e.Encode(append(stdlib, subrepo...))
		return err
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err := h.executeTemplate(w, req, "Header", map[string]interface{}{
@@ -315,17 +330,29 @@ func renderTable(w io.Writer, pkgs []pkg) error {
	_, err = io.WriteString(w, `</tbody>
			</table>`)
	return err
}

// 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 {
// ServeIssuesOrChangesPkg serves a list of issues or changes for the package with import path pkg.
func (h *handler) ServeIssuesOrChangesPkg(w http.ResponseWriter, req *http.Request, pkg string) error {
	switch changes := h.rtr.WantChanges(req); {
	case !changes:
		return h.serveIssuesPkg(w, req, pkg)
	case changes:
		return h.serveChangesPkg(w, req, pkg)
	default:
		panic("unreachable")
	}
}

// ServeIssuesOrChangesPattern serves a list of issues or changes for packages matching import path pattern.
func (h *handler) ServeIssuesOrChangesPattern(w http.ResponseWriter, req *http.Request, pattern string) error {
	switch changes := h.rtr.WantChanges(req); {
	case !changes:
		return h.serveIssues(w, req, pkg)
		return h.serveIssuesPattern(w, req, pattern)
	case changes:
		return h.serveChanges(w, req, pkg)
		return h.serveChangesPattern(w, req, pattern)
	default:
		panic("unreachable")
	}
}

packages.go
@@ -1,7 +1,12 @@
package main

import (
	"regexp"
	"strings"
)

// TODO: Consider including directories from GOROOT other than just packages in GOROOT/src.
// TODO: Consider including special prefixes such as "all:", "build:", "gccgo:", "website:", "wiki:", etc.

// existingPackages is a set of import paths of Go packages that are known to exist.
// It includes packages in Go standard library and sub-repositories.
@@ -968,5 +973,39 @@ var existingPackages = map[string]struct{}{
	"golang.org/x/tour/reader":                                        {},
	"golang.org/x/tour/tree":                                          {},
	"golang.org/x/tour/wc":                                            {},
	"golang.org/x/vgo":                                                {},
}

// expandPattern returns a list of Go packages matched by specified
// import path pattern, which may have the following forms:
//
// 	example.org/single/package     # a single package
// 	example.org/dir/...            # all packages beneath dir
// 	example.org/.../tools/...      # all matching packages
// 	...                            # the entire workspace
//
// A trailing slash in a pattern is ignored.
func expandPattern(allPackages []string, pattern string) []string {
	match := matchPattern(pattern)
	var matched []string
	for _, pkg := range allPackages {
		if !match(pkg) {
			continue
		}
		matched = append(matched, pkg)
	}
	return matched
}

// matchPattern(pattern)(name) reports whether name matches pattern.
// Pattern is a limited glob pattern in which '...' means 'any string',
// foo/... matches foo too, and there is no other special syntax.
func matchPattern(pattern string) func(name string) bool {
	re := regexp.QuoteMeta(pattern)
	re = strings.Replace(re, `\.\.\.`, `.*`, -1)
	// Special case: foo/... matches foo too.
	if strings.HasSuffix(re, `/.*`) {
		re = re[:len(re)-len(`/.*`)] + `(/.*)?`
	}
	return regexp.MustCompile(`^` + re + `$`).MatchString
}
page.go
@@ -1,8 +1,9 @@
package main

import (
	"fmt"
	"net/url"

	changescomponent "dmitri.shuralyov.com/app/changes/component"
	"dmitri.shuralyov.com/service/change"
	"github.com/shurcooL/htmlg"
@@ -11,19 +12,19 @@ import (
	"github.com/shurcooL/octicon"
	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

type heading struct{ Pkg string }
type heading struct{ PkgOrPattern string }

func (h heading) Render() []*html.Node {
	switch h.Pkg {
	switch h.PkgOrPattern {
	default:
		h2 := &html.Node{
			Type: html.ElementNode, Data: atom.H2.String(),
			Attr:       []html.Attribute{{Key: atom.Style.String(), Val: "margin-top: 30px;"}},
			FirstChild: htmlg.Text(h.Pkg),
			FirstChild: htmlg.Text(h.PkgOrPattern),
		}
		return []*html.Node{h2}
	case otherPackages:
		h3 := &html.Node{
			Type: html.ElementNode, Data: atom.H3.String(),
@@ -32,21 +33,52 @@ func (h heading) Render() []*html.Node {
		}
		return []*html.Node{h3}
	}
}

type subheading struct{ Pkg string }
type subheadingPkg struct{ Pkg string }

func (s subheading) Render() []*html.Node {
func (s subheadingPkg) 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
	}
}

type subheadingPattern struct {
	Pattern string
	Pkgs    []string

	// pkgURL returns the URL of the page for package pkg.
	pkgURL func(pkg string) (url string)
}

func (s subheadingPattern) Render() []*html.Node {
	if len(s.Pkgs) == 0 {
		return []*html.Node{htmlg.P(htmlg.Text(fmt.Sprintf("warning: %q matched no packages", s.Pattern)))}
	}
	const maxPkgs = 10
	var lis []*html.Node
	switch {
	default:
		for _, p := range s.Pkgs {
			lis = append(lis, htmlg.LI(htmlg.A(p, s.pkgURL(p))))
		}
	case len(s.Pkgs) > maxPkgs:
		for _, p := range s.Pkgs[:maxPkgs] {
			lis = append(lis, htmlg.LI(htmlg.A(p, s.pkgURL(p))))
		}
		lis = append(lis, htmlg.LI(htmlg.Text(fmt.Sprintf("... (%d more)", len(s.Pkgs)-maxPkgs))))
	}
	return []*html.Node{
		htmlg.P(htmlg.Text(fmt.Sprintf("Issues and changes for %d package(s) matching %q:", len(s.Pkgs), s.Pattern))),
		htmlg.UL(lis...),
	}
}

func renderTabnav(selected pageTab, openIssues, openChanges int, pattern string, rtr Router) htmlg.Component {
	return tabnav{
		Tabs: []tab{
			{
				Content: contentCounter{
route.go
@@ -9,31 +9,33 @@ import (
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
	// IssuesURL returns the URL of the issues page for the specified
	// package or import path pattern.
	IssuesURL(pkgOrPattern string) string

	// ChangesURL returns the URL of the changes page for package pkg.
	ChangesURL(pkg string) string
	// ChangesURL returns the URL of the changes page for the specified
	// package or import path pattern.
	ChangesURL(pkgOrPattern 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) IssuesURL(pkgOrPattern string) string {
	return "//goissues.org/" + pkgOrPattern
}

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

// devRouter provides routing system for local development.
// Pages for issues/changes are selected based on ?changes=1 query parameter.
type devRouter struct{}
@@ -41,12 +43,12 @@ 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) IssuesURL(pkgOrPattern string) string {
	return "/" + pkgOrPattern
}

func (devRouter) ChangesURL(pkg string) string {
	return "/" + pkg + "?changes=1"
func (devRouter) ChangesURL(pkgOrPattern string) string {
	return "/" + pkgOrPattern + "?changes=1"
}
service.go
@@ -22,40 +22,51 @@ type service struct {
	// An additional entry with key otherPackages is for issues and changes that don't fit
	// into any existing Go package.
	IssuesAndChangesMu sync.RWMutex
	IssuesAndChanges   map[string]*Directory

	// Packages is a list of all packages. Sorted by import path, standard library first.
	Packages []string
	AllPackages []string // All packages. Sorted by import path, standard library first.
	StdPackages []string // Packages in the standard Go library.
	CmdPackages []string // Go repository's commands and their internal libraries.
}

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

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

	// Initialize list of packages sorted by import path, standard library first.
	var packages []string
	// Initialize lists of packages.
	var all, std, cmd []string
	for p := range issuesAndChanges {
		if p == otherPackages { // Don't include "other", it's not a real package.
			continue
		}
		packages = append(packages, p)
		all = append(all, p)
		if isStandard(p) {
			std = append(std, p)
			if p == "cmd" || strings.HasPrefix(p, "cmd/") {
				cmd = append(cmd, p)
			}
		}
	}
	sort.Slice(packages, func(i, j int) bool {
		if a, b := category(packages[i]), category(packages[j]); a != b {
	sort.Slice(all, func(i, j int) bool {
		if a, b := category(all[i]), category(all[j]); a != b {
			return a < b
		}
		return packages[i] < packages[j]
		return all[i] < all[j]
	})
	sort.Strings(std)
	sort.Strings(cmd)

	s := &service{
		IssuesAndChanges: issuesAndChanges,
		Packages:         packages,
		AllPackages:      all,
		StdPackages:      std,
		CmdPackages:      cmd,
	}
	go s.poll(ctx)
	return s
}