// 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 (
	"bytes"
	"context"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/google/go-github/github"
	"golang.org/x/build/gerrit"
	"golang.org/x/build/internal/gophers"
	"golang.org/x/build/maintner"
	"golang.org/x/build/maintner/godata"
	"golang.org/x/oauth2"
)

var (
	mode           = flag.String("mode", "", "mode to run in. Valid values:\n\n"+modeSummary())
	startTime      = newTimeFlag("from", "1900-01-01", "start of time range for the 'range-stats' mode")
	endTime        = newTimeFlag("to", "2100-01-01", "end of time range for the 'range-stats' mode")
	timeZone       = flag.String("tz", "US/Pacific", "timezone to use for time values")
	gerritProjects = newStringSetFlag("projects", "", "set of Gerrit projects to include; empty means all")
)

type stringSetFlag map[string]bool

func newStringSetFlag(name string, defVal string, desc string) *stringSetFlag {
	var s stringSetFlag
	s.Set(defVal)
	flag.Var(&s, name, desc)
	return &s
}

func (s *stringSetFlag) Includes(p string) bool {
	if len(*s) == 0 {
		return true
	}
	return (*s)[p]
}

func (s *stringSetFlag) Set(v string) error {
	if v == "" {
		*s = nil
		return nil
	}
	elms := strings.Split(v, ",")
	*s = make(map[string]bool, len(elms))
	for _, e := range elms {
		(*s)[e] = true
	}
	return nil
}

func (s *stringSetFlag) String() string {
	var elms []string
	for e := range *s {
		elms = append(elms, e)
	}
	sort.Strings(elms)
	return strings.Join(elms, ",")
}

func newTimeFlag(name, defVal, desc string) *time.Time {
	var t time.Time
	tf := (*timeFlag)(&t)
	if err := tf.Set(defVal); err != nil {
		panic(err.Error())
	}
	flag.Var(tf, name, desc)
	return &t
}

type timeFlag time.Time

func (t *timeFlag) String() string { return time.Time(*t).String() }
func (t *timeFlag) Set(v string) error {
	loc, err := time.LoadLocation(*timeZone)
	if err != nil {
		return err
	}
	for _, pat := range []string{
		time.RFC3339Nano,
		time.RFC3339,
		"2006-01-02T15:04:05",
		"2006-01-02T15:04",
		"2006-01-02",
	} {
		parsedTime, err := time.ParseInLocation(pat, v, loc)
		if err == nil {
			*t = timeFlag(parsedTime)
			return nil
		}
	}
	return fmt.Errorf("unrecognized RFC3339 or prefix %q", v)
}

type handler struct {
	fn   func(*statsClient)
	desc string
}

var modes = map[string]handler{
	"find-github-email":   {(*statsClient).findGithubEmails, "discover mappings between github usernames and emails"},
	"gerrit-groups":       {(*statsClient).gerritGroups, "print stats on gerrit groups"},
	"github-groups":       {(*statsClient).githubGroups, "print stats on github groups"},
	"github-issue-close":  {(*statsClient).githubIssueCloseStats, "print stats on github issues closes by quarter (googler-vs-not, unique numbers)"},
	"gerrit-cls":          {(*statsClient).gerritCLStats, "print stats on opened gerrit CLs by quarter"},
	"workshop-stats":      {(*statsClient).workshopStats, "print stats from contributor workshop"},
	"find-gerrit-gophers": {(*statsClient).findGerritGophers, "discover mappings between internal/gopher entries and Gerrit IDs"},
	"range-stats":         {(*statsClient).rangeStats, "show various summaries of activity in the flag-provided time range"},
}

func modeSummary() string {
	var buf bytes.Buffer
	var sorted []string
	for mode := range modes {
		sorted = append(sorted, mode)
	}
	sort.Strings(sorted)
	for _, mode := range sorted {
		fmt.Fprintf(&buf, "%q: %s\n", mode, modes[mode].desc)
	}
	return buf.String()
}

type statsClient struct {
	lazyGitHub *github.Client
	lazyGerrit *gerrit.Client

	corpusCache *maintner.Corpus
}

func (sc *statsClient) github() *github.Client {
	if sc.lazyGitHub != nil {
		return sc.lazyGitHub
	}
	ghc, err := getGithubClient()
	if err != nil {
		log.Fatal(err)
	}
	sc.lazyGitHub = ghc
	return ghc
}

