diff --git a/cmd/devapp/main.go b/cmd/devapp/main.go new file mode 100644 index 0000000000..0f41a480fc --- /dev/null +++ b/cmd/devapp/main.go @@ -0,0 +1,48 @@ +// 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. + +// Devapp generates the dashboard that powers dev.golang.org. +// +// Usage: +// +// devapp --port=8081 +// +// By default devapp listens on port 8081. +// +// Github issues and Gerrit CL's are stored in memory in the running process. +// To trigger an initial download, visit http://localhost:8081/update or +// http://localhost:8081/update/stats in your browser. +package main + +import ( + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + + _ "golang.org/x/build/devapp" +) + +func init() { + flag.Usage = func() { + os.Stderr.WriteString(`usage: devapp [-port=port] + +Devapp generates the dashboard that powers dev.golang.org. +`) + os.Exit(2) + } +} + +func main() { + port := flag.Uint("port", 8081, "Port to listen on") + flag.Parse() + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", *port)) + if err != nil { + log.Fatal(err) + } + fmt.Fprintf(os.Stderr, "Listening on port %d\n", *port) + log.Fatal(http.Serve(ln, nil)) +} diff --git a/devapp/appengine.go b/devapp/appengine.go new file mode 100644 index 0000000000..c985591b0d --- /dev/null +++ b/devapp/appengine.go @@ -0,0 +1,142 @@ +// Copyright 2015 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. + +// +build appengine + +package devapp + +import ( + "net/http" + "os" + + "appengine" + + "golang.org/x/build/godash" + "golang.org/x/net/context" + "google.golang.org/appengine/datastore" + applog "google.golang.org/appengine/log" + "google.golang.org/appengine/urlfetch" + "google.golang.org/appengine/user" +) + +func init() { + onAppengine = !appengine.IsDevAppServer() + log = &appengineLogger{} + + http.HandleFunc("/setToken", setTokenHandler) +} + +type appengineLogger struct{} + +func (a *appengineLogger) Infof(ctx context.Context, format string, args ...interface{}) { + applog.Infof(ctx, format, args...) +} + +func (a *appengineLogger) Errorf(ctx context.Context, format string, args ...interface{}) { + applog.Errorf(ctx, format, args...) +} + +func (a *appengineLogger) Criticalf(ctx context.Context, format string, args ...interface{}) { + applog.Criticalf(ctx, format, args...) +} + +func newTransport(ctx context.Context) http.RoundTripper { + return &urlfetch.Transport{Context: ctx} +} + +func currentUserEmail(ctx context.Context) string { + u := user.Current(ctx) + if u == nil { + return "" + } + return u.Email +} + +// loginURL returns a URL that, when visited, prompts the user to sign in, +// then redirects the user to the URL specified by dest. +func loginURL(ctx context.Context, path string) (string, error) { + return user.LoginURL(ctx, path) +} + +func logoutURL(ctx context.Context, path string) (string, error) { + return user.LogoutURL(ctx, path) +} + +func loadData(ctx context.Context) (*godash.Data, error) { + os.Stderr.WriteString("appengine load data") + cache, err := getCache(ctx, "gzdata") + if err != nil { + return nil, err + } + return parseData(cache) +} + +func newHTTPClient(ctx context.Context) *http.Client { + return urlfetch.Client(ctx) +} + +func getCache(ctx context.Context, name string) (*Cache, error) { + var cache Cache + if err := datastore.Get(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", name, 0, nil), &cache); err != nil { + return &cache, err + } + return &cache, nil +} + +func getCaches(ctx context.Context, names ...string) map[string]*Cache { + out := make(map[string]*Cache) + var keys []*datastore.Key + var ptrs []*Cache + for _, name := range names { + keys = append(keys, datastore.NewKey(ctx, entityPrefix+"Cache", name, 0, nil)) + out[name] = &Cache{} + ptrs = append(ptrs, out[name]) + } + datastore.GetMulti(ctx, keys, ptrs) // Ignore errors since they might not exist. + return out +} + +func getPage(ctx context.Context, page string) (*Page, error) { + entity := new(Page) + err := datastore.Get(ctx, datastore.NewKey(ctx, entityPrefix+"Page", page, 0, nil), entity) + return entity, err +} + +func writePage(ctx context.Context, page string, content []byte) error { + entity := &Page{ + Content: content, + } + _, err := datastore.Put(ctx, datastore.NewKey(ctx, entityPrefix+"Page", page, 0, nil), entity) + return err +} + +func putCache(ctx context.Context, name string, c *Cache) error { + _, err := datastore.Put(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", name, 0, nil), &c) + return err +} + +func getToken(ctx context.Context) (string, error) { + cache, err := getCache(ctx, "github-token") + if err != nil { + return "", err + } + return cache.Value, nil +} + +// Store a token in the database +func setTokenHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + r.ParseForm() + if value := r.Form.Get("value"); value != "" { + var token Cache + token.Value = []byte(value) + if err := putCache(ctx, "github-token", &token); err != nil { + http.Error(w, err.Error(), 500) + } + } +} + +func getContext(r *http.Request) context.Context { + return appengine.NewContext(r) +} diff --git a/devapp/cache.go b/devapp/cache.go index dfbd5d5151..2bfd49a0bd 100644 --- a/devapp/cache.go +++ b/devapp/cache.go @@ -10,8 +10,6 @@ import ( "encoding/gob" "golang.org/x/net/context" - "google.golang.org/appengine/datastore" - "google.golang.org/appengine/log" ) // Cache is a datastore entity type that contains serialized data for dashboards. @@ -22,27 +20,6 @@ type Cache struct { Value []byte } -func getCaches(ctx context.Context, names ...string) map[string]*Cache { - out := make(map[string]*Cache) - var keys []*datastore.Key - var ptrs []*Cache - for _, name := range names { - keys = append(keys, datastore.NewKey(ctx, entityPrefix+"Cache", name, 0, nil)) - out[name] = &Cache{} - ptrs = append(ptrs, out[name]) - } - datastore.GetMulti(ctx, keys, ptrs) // Ignore errors since they might not exist. - return out -} - -func getCache(ctx context.Context, name string) (*Cache, error) { - var cache Cache - if err := datastore.Get(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", name, 0, nil), &cache); err != nil { - return nil, err - } - return &cache, nil -} - func unpackCache(cache *Cache, data interface{}) error { if len(cache.Value) > 0 { gzr, err := gzip.NewReader(bytes.NewReader(cache.Value)) @@ -78,8 +55,5 @@ func writeCache(ctx context.Context, name string, data interface{}) error { } cache.Value = cacheout.Bytes() log.Infof(ctx, "Cache %q update finished; writing %d bytes", name, cacheout.Len()) - if _, err := datastore.Put(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", name, 0, nil), &cache); err != nil { - return err - } - return nil + return putCache(ctx, name, &cache) } diff --git a/devapp/dash.go b/devapp/dash.go index db41500b50..fdaf067fc7 100644 --- a/devapp/dash.go +++ b/devapp/dash.go @@ -16,16 +16,23 @@ import ( "github.com/google/go-github/github" "golang.org/x/build/godash" "golang.org/x/net/context" - "google.golang.org/appengine" - "google.golang.org/appengine/log" - "google.golang.org/appengine/user" ) +var onAppengine = false + +type logger interface { + Infof(context.Context, string, ...interface{}) + Errorf(context.Context, string, ...interface{}) + Criticalf(context.Context, string, ...interface{}) +} + +var log logger + func findEmail(ctx context.Context, data *godash.Data) string { - u := user.Current(ctx) + email := currentUserEmail(ctx) - if u != nil { - return data.Reviewers.Preferred(u.Email) + if email != "" { + return data.Reviewers.Preferred(email) } return "" } @@ -67,6 +74,14 @@ func (x byDate) Less(i, j int) bool { return a.Before(*b) } +func loadData(ctx context.Context) (*godash.Data, error) { + cache, err := getCache(ctx, "gzdata") + if err != nil { + return nil, err + } + return parseData(cache) +} + func datedMilestones(milestones []*github.Milestone) []string { milestones = append([]*github.Milestone{}, milestones...) sort.Stable(byDate(milestones)) @@ -79,21 +94,13 @@ func datedMilestones(milestones []*github.Milestone) []string { return names } -func loadData(ctx context.Context) (*godash.Data, error) { - cache, err := getCache(ctx, "gzdata") - if err != nil { - return nil, err - } - return parseData(cache) -} - func parseData(cache *Cache) (*godash.Data, error) { data := &godash.Data{Reviewers: &godash.Reviewers{}} return data, unpackCache(cache, &data) } func showDash(w http.ResponseWriter, req *http.Request) { - ctx := appengine.NewContext(req) + ctx := getContext(req) req.ParseForm() data, err := loadData(ctx) @@ -143,11 +150,11 @@ func showDash(w http.ResponseWriter, req *http.Request) { filtered = append(filtered, group) } - login, err := user.LoginURL(ctx, "/dash") + login, err := loginURL(ctx, "/dash") if err != nil { http.Error(w, err.Error(), 500) } - logout, err := user.LogoutURL(ctx, "/dash") + logout, err := logoutURL(ctx, "/dash") if err != nil { http.Error(w, err.Error(), 500) } diff --git a/devapp/devapp.go b/devapp/devapp.go index c5f640b27a..a3b74d0de6 100644 --- a/devapp/devapp.go +++ b/devapp/devapp.go @@ -19,14 +19,12 @@ import ( "golang.org/x/build/gerrit" "golang.org/x/build/godash" "golang.org/x/net/context" - "google.golang.org/appengine" - "google.golang.org/appengine/datastore" - "google.golang.org/appengine/log" - "google.golang.org/appengine/urlfetch" ) const entityPrefix = "DevApp" +var gerritTransport http.RoundTripper + func init() { for _, page := range []string{"release", "cl"} { page := page @@ -34,7 +32,6 @@ func init() { } http.Handle("/dash", hstsHandler(showDash)) http.Handle("/update", ctxHandler(update)) - http.HandleFunc("/setToken", setTokenHandler) // Defined in stats.go http.HandleFunc("/stats/raw", rawHandler) http.HandleFunc("/stats/svg", svgHandler) @@ -53,7 +50,7 @@ func hstsHandler(fn http.HandlerFunc) http.Handler { func ctxHandler(fn func(ctx context.Context, w http.ResponseWriter, r *http.Request) error) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := appengine.NewContext(r) + ctx := getContext(r) if err := fn(ctx, w, r); err != nil { http.Error(w, err.Error(), 500) } @@ -74,34 +71,30 @@ type Page struct { } func servePage(w http.ResponseWriter, r *http.Request, page string) { - ctx := appengine.NewContext(r) - var entity Page - if err := datastore.Get(ctx, datastore.NewKey(ctx, entityPrefix+"Page", page, 0, nil), &entity); err != nil { + ctx := r.Context() + entity, err := getPage(ctx, page) + if err != nil { http.Error(w, "page not found", 404) return } - w.Header().Set("Content-type", "text/html; charset=utf-8") + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(entity.Content) } -func writePage(ctx context.Context, page string, content []byte) error { - entity := &Page{ - Content: content, - } - _, err := datastore.Put(ctx, datastore.NewKey(ctx, entityPrefix+"Page", page, 0, nil), entity) - return err -} - func update(ctx context.Context, w http.ResponseWriter, _ *http.Request) error { - caches := getCaches(ctx, "github-token", "gzdata") - gh := godash.NewGitHubClient("golang/go", string(caches["github-token"].Value), &urlfetch.Transport{Context: ctx}) + token, err := getToken(ctx) + if err != nil { + return err + } + gzdata, _ := getCache(ctx, "gzdata") + gh := godash.NewGitHubClient("golang/go", token, newTransport(ctx)) ger := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth) // Without a deadline, urlfetch will use a 5s timeout which is too slow for Gerrit. gerctx, cancel := context.WithTimeout(ctx, 9*time.Minute) defer cancel() - ger.HTTPClient = urlfetch.Client(gerctx) + ger.HTTPClient = newHTTPClient(gerctx) - data, err := parseData(caches["gzdata"]) + data, err := parseData(gzdata) if err != nil { return err } @@ -110,7 +103,7 @@ func update(ctx context.Context, w http.ResponseWriter, _ *http.Request) error { return err } l := logFn(ctx, w) - if err := data.FetchData(ctx, gh, ger, l, 7, false, false); err != nil { + if err := data.FetchData(gerctx, gh, ger, l, 7, false, false); err != nil { log.Criticalf(ctx, "failed to fetch data: %v", err) return err } @@ -139,15 +132,3 @@ func update(ctx context.Context, w http.ResponseWriter, _ *http.Request) error { } return writeCache(ctx, "gzdata", &data) } - -func setTokenHandler(w http.ResponseWriter, r *http.Request) { - ctx := appengine.NewContext(r) - r.ParseForm() - if value := r.Form.Get("value"); value != "" { - var token Cache - token.Value = []byte(value) - if _, err := datastore.Put(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", "github-token", 0, nil), &token); err != nil { - http.Error(w, err.Error(), 500) - } - } -} diff --git a/devapp/devapp_test.go b/devapp/devapp_test.go new file mode 100644 index 0000000000..43755f860f --- /dev/null +++ b/devapp/devapp_test.go @@ -0,0 +1,27 @@ +// 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 devapp + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHSTSHeaderSetDash(t *testing.T) { + req := httptest.NewRequest("GET", "/dash", nil) + w := httptest.NewRecorder() + http.DefaultServeMux.ServeHTTP(w, req) + if hdr := w.Header().Get("Strict-Transport-Security"); hdr == "" { + t.Errorf("missing Strict-Transport-Security header; headers = %v", w.Header()) + } +} + +func TestReleaseReturns(t *testing.T) { + req := httptest.NewRequest("GET", "/dash", nil) + w := httptest.NewRecorder() + http.DefaultServeMux.ServeHTTP(w, req) + // This shouldn't panic. TODO add a better assertion. +} diff --git a/devapp/noappengine.go b/devapp/noappengine.go new file mode 100644 index 0000000000..d99971b725 --- /dev/null +++ b/devapp/noappengine.go @@ -0,0 +1,167 @@ +// 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. + +// +build !appengine + +package devapp + +import ( + "errors" + "flag" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/net/context" +) + +var tokenFile = flag.String("token", "", "read GitHub token personal access token from `file` (default $HOME/.github-issue-token)") + +func init() { + log = &stderrLogger{} +} + +type stderrLogger struct{} + +func (s *stderrLogger) Infof(_ context.Context, format string, args ...interface{}) { + log.Printf(format, args...) +} + +func (s *stderrLogger) Errorf(_ context.Context, format string, args ...interface{}) { + log.Printf(format, args...) +} + +func (s *stderrLogger) Criticalf(_ context.Context, format string, args ...interface{}) { + log.Printf(format, args...) +} + +func newTransport(ctx context.Context) http.RoundTripper { + dline, ok := ctx.Deadline() + t := &http.Transport{} + if ok { + t.ResponseHeaderTimeout = time.Until(dline) + } + return t +} + +func currentUserEmail(ctx context.Context) string { + // TODO + return "" +} + +// loginURL returns a URL that, when visited, prompts the user to sign in, +// then redirects the user to the URL specified by dest. +func loginURL(ctx context.Context, path string) (string, error) { + return "", errors.New("loginURL: unimplemented") +} + +func logoutURL(ctx context.Context, path string) (string, error) { + return "", errors.New("logoutURL: unimplemented") +} + +func newHTTPClient(ctx context.Context) *http.Client { + return &http.Client{} +} + +func getCaches(ctx context.Context, names ...string) map[string]*Cache { + out := make(map[string]*Cache) + dstoreMu.Lock() + defer dstoreMu.Unlock() + for _, name := range names { + if val, ok := dstore[name]; ok { + out[name] = val + } else { + // Ignore errors since they might not exist. + out[name] = &Cache{} + } + } + return out +} + +var dstore = make(map[string]*Cache) +var dstoreMu sync.Mutex + +var pageStore = make(map[string]*Page) +var pageStoreMu sync.Mutex + +func getCache(_ context.Context, name string) (*Cache, error) { + dstoreMu.Lock() + defer dstoreMu.Unlock() + cache, ok := dstore[name] + if ok { + return cache, nil + } + return &Cache{}, fmt.Errorf("cache key %s not found", name) +} + +func getPage(ctx context.Context, name string) (*Page, error) { + pageStoreMu.Lock() + defer pageStoreMu.Unlock() + page, ok := pageStore[name] + if ok { + return page, nil + } + return &Page{}, fmt.Errorf("page key %s not found", name) +} + +func writePage(ctx context.Context, page string, content []byte) error { + pageStoreMu.Lock() + defer pageStoreMu.Unlock() + entity := &Page{ + Content: content, + } + pageStore[page] = entity + return nil +} + +func putCache(_ context.Context, name string, c *Cache) error { + dstoreMu.Lock() + defer dstoreMu.Unlock() + dstore[name] = c + return nil +} + +var githubToken string +var githubOnceErr error +var githubOnce sync.Once + +func getToken(ctx context.Context) (string, error) { + githubOnce.Do(func() { + const short = ".github-issue-token" + filename := filepath.Clean(os.Getenv("HOME") + "/" + short) + shortFilename := filepath.Clean("$HOME/" + short) + if *tokenFile != "" { + filename = *tokenFile + shortFilename = *tokenFile + } + data, err := ioutil.ReadFile(filename) + if err != nil { + msg := fmt.Sprintln("reading token: ", err, "\n\n"+ + "Please create a personal access token at https://github.com/settings/tokens/new\n"+ + "and write it to ", shortFilename, " to use this program.\n"+ + "The token only needs the repo scope, or private_repo if you want to\n"+ + "view or edit issues for private repositories.\n"+ + "The benefit of using a personal access token over using your GitHub\n"+ + "password directly is that you can limit its use and revoke it at any time.\n\n") + githubOnceErr = errors.New(msg) + return + } + fi, err := os.Stat(filename) + if fi.Mode()&0077 != 0 { + githubOnceErr = fmt.Errorf("reading token: %s mode is %#o, want %#o", shortFilename, fi.Mode()&0777, fi.Mode()&0700) + return + } + githubToken = strings.TrimSpace(string(data)) + }) + return githubToken, githubOnceErr +} + +func getContext(r *http.Request) context.Context { + return r.Context() +} diff --git a/devapp/stats.go b/devapp/stats.go index 7b36f68f62..9d26139df0 100644 --- a/devapp/stats.go +++ b/devapp/stats.go @@ -18,34 +18,46 @@ import ( "strings" "time" - "golang.org/x/build/godash" - gdstats "golang.org/x/build/godash/stats" - "github.com/aclements/go-gg/generic/slice" "github.com/aclements/go-gg/gg" "github.com/aclements/go-gg/ggstat" "github.com/aclements/go-gg/table" "github.com/aclements/go-moremath/stats" "github.com/kylelemons/godebug/pretty" + "golang.org/x/build/godash" + gdstats "golang.org/x/build/godash/stats" "golang.org/x/net/context" - "google.golang.org/appengine" - "google.golang.org/appengine/urlfetch" + "golang.org/x/sync/errgroup" ) func updateStats(ctx context.Context, w http.ResponseWriter, r *http.Request) error { r.ParseForm() - caches := getCaches(ctx, "github-token", "gzstats") + g, errctx := errgroup.WithContext(ctx) + var token string + g.Go(func() error { + var err error + token, err = getToken(errctx) + return err + }) + var gzstats *Cache + g.Go(func() error { + gzstats, _ = getCache(errctx, "gzstats") + return nil + }) + if err := g.Wait(); err != nil { + return err + } log := logFn(ctx, w) stats := &godash.Stats{} - if err := unpackCache(caches["gzstats"], stats); err != nil { + if err := unpackCache(gzstats, stats); err != nil { return err } - transport := &urlfetch.Transport{Context: ctx} - gh := godash.NewGitHubClient("golang/go", string(caches["github-token"].Value), transport) + transport := newTransport(ctx) + gh := godash.NewGitHubClient("golang/go", token, transport) if r.Form.Get("reset_detail") != "" { stats.IssueDetailSince = time.Time{} @@ -68,13 +80,15 @@ func updateStats(ctx context.Context, w http.ResponseWriter, r *http.Request) er } } log("Have data about %d issues", len(stats.Issues)) - log("Updated issue stats to %v (detail to %v) in %.3f seconds", stats.Since, stats.IssueDetailSince, time.Now().Sub(start).Seconds()) + log("Updated issue stats to %v (detail to %v) in %.3f seconds", stats.Since, stats.IssueDetailSince, time.Since(start).Seconds()) return writeCache(ctx, "gzstats", stats) } +// GET /stats/release func release(ctx context.Context, w http.ResponseWriter, req *http.Request) error { req.ParseForm() + // TODO add this to the binary with go-bindata or similar. tmpl, err := ioutil.ReadFile("template/release.html") if err != nil { return err @@ -97,14 +111,12 @@ func release(ctx context.Context, w http.ResponseWriter, req *http.Request) erro cycle = data.GoReleaseCycle } - if err := t.Execute(w, struct{ GoReleaseCycle int }{cycle}); err != nil { - return err - } - return nil + return t.Execute(w, struct{ GoReleaseCycle int }{cycle}) } +// GET /stats/raw func rawHandler(w http.ResponseWriter, r *http.Request) { - ctx := appengine.NewContext(r) + ctx := getContext(r) stats := &godash.Stats{} if err := loadCache(ctx, "gzstats", stats); err != nil { @@ -116,13 +128,14 @@ func rawHandler(w http.ResponseWriter, r *http.Request) { (&pretty.Config{PrintStringers: true}).Fprint(w, stats) } +// GET /stats/svg func svgHandler(w http.ResponseWriter, req *http.Request) { if req.URL.RawQuery == "" { http.ServeFile(w, req, "static/svg.html") return } - ctx := appengine.NewContext(req) + ctx := getContext(req) req.ParseForm() stats := &godash.Stats{} @@ -290,7 +303,7 @@ func (p windowedPercentiles) F(input table.Grouping) table.Grouping { min, max := s.Bounds() outMin[i], outMax[i] = time.Duration(min), time.Duration(max) - p25, p50, p75 := s.Percentile(.25), s.Percentile(.5), s.Percentile(.75) + p25, p50, p75 := s.Quantile(.25), s.Quantile(.5), s.Quantile(.75) out25[i], out50[i], out75[i] = time.Duration(p25), time.Duration(p50), time.Duration(p75) } }, p.X, p.Y)("min "+p.Y, "p25 "+p.Y, "median "+p.Y, "p75 "+p.Y, "max "+p.Y, "points "+p.Y) diff --git a/godash/godash.go b/godash/godash.go index e7d86ad770..b3d1d07b7b 100644 --- a/godash/godash.go +++ b/godash/godash.go @@ -87,7 +87,7 @@ func (d *Data) FetchData(ctx context.Context, gh *github.Client, ger *gerrit.Cli if err != nil { return err } - log("Fetched %d open CLs in %.3f seconds", len(cls), time.Now().Sub(start).Seconds()) + log("Fetched %d open CLs in %.3f seconds", len(cls), time.Since(start).Seconds()) start = time.Now() var open []*CL @@ -101,7 +101,7 @@ func (d *Data) FetchData(ctx context.Context, gh *github.Client, ger *gerrit.Cli if err != nil { return err } - log("Fetched %d merged CLs in %.3f seconds", len(cls), time.Now().Sub(start).Seconds()) + log("Fetched %d merged CLs in %.3f seconds", len(cls), time.Since(start).Seconds()) start = time.Now() open = append(open, cls...) } @@ -114,13 +114,13 @@ func (d *Data) FetchData(ctx context.Context, gh *github.Client, ger *gerrit.Cli if err != nil { return err } - log("Fetched %d open issues in %.3f seconds", len(res), time.Now().Sub(start).Seconds()) + log("Fetched %d open issues in %.3f seconds", len(res), time.Since(start).Seconds()) start = time.Now() res2, err := searchIssues(gh, "is:closed closed:>="+since.Format(time.RFC3339)) if err != nil { return err } - log("Fetched %d closed issues in %.3f seconds", len(res2), time.Now().Sub(start).Seconds()) + log("Fetched %d closed issues in %.3f seconds", len(res2), time.Since(start).Seconds()) res = append(res, res2...) for _, issue := range res { d.Issues[issue.Number] = issue