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