func (sc *statsClient) gerrit() *gerrit.Client {
	if sc.lazyGerrit != nil {
		return sc.lazyGerrit
	}
	gerrc := gerrit.NewClient("https://go-review.googlesource.com", gerrit.GitCookieFileAuth(filepath.Join(os.Getenv("HOME"), ".gitcookies")))
	sc.lazyGerrit = gerrc
	return gerrc
}

func (sc *statsClient) corpus() *maintner.Corpus {
	if sc.corpusCache == nil {
		var err error
		sc.corpusCache, err = godata.Get(context.Background())
		if err != nil {
			log.Fatalf("Loading maintner corpus: %v", err)
		}
	}
	return sc.corpusCache
}

func main() {
	flag.Parse()

	if *mode == "" {
		fmt.Fprintf(os.Stderr, "Missing required --mode flag.\n")
		flag.Usage()
		os.Exit(1)
	}
	h, ok := modes[*mode]
	if !ok {
		fmt.Fprintf(os.Stderr, "Unknown --mode flag.\n")
		flag.Usage()
		os.Exit(1)
	}

	sc := &statsClient{}
	h.fn(sc)
}

func (sc *statsClient) gerritGroups() {
	ctx := context.Background()
	gerrc := sc.gerrit()

	groups, err := gerrc.GetGroups(ctx)
	if err != nil {
		log.Fatalf("Gerrit.GetGroups: %v", err)
	}
	for name, gi := range groups {
		switch name {
		case "admins", "approvers", "may-start-trybots", "gophers",
			"may-abandon-changes",
			"may-forge-author-identity", "osp-team",
			"release-managers":
			members, err := gerrc.GetGroupMembers(ctx, gi.ID)
			if err != nil {
				log.Fatal(err)
			}
			numGoog, numExt := 0, 0
			for _, member := range members {
				//fmt.Printf("  %s: %+v\n", name, member)
				p := gophers.GetGerritPerson(member)
				if p == nil {
					fmt.Printf("addPerson(%q, %q)\n", member.Name, member.Email)
				} else {
					if p.Googler {
						numGoog++
					} else {
						numExt++
					}
				}
			}
			fmt.Printf("Group %s: %d total (%d googlers, %d external)\n", name, numGoog+numExt, numGoog, numExt)
		}
	}
}

// quarter returns a quarter of a year, in the form "2017q1".
func quarter(t time.Time) string {
	// TODO: do this allocation-free? preculate them in init?
	return fmt.Sprintf("%04dq%v", t.Year(), (int(t.Month()-1)/3)+1)
}

func (sc *statsClient) githubIssueCloseStats() {
	repo := sc.corpus().GitHub().Repo("golang", "go")
	if repo == nil {
		log.Fatal("Failed to find Go repo.")
	}
	commClosed := map[string]map[*gophers.Person]int{}
	googClosed := map[string]map[*gophers.Person]int{}
	quarterSet := map[string]struct{}{}
	repo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
		if !gi.Closed {
			return nil
		}
		gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error {
			if e.Type != "closed" {
				return nil
			}
			if e.Actor == nil {
				return nil
			}
			q := quarter(e.Created)
			quarterSet[q] = struct{}{}
			if commClosed[q] == nil {
				commClosed[q] = map[*gophers.Person]int{}
			}
			if googClosed[q] == nil {
				googClosed[q] = map[*gophers.Person]int{}
			}
			var p *gophers.Person
			if e.Actor.Login == "gopherbot" {
				gc := sc.corpus().GitCommit(e.CommitID)
				if gc != nil {
					email := gc.Author.Email()
					p = gophers.GetPerson(email)
					if p == nil {
						log.Printf("unknown closer email: %q", email)
					}
				}
			} else {
				p = gophers.GetPerson("@" + e.Actor.Login)
			}
			if p != nil {
				if p.Googler {
					googClosed[q][p]++
				} else {
					commClosed[q][p]++
				}
			}
			return nil
		})
		return nil
	})
	sumPeeps := func(m map[*gophers.Person]int) (sum int) {
		for _, v := range m {
			sum += v
		}
		return
	}
	var quarters []string
	for q := range quarterSet {
		quarters = append(quarters, q)
	}
	sort.Strings(quarters)
	for _, q := range quarters {
		googTotal := sumPeeps(googClosed[q])
		commTotal := sumPeeps(commClosed[q])
		googUniq := len(googClosed[q])
		commUniq := len(commClosed[q])
		tot := googTotal + commTotal
		totUniq := googUniq + commUniq
		percentGoog := 100 * float64(googTotal) / float64(tot)
		fmt.Printf("%s closed issues: %v closes (%.2f%% goog %d; ext %d), %d unique people (%d goog, %d ext)\n",
			q, tot,
			percentGoog, googTotal, commTotal,
			totUniq, googUniq, commUniq,
		)
	}
}

