@@ -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 {
@@ -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" )
@@ -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") } }
@@ -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 }
@@ -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{
@@ -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" }
@@ -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 }