| // Copyright 2017 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package main | |
| import ( | |
| "compress/gzip" | |
| "context" | |
| "fmt" | |
| "html/template" | |
| "log" | |
| "math/rand" | |
| "net/http" | |
| "path" | |
| "strconv" | |
| "strings" | |
| "sync" | |
| "time" | |
| "golang.org/x/build/devapp/owners" | |
| "golang.org/x/build/maintner" | |
| "golang.org/x/build/maintner/godata" | |
| ) | |
| // A server is an http.Handler that serves content within staticDir at root and | |
| // the dynamically-generated dashboards at their respective endpoints. | |
| type server struct { | |
| mux *http.ServeMux | |
| staticDir string | |
| templateDir string | |
| reloadTmpls bool | |
| cMu sync.RWMutex // Used to protect the fields below. | |
| corpus *maintner.Corpus | |
| repo *maintner.GitHubRepo // The golang/go repo. | |
| proj *maintner.GerritProject // The go.googlesource.com/go project. | |
| helpWantedIssues []issueData | |
| data pageData | |
| // GopherCon-specific fields. Must still hold cMu when reading/writing these. | |
| userMapping map[int]*maintner.GitHubUser // Gerrit Owner ID => GitHub user | |
| activities []activity // All contribution activities | |
| totalPoints int | |
| } | |
| type issueData struct { | |
| id int32 | |
| titlePrefix string | |
| } | |
| type pageData struct { | |
| release releaseData | |
| reviews reviewsData | |
| stats statsData | |
| } | |
| func newServer(mux *http.ServeMux, staticDir, templateDir string, reloadTmpls bool) *server { | |
| s := &server{ | |
| mux: mux, | |
| staticDir: staticDir, | |
| templateDir: templateDir, | |
| reloadTmpls: reloadTmpls, | |
| userMapping: map[int]*maintner.GitHubUser{}, | |
| } | |
| s.mux.Handle("/", http.FileServer(http.Dir(s.staticDir))) | |
| s.mux.HandleFunc("/favicon.ico", s.handleFavicon) | |
| s.mux.HandleFunc("/release", s.withTemplate("/release.tmpl", s.handleRelease)) | |
| s.mux.HandleFunc("/reviews", s.withTemplate("/reviews.tmpl", s.handleReviews)) | |
| s.mux.HandleFunc("/stats", s.withTemplate("/stats.tmpl", s.handleStats)) | |
| s.mux.HandleFunc("/dir/", handleDirRedirect) | |
| s.mux.HandleFunc("/owners", owners.Handler) | |
| s.mux.Handle("/owners/", http.RedirectHandler("/owners", http.StatusPermanentRedirect)) // TODO: remove after clients updated to use URL without trailing slash | |
| for _, p := range []string{"/imfeelinghelpful", "/imfeelinglucky"} { | |
| s.mux.HandleFunc(p, s.handleRandomHelpWantedIssue) | |
| } | |
| s.mux.HandleFunc("/_/activities", s.handleActivities) | |
| return s | |
| } | |
| func (s *server) withTemplate(tmpl string, fn func(*template.Template, http.ResponseWriter, *http.Request)) http.HandlerFunc { | |
| t := template.Must(template.ParseFiles(path.Join(s.templateDir, tmpl))) | |
| return func(w http.ResponseWriter, r *http.Request) { | |
| if s.reloadTmpls { | |
| t = template.Must(template.ParseFiles(path.Join(s.templateDir, tmpl))) | |
| } | |
| fn(t, w, r) | |
| } | |
| } | |
| // initCorpus fetches a full maintner corpus, overwriting any existing data. | |
| func (s *server) initCorpus(ctx context.Context) error { | |
| s.cMu.Lock() | |
| defer s.cMu.Unlock() | |
| corpus, err := godata.Get(ctx) | |
| if err != nil { | |
| return fmt.Errorf("godata.Get: %v", err) | |
| } | |
| s.corpus = corpus | |
| s.repo = s.corpus.GitHub().Repo("golang", "go") | |
| if s.repo == nil { | |
| return fmt.Errorf(`s.corpus.GitHub().Repo("golang", "go") = nil`) | |
| } | |
| s.proj = s.corpus.Gerrit().Project("go.googlesource.com", "go") | |
| if s.proj == nil { | |
| return fmt.Errorf(`s.corpus.Gerrit().Project("go.googlesource.com", "go") = nil`) | |
| } | |
| return nil | |
| } | |
| // corpusUpdateLoop continuously updates the server’s corpus until ctx’s Done | |
| // channel is closed. | |
| func (s *server) corpusUpdateLoop(ctx context.Context) { | |
| log.Println("Starting corpus update loop ...") | |
| for { | |
| log.Println("Updating help wanted issues ...") | |
| s.updateHelpWantedIssues() | |
| log.Println("Updating activities ...") | |
| s.updateActivities() | |
| s.cMu.Lock() | |
| s.data.release.dirty = true | |
| s.data.reviews.dirty = true | |
| s.data.stats.dirty = true | |
| s.cMu.Unlock() | |
| err := s.corpus.UpdateWithLocker(ctx, &s.cMu) | |
| if err != nil { | |
| if err == maintner.ErrSplit { | |
| log.Println("Corpus out of sync. Re-fetching corpus.") | |
| s.initCorpus(ctx) | |
| } else { | |
| log.Printf("corpus.Update: %v; sleeping 15s", err) | |
| time.Sleep(15 * time.Second) | |
| continue | |
| } | |
| } | |
| select { | |
| case <-ctx.Done(): | |
| return | |
| default: | |
| continue | |
| } | |
| } | |
| } | |
| const ( | |
| issuesURLBase = "https://golang.org/issue/" | |
| labelHelpWantedID = 150880243 | |
| ) | |
| func (s *server) updateHelpWantedIssues() { | |
| s.cMu.Lock() | |
| defer s.cMu.Unlock() | |
| var issues []issueData | |
| s.repo.ForeachIssue(func(i *maintner.GitHubIssue) error { | |
| if i.Closed { | |
| return nil | |
| } | |
| if i.HasLabelID(labelHelpWantedID) { | |
| prefix, _, _ := strings.Cut(i.Title, ":") | |
| issues = append(issues, issueData{id: i.Number, titlePrefix: prefix}) | |
| } | |
| return nil | |
| }) | |
| s.helpWantedIssues = issues | |
| } | |
| func (s *server) handleRandomHelpWantedIssue(w http.ResponseWriter, r *http.Request) { | |
| s.cMu.RLock() | |
| defer s.cMu.RUnlock() | |
| if len(s.helpWantedIssues) == 0 { | |
| http.Redirect(w, r, issuesURLBase, http.StatusSeeOther) | |
| return | |
| } | |
| pkgs := r.URL.Query().Get("pkg") | |
| var rid int32 | |
| if pkgs == "" { | |
| rid = s.helpWantedIssues[rand.Intn(len(s.helpWantedIssues))].id | |
| } else { | |
| filtered := s.filteredHelpWantedIssues(strings.Split(pkgs, ",")...) | |
| if len(filtered) == 0 { | |
| http.Redirect(w, r, issuesURLBase, http.StatusSeeOther) | |
| return | |
| } | |
| rid = filtered[rand.Intn(len(filtered))].id | |
| } | |
| http.Redirect(w, r, issuesURLBase+strconv.Itoa(int(rid)), http.StatusSeeOther) | |
| } | |
| func (s *server) filteredHelpWantedIssues(pkgs ...string) []issueData { | |
| var issues []issueData | |
| for _, i := range s.helpWantedIssues { | |
| for _, p := range pkgs { | |
| if strings.HasPrefix(i.titlePrefix, p) { | |
| issues = append(issues, i) | |
| break | |
| } | |
| } | |
| } | |
| return issues | |
| } | |
| func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) { | |
| // Need to specify content type for consistent tests, without this it's | |
| // determined from mime.types on the box the test is running on | |
| w.Header().Set("Content-Type", "image/x-icon") | |
| http.ServeFile(w, r, path.Join(s.staticDir, "/favicon.ico")) | |
| } | |
| // ServeHTTP satisfies the http.Handler interface. | |
| func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| if r.TLS != nil { | |
| w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload") | |
| } | |
| if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { | |
| w.Header().Set("Content-Encoding", "gzip") | |
| gz := gzip.NewWriter(w) | |
| defer gz.Close() | |
| gzw := &gzipResponseWriter{Writer: gz, ResponseWriter: w} | |
| s.mux.ServeHTTP(gzw, r) | |
| return | |
| } | |
| s.mux.ServeHTTP(w, r) | |
| } | |
| // handleDirRedirect accepts requests of the form: | |
| // | |
| // /dir/REPO/some/dir/ | |
| // | |
| // And redirects them to either: | |
| // | |
| // https://github.com/golang/REPO/tree/master/some/dir/ | |
| // | |
| // or: | |
| // | |
| // https://go.googlesource.com/REPO/+/master/some/dir/ | |
| // | |
| // ... depending on the Referer. This is so we can make links | |
| // in Markdown docs that are clickable on both GitHub and | |
| // in the go.googlesource.com viewer. If detection fails, we | |
| // default to GitHub. | |
| func handleDirRedirect(w http.ResponseWriter, r *http.Request) { | |
| useGoog := strings.Contains(r.Referer(), "googlesource.com") | |
| path := r.URL.Path | |
| if !strings.HasPrefix(path, "/dir/") { | |
| http.Error(w, "bad mux", http.StatusInternalServerError) | |
| return | |
| } | |
| path = strings.TrimPrefix(path, "/dir/") | |
| // path is now "REPO/some/dir/" | |
| var repo string | |
| slash := strings.IndexByte(path, '/') | |
| if slash == -1 { | |
| repo, path = path, "" | |
| } else { | |
| repo, path = path[:slash], path[slash+1:] | |
| } | |
| path = strings.TrimSuffix(path, "/") | |
| var target string | |
| if useGoog { | |
| target = fmt.Sprintf("https://go.googlesource.com/%s/+/master/%s", repo, path) | |
| } else { | |
| target = fmt.Sprintf("https://github.com/golang/%s/tree/master/%s", repo, path) | |
| } | |
| http.Redirect(w, r, target, http.StatusFound) | |
| } |