type personSet struct {
	s       map[*gophers.Person]struct{}
	numGoog int
	numExt  int
}

func (s *personSet) sum() int { return len(s.s) }

func (s *personSet) add(p *gophers.Person) {
	if s.s == nil {
		s.s = make(map[*gophers.Person]struct{})
	}
	if _, ok := s.s[p]; !ok {
		s.s[p] = struct{}{}
		if p.Googler {
			s.numGoog++
		} else {
			s.numExt++
		}
	}
}

func (sc *statsClient) githubGroups() {
	ctx := context.Background()
	ghc := sc.github()
	teamList, _, err := ghc.Repositories.ListTeams(ctx, "golang", "go", nil)
	if err != nil {
		log.Fatal(err)
	}

	var teams = map[string]*personSet{}
	for _, t := range teamList {
		teamName := t.GetName()
		switch teamName {
		default:
			continue
		case "go-approvers", "gophers":
		}

		ps := new(personSet)
		teams[teamName] = ps
		users, _, err := ghc.Teams.ListTeamMembers(ctx, t.GetID(), &github.TeamListTeamMembersOptions{
			ListOptions: github.ListOptions{PerPage: 1000},
		})
		if err != nil {
			log.Fatal(err)
		}

		for _, u := range users {
			login := strings.ToLower(u.GetLogin())
			if login == "gopherbot" {
				continue
			}
			p := gophers.GetPerson("@" + login)
			if p == nil {
				panic(fmt.Sprintf("failed to find github person %q", "@"+login))
			}
			ps.add(p)
		}
	}

	cur := teams["go-approvers"]
	prev := parseOldSnapshot(githubGoApprovers20170106)
	log.Printf("Approvers 2016-12-13: %d: %v goog, %v ext", prev.sum(), prev.numGoog, prev.numExt)
	log.Printf("Approvers        cur: %d: %v goog, %v ext", cur.sum(), cur.numGoog, cur.numExt)
}

func parseOldSnapshot(s string) *personSet {
	ps := new(personSet)
	for _, f := range strings.Fields(s) {
		if !strings.HasPrefix(f, "@") {
			continue
		}
		p := gophers.GetPerson(f)
		if p == nil {
			panic(fmt.Sprintf("failed to find github person %q", f))
		}
		ps.add(p)
	}
	return ps
}

// Gerrit 2016-12-13:
// May start trybots, non-Googlers: 11
// Approvers, non-Googlers: 19

const githubGoApprovers20170106 = `
@0intro
0intro
David du Colombier

@4ad
4ad
Aram Hăvărneanu

@adams-sarah
adams-sarah
Sarah Adams

@adg
adg Owner
Andrew Gerrand

@alexbrainman
alexbrainman
Alex Brainman

@ality
ality
Anthony Martin

@campoy
campoy
Francesc Campoy

@DanielMorsing
DanielMorsing
Daniel Morsing

@davecheney
davecheney
Dave Cheney

@davidlazar
davidlazar
David Lazar

@dvyukov
dvyukov
Dmitry Vyukov

@eliasnaur
eliasnaur
Elias Naur

@hanwen
hanwen
Han-Wen Nienhuys

@josharian
josharian
Josh Bleecher Snyder

@jpoirier
jpoirier
Joseph Poirier

@kardianos
kardianos
Daniel Theophanes

@martisch
martisch
Martin Möhrmann

@matloob
matloob
Michael Matloob

@mdempsky
mdempsky
Matthew Dempsky

@mikioh
mikioh
Mikio Hara

@minux
minux
Minux Ma

@mwhudson
mwhudson
Michael Hudson-Doyle

@neild
neild
Damien Neil

@niemeyer
niemeyer
Gustavo Niemeyer

@odeke-em
odeke-em
Emmanuel T Odeke

@quentinmit
quentinmit Owner
Quentin Smith

@rakyll
rakyll
jbd@

@remyoudompheng
remyoudompheng
Rémy Oudompheng

@rminnich
rminnich
ron minnich

@rogpeppe
rogpeppe
Roger Peppe

@rui314
rui314
Rui Ueyama

@thanm
thanm
Than McIntosh
`

