blob: c57e1f9856023290a0a986dc08fc2a19707b1936 [file] [log] [blame]
// Copyright 2016 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 godash
import (
"errors"
"sort"
"time"
"github.com/google/go-github/github"
"golang.org/x/net/context"
"golang.org/x/sync/errgroup"
)
// Update fetches new information from GitHub for any issues modified since s was last updated.
func (s *Stats) Update(ctx context.Context, gh *github.Client, log func(string, ...interface{})) error {
res, err := listIssues(ctx, gh, github.IssueListByRepoOptions{State: "all", Since: s.Since})
if err != nil {
return err
}
log("Github returned %d issues", len(res))
if s.Issues == nil {
log("Initializing new data")
s.Issues = make(map[int]*IssueStat)
}
for _, issue := range res {
s.Issues[issue.Number] = &IssueStat{
Created: issue.Created,
Closed: issue.Closed,
Updated: issue.Updated,
Milestone: issue.Milestone,
}
if issue.Updated.After(s.Since) {
s.Since = issue.Updated
}
}
// Ingest event details. We have to do this last because it
// can blow through our rate limit.
var issuenums []int
for n, issue := range s.Issues {
if !issue.Updated.Before(s.IssueDetailSince) {
issuenums = append(issuenums, n)
}
}
if len(issuenums) == 0 {
log("No new issues; not updating")
return nil
}
sort.Sort(issueUpdatedSort{issuenums, s.Issues})
log("Need to update %d issues", len(issuenums))
// TODO: Limit by time instead of a fixed cap?
if len(issuenums) > 1000 {
issuenums = issuenums[:1000]
}
numch := make(chan int)
g, ctx := errgroup.WithContext(ctx)
for i := 0; i < 5; i++ {
g.Go(func() error {
for num := range numch {
if err := s.UpdateIssue(ctx, gh, num, log); err != nil {
return err
}
}
return nil
})
}
g.Go(func() error {
defer close(numch)
for _, num := range issuenums {
select {
case numch <- num:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
})
if err := g.Wait(); err != nil {
// Failure to update issue details should not cause the whole update to fail.
log("Failed updating stats: %v", err)
return nil
}
s.IssueDetailSince = s.Issues[issuenums[len(issuenums)-1]].Updated
log("Updated %d issues. Details now correct through %v", len(issuenums), s.IssueDetailSince)
return nil
}
// UpdateIssue updates a single issue, without moving s.Since.
func (s *Stats) UpdateIssue(ctx context.Context, gh *github.Client, num int, log func(string, ...interface{})) error {
issue := s.Issues[num]
var milestone string
var labels []string
milestoneChange := func(m string, t time.Time) {
if milestone != "" && m != milestone {
issue.MilestoneHistory = append(issue.MilestoneHistory, MilestoneChange{milestone, t})
}
milestone = m
}
for page := 1; ; {
events, resp, err := gh.Issues.ListIssueEvents(ctx, projectOwner, projectRepo, num, &github.ListOptions{
Page: page,
PerPage: 100,
})
if err != nil {
// TODO: Sometimes calls to GitHub seem to time out; if they do, perhaps we should retry?
return err
}
if page == 1 {
issue.MilestoneHistory = nil
}
for _, event := range events {
evtype := getString(event.Event)
evtime := getTime(event.CreatedAt)
switch evtype {
case "labeled":
if event.Label == nil {
continue
}
label := getString(event.Label.Name)
labels = append(labels, label)
switch label {
case "fixed", "retracted", "done", "duplicate", "workingasintended", "wontfix", "invalid", "unfortunate", "timedout":
// Old issues have these labels.
issue.Closed = evtime
}
if label[:2] == "go" {
milestoneChange("Go"+label[2:], evtime)
}
case "milestoned":
if event.Milestone == nil {
continue
}
m := getString(event.Milestone.Title)
milestoneChange(m, evtime)
}
}
if (resp.Remaining * 2) < (resp.Limit / 3) {
// Save rate limit for the more important updates above.
log("Out of quota (%d/%d) until %v", resp.Remaining, resp.Limit, resp.Reset)
return errors.New("out of quota")
}
if resp.NextPage == 0 {
break
}
page = resp.NextPage
}
// GitHub has a massive number of issues closed on 2014/12/8;
// I suspect this is when they first added the closed
// field. If we still think the issue is closed on this date,
// that probably means we failed to correctly process the
// labels. Log the issue's labels so we can investigate and
// possibly add to the list of labels above.
c := issue.Closed
if c.Year() == 2014 && c.Month() == 12 && c.Day() == 8 {
log("Issue %d labels: %v", num, labels)
}
return nil
}
type issueUpdatedSort struct {
nums []int
issues map[int]*IssueStat
}
func (x issueUpdatedSort) Len() int { return len(x.nums) }
func (x issueUpdatedSort) Swap(i, j int) { x.nums[i], x.nums[j] = x.nums[j], x.nums[i] }
func (x issueUpdatedSort) Less(i, j int) bool {
return x.issues[x.nums[i]].Updated.Before(x.issues[x.nums[j]].Updated)
}
// Stats contains information about all GitHub issues.
//
// We track statistics for each issue to produce graphs:
// - Issue creation time
// - TODO(quentin): First reply time from Go team member
// - Issue close time
// - Issue current milestone
// - History of issue labels + milestones
// As well as the following global info
// - Last issue update time
// - Last issue detail update time
type Stats struct {
// Issues is a map of issue number to per-issue data.
Issues map[int]*IssueStat
// Since is the high watermark for issue update times; any
// issues updated since Since will be refetched.
Since time.Time
// IssueDetailSince is the high watermark for issue details;
// this is separate because requesting issue details uses up
// quota, and we cannot request all issues at once.
IssueDetailSince time.Time
}
// MilestoneChange stores a historical milestone. We store historical
// milestones separately since most issues have only ever had one
// milestone; we can save on constructing and serializing the slice
// then.
type MilestoneChange struct {
// Name is the name of the milestone.
Name string
// Until is the time that the milestone was removed.
Until time.Time
}
// IssueStat holds an individual issue's important facts.
type IssueStat struct {
Created, Closed, Updated time.Time
// Milestone contains the milestone the issue is currently
// associated with.
Milestone string
// MilestoneHistory contains previous milestones and the time
// the issue ceased to be assigned to that milestone. We store
// this so the slice can be empty for most issues that have
// only ever been associated with one milestone.
MilestoneHistory []MilestoneChange
}