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 8 months 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
 }