const githubGoAssignees20170106 = `
@crawshaw
crawshaw Team maintainer
David Crawshaw

@0intro
0intro
David du Colombier

@4ad
4ad
Aram Hăvărneanu

@adams-sarah
adams-sarah
Sarah Adams

@alexbrainman
alexbrainman
Alex Brainman

@alexcesaro
alexcesaro
Alexandre Cesaro

@ality
ality
Anthony Martin

@artyom
artyom
Artyom Pervukhin

@bcmills
bcmills
Bryan C. Mills

@billotosyr
billotosyr

@brtzsnr
brtzsnr
Alexandru Moșoi

@bsiegert
bsiegert
Benny Siegert

@c4milo
c4milo
Camilo Aguilar

@carl-mastrangelo
carl-mastrangelo
Carl Mastrangelo

@cespare
cespare
Caleb Spare

@DanielMorsing
DanielMorsing
Daniel Morsing

@davecheney
davecheney
Dave Cheney

@dominikh
dominikh
Dominik Honnef

@dskinner
dskinner
Daniel Skinner

@dsnet
dsnet
Joe Tsai

@dspezia
dspezia
Didier Spezia

@eliasnaur
eliasnaur
Elias Naur

@emergencybutter
emergencybutter
Arnaud

@evandbrown
evandbrown
Evan Brown

@fatih
fatih
Fatih Arslan

@garyburd
garyburd
Gary Burd

@hanwen
hanwen
Han-Wen Nienhuys

@jeffallen
jeffallen
Jeff R. Allen

@johanbrandhorst
johanbrandhorst
Johan Brandhorst

@josharian
josharian
Josh Bleecher Snyder

@jtsylve
jtsylve
Joe Sylve

@kardianos
kardianos
Daniel Theophanes

@kytrinyx
kytrinyx
Katrina Owen

@marete
marete
Brian Gitonga Marete

@martisch
martisch
Martin Möhrmann

@mattn
mattn
mattn

@mdempsky
mdempsky
Matthew Dempsky

@mdlayher
mdlayher
Matt Layher

@mikioh
mikioh
Mikio Hara

@millerresearch
millerresearch
Richard Miller

@minux
minux
Minux Ma

@mundaym
mundaym
Michael Munday

@mwhudson
mwhudson
Michael Hudson-Doyle

@myitcv
myitcv
Paul Jolly

@neelance
neelance
Richard Musiol

@niemeyer
niemeyer
Gustavo Niemeyer

@nodirt
nodirt
Nodir Turakulov

@rahulchaudhry
rahulchaudhry
Rahul Chaudhry

@rauls5382
rauls5382
Raul Silvera

@remyoudompheng
remyoudompheng
Rémy Oudompheng

@rhysh
rhysh
Rhys Hiltner

@rogpeppe
rogpeppe
Roger Peppe

@rsc
rsc Owner
Russ Cox

@rui314
rui314
Rui Ueyama

@sbinet
sbinet
Sebastien Binet

@shawnps
shawnps
Shawn Smith

@thanm
thanm
Than McIntosh

@titanous
titanous
Jonathan Rudenberg

@tombergan
tombergan

@tzneal
tzneal
Todd

@vstefanovic
vstefanovic

@wathiede
wathiede
Bill

@x1ddos
x1ddos
alex

@zombiezen
zombiezen
Ross Light
`

var discoverGoRepo = flag.String("discovery-go-repo", "go", "github.com/golang repo to discovery email addreses from")

func foreachProjectUnsorted(g *maintner.Gerrit, f func(gp *maintner.GerritProject) error) {
	g.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
		if !gerritProjects.Includes(gp.Project()) {
			log.Printf("skipping project %s", gp.Project())
			return nil
		}
		return f(gp)
	})
}

func (sc *statsClient) findGerritGophers() {
	gerrc := sc.gerrit()
	log.Printf("find gerrit gophers")
	gerritEmails := map[string]int{}

	const suffix = "@62eb7196-b449-3ce5-99f1-c037f21e1705"

	foreachProjectUnsorted(sc.corpus().Gerrit(), func(gp *maintner.GerritProject) error {
		return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
			for _, meta := range cl.Metas {
				who := meta.Commit.Author.Email()
				if strings.HasSuffix(who, suffix) {
					gerritEmails[who]++
				}
			}
			return nil
		})
	})

	var emails []string
	for k := range gerritEmails {
		emails = append(emails, k)
	}
	sort.Slice(emails, func(i, j int) bool {
		return gerritEmails[emails[j]] < gerritEmails[emails[i]]
	})
	for _, email := range emails {
		p := gophers.GetPerson(email)
		if p == nil {
			ai, err := gerrc.GetAccountInfo(context.Background(), strings.TrimSuffix(email, suffix))
			if err != nil {
				log.Printf("Looking up %s: %v", email, err)
				continue
			}
			fmt.Printf("addPerson(%q, %q, %q)\n", ai.Name, ai.Email, email)
		}
	}

}

func (sc *statsClient) findGithubEmails() {
	ghc := sc.github()
	seen := map[string]bool{}
	for page := 1; page < 500; page++ {
		commits, _, err := ghc.Repositories.ListCommits(context.Background(), "golang", *discoverGoRepo, &github.CommitsListOptions{
			ListOptions: github.ListOptions{Page: page, PerPage: 1000},
		})
		if err != nil {
			log.Fatalf("page %d: %v", page, err)
		}
		for _, com := range commits {
			ghUser := com.Author.GetLogin()
			if ghUser == "" {
				continue
			}
			if seen[ghUser] {
				continue
			}
			seen[ghUser] = true
			ca := com.Commit.Author

			p := gophers.GetPerson("@" + ghUser)
			if p != nil && gophers.GetPerson(ca.GetEmail()) == p {
				// Nothing new.
				continue
			}
			fmt.Printf("addPerson(%q, %q, %q)\n", ca.GetName(), ca.GetEmail(), "@"+ghUser)
		}
	}
}

func (sc *statsClient) gerritCLStats() {
	perQuarter := map[string]int{}
	perQuarterGoog := map[string]int{}
	perQuarterExt := map[string]int{}
	printedUnknown := map[string]bool{}
	perQuarterUniq := map[string]*personSet{}

	foreachProjectUnsorted(sc.corpus().Gerrit(), func(gp *maintner.GerritProject) error {
		gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
			q := quarter(cl.Created)
			perQuarter[q]++
			email := cl.Commit.Author.Email()
			p := gophers.GetPerson(email)
			var isGoog bool
			if p != nil {
				isGoog = p.Googler
				if _, ok := perQuarterUniq[q]; !ok {
					perQuarterUniq[q] = new(personSet)
				}
				perQuarterUniq[q].add(p)
			} else {
				isGoog = strings.HasSuffix(email, "@google.com")
				if !printedUnknown[email] {
					printedUnknown[email] = true
					fmt.Printf("addPerson(%q, %q)\n", cl.Commit.Author.Name(), email)

				}
			}
			if isGoog {
				perQuarterGoog[q]++
			} else {
				perQuarterExt[q]++
			}
			return nil
		})
		return nil
	})
	for _, q := range sortedStrMapKeys(perQuarter) {
		goog := perQuarterGoog[q]
		ext := perQuarterExt[q]
		tot := goog + ext
		fmt.Printf("%s: %d commits (%0.2f%% %d goog, %d ext)\n", q, perQuarter[q], 100*float64(goog)/float64(tot), goog, ext)
	}
	for _, q := range sortedStrMapKeys(perQuarter) {
		ps := perQuarterUniq[q]
		fmt.Printf("%s: %d unique users (%0.2f%% %d goog, %d ext)\n", q, len(ps.s), 100*float64(ps.numGoog)/float64(len(ps.s)), ps.numGoog, ps.numExt)
	}
}

func sortedStrMapKeys(m map[string]int) []string {
	ret := make([]string, 0, len(m))
	for k := range m {
		ret = append(ret, k)
	}
	sort.Strings(ret)
	return ret
}

func (sc *statsClient) workshopStats() {
	const workshopIssue = 21017
	loc, err := time.LoadLocation("America/Denver")
	if err != nil {
		fmt.Fprintf(os.Stderr, "loading location failed: %v", err)
		os.Exit(2)
	}
	workshopStartDate := time.Date(2017, time.July, 15, 0, 0, 0, 0, loc)

	// The key is the string representation of the gerrit ID.
	// The value is the string for the GitHub login.
	contributors := map[string]string{}

	// Get all the contributors from comments on the issue.
	sc.corpus().GitHub().Repo("golang", "go").Issue(workshopIssue).ForeachComment(func(c *maintner.GitHubComment) error {
		contributors[strings.TrimSpace(c.Body)] = c.User.Login
		return nil
	})
	fmt.Printf("Number of registrations: %d\n", len(contributors))

	// Store the already known contributors before the workshop.
	knownContributors := map[string]struct{}{}

	type projectStats struct {
		name      string
		openedCLs []string // Gerrit IDs of owners of opened CLs
		mergedCLs []string // Gerrit IDs of owners of merged CLs
	}
	ps := []projectStats{}

	// Get all the CLs during the time of the workshop and after.
	foreachProjectUnsorted(sc.corpus().Gerrit(), func(gp *maintner.GerritProject) error {
		p := projectStats{
			name: gp.Project(),
		}
		gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
			ownerID := fmt.Sprintf("%d", cl.OwnerID())
			// Make sure it was made after the workshop started
			// otherwise save as a known contributor.
			if cl.Created.After(workshopStartDate) {
				if _, ok := contributors[ownerID]; ok {
					p.openedCLs = append(p.openedCLs, ownerID)
					if cl.Status == "merged" {
						p.mergedCLs = append(p.mergedCLs, ownerID)
					}
				}
			} else {
				knownContributors[ownerID] = struct{}{}
			}
			return nil
		})

		// Return early if no one contributed to that project.
		if len(p.openedCLs) == 0 && len(p.mergedCLs) == 0 {
			return nil
		}

		ps = append(ps, p)
		return nil
	})

	sort.Slice(ps, func(i, j int) bool { return ps[i].name < ps[j].name })
	for _, p := range ps {
		var newOpened, newMerged int

		// Determine the first time contributors.
		for _, id := range p.openedCLs {
			if _, ok := knownContributors[id]; !ok {
				newOpened++
			}
		}
		for _, id := range p.mergedCLs {
			if _, ok := knownContributors[id]; !ok {
				newMerged++
			}
		}

		// Ignore repos where only past contributors had patches merged.
		if newOpened != 0 || newMerged != 0 {
			fmt.Printf(`%s:
	Total Opened CLs: %d
	Total Merged CLs: %d
	New Contributors Opened CLs: %d
	New Contributors Merged CLs: %d`+"\n", p.name, len(p.openedCLs), len(p.mergedCLs), newOpened, newMerged)
		}
	}
}

func (sc *statsClient) rangeStats() {
	var (
		newCLs                    = map[*gophers.Person]int{}
		commentsOnOtherCLs        = map[*gophers.Person]int{}
		githubIssuesCreated       = map[*gophers.Person]int{}
		githubUniqueIssueComments = map[*gophers.Person]int{} // non-owner
		githubUniqueIssueEvents   = map[*gophers.Person]int{} // non-owner
		uniqueFilesEdited         = map[*gophers.Person]int{}
		uniqueDirsEdited          = map[*gophers.Person]int{}
	)

	t1 := *startTime
	t2 := *endTime

	sc.corpus().GitHub().ForeachRepo(func(r *maintner.GitHubRepo) error {
		if r.ID().Owner != "golang" {
			return nil
		}
		return r.ForeachIssue(func(gi *maintner.GitHubIssue) error {
			if gi.User == nil {
				return nil
			}
			owner := gophers.GetPerson("@" + gi.User.Login)
			if gi.Created.After(t1) && gi.Created.Before(t2) {
				if owner == nil {
					log.Printf("No owner for golang.org/issue/%d (%q)", gi.Number, gi.User.Login)
				} else if !owner.Bot {
					githubIssuesCreated[owner]++
				}
			}

			sawCommenter := map[*gophers.Person]bool{}
			gi.ForeachComment(func(gc *maintner.GitHubComment) error {
				if gc.User == nil || gc.User.ID == gi.User.ID {
					return nil
				}
				if gc.Created.After(t1) && gc.Created.Before(t2) {
					commenter := gophers.GetPerson("@" + gc.User.Login)
					if commenter == nil || sawCommenter[commenter] || commenter.Bot {
						return nil
					}
					sawCommenter[commenter] = true
					githubUniqueIssueComments[commenter]++
				}
				return nil
			})

			sawEventer := map[*gophers.Person]bool{}
			gi.ForeachEvent(func(gc *maintner.GitHubIssueEvent) error {
				if gc.Actor == nil || gc.Actor.ID == gi.User.ID {
					return nil
				}
				if gc.Created.After(t1) && gc.Created.Before(t2) {
					eventer := gophers.GetPerson("@" + gc.Actor.Login)
					if eventer == nil || sawEventer[eventer] || eventer.Bot {
						return nil
					}
					sawEventer[eventer] = true
					githubUniqueIssueEvents[eventer]++
				}
				return nil
			})

			return nil
		})
	})

	type projectFile struct {
		gp   *maintner.GerritProject
		file string
	}
	var fileTouched = map[*gophers.Person]map[projectFile]bool{}
	var dirTouched = map[*gophers.Person]map[projectFile]bool{}

	foreachProjectUnsorted(sc.corpus().Gerrit(), func(gp *maintner.GerritProject) error {
		if gp.Server() != "go.googlesource.com" {
			return nil
		}
		return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
			owner := gophers.GetPerson(cl.Commit.Author.Email())
			if cl.Created.After(t1) && cl.Created.Before(t2) {
				newCLs[owner]++
			}

			if ct := cl.Commit.CommitTime; ct.After(t1) && ct.Before(t2) && cl.Status == "merged" {
				email := cl.Commit.Author.Email() // gerrit-y email
				who := gophers.GetPerson(email)
				if who != nil {
					if len(cl.Commit.Files) > 20 {
						// Probably just a cleanup or moving files, skip this CL.
						return nil
					}
					if fileTouched[who] == nil {
						fileTouched[who] = map[projectFile]bool{}
					}
					if dirTouched[who] == nil {
						dirTouched[who] = map[projectFile]bool{}
					}
					for _, diff := range cl.Commit.Files {
						if strings.Contains(diff.File, "vendor/") {
							continue
						}
						fileTouched[who][projectFile{gp, diff.File}] = true
						dirTouched[who][projectFile{gp, path.Dir(diff.File)}] = true
					}
				}
			}

			saw := map[*gophers.Person]bool{}
			for _, meta := range cl.Metas {
				t := meta.Commit.CommitTime
				if t.Before(t1) || t.After(t2) {
					continue
				}
				email := meta.Commit.Author.Email() // gerrit-y email
				who := gophers.GetPerson(email)
				if who == owner || who == nil || saw[who] || who.Bot {
					continue
				}
				saw[who] = true
				commentsOnOtherCLs[who]++
			}
			return nil
		})
	})

	for p, m := range fileTouched {
		uniqueFilesEdited[p] = len(m)
	}
	for p, m := range dirTouched {
		uniqueDirsEdited[p] = len(m)
	}

	top(newCLs, "CLs created:", 40)
	top(commentsOnOtherCLs, "Unique non-self CLs commented on:", 40)

	top(githubIssuesCreated, "GitHub issues created:", 40)
	top(githubUniqueIssueComments, "Unique GitHub issues commented on:", 40)
	top(githubUniqueIssueEvents, "Unique GitHub issues acted on:", 40)

	top(uniqueFilesEdited, "Unique files edited:", 40)
	top(uniqueDirsEdited, "Unique directories edited:", 40)
}

func top(m map[*gophers.Person]int, title string, n int) {
	var kk []*gophers.Person
	for k := range m {
		if k == nil {
			continue
		}
		kk = append(kk, k)
	}
	sort.Slice(kk, func(i, j int) bool { return m[kk[j]] < m[kk[i]] })
	fmt.Println(title)
	for i, k := range kk {
		if i == n {
			break
		}
		fmt.Printf(" %5d %s\n", m[k], k.Name)
	}
	fmt.Println()
}

func getGithubToken() (string, error) {
	// TODO: get from GCE metadata, etc.
	tokenFile := filepath.Join(os.Getenv("HOME"), "keys", "github-read-org")
	slurp, err := ioutil.ReadFile(tokenFile)
	if err != nil {
		return "", err
	}
	f := strings.SplitN(strings.TrimSpace(string(slurp)), ":", 2)
	if len(f) != 2 || f[0] == "" || f[1] == "" {
		return "", fmt.Errorf("Expected token file %s to be of form <username>:<token>", tokenFile)
	}
	return f[1], nil
}

func getGithubClient() (*github.Client, error) {
	token, err := getGithubToken()
	if err != nil {
		return nil, err
	}
	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
	tc := oauth2.NewClient(context.Background(), ts)
	return github.NewClient(tc), nil
}
