add pipelinestats
diff --git a/buildkite/tools/pipelinestats/main.go b/buildkite/tools/pipelinestats/main.go
new file mode 100644
index 0000000..5d417b3
--- /dev/null
+++ b/buildkite/tools/pipelinestats/main.go
@@ -0,0 +1,243 @@
+package main
+
+import (
+	"encoding/csv"
+	"flag"
+	"fmt"
+	"os"
+	"sort"
+	"strconv"
+	"time"
+
+	"github.com/buildkite/go-buildkite/buildkite"
+)
+
+type durationPercentiles struct {
+	Max   time.Duration
+	Min   time.Duration
+	Pct90 time.Duration
+	Pct70 time.Duration
+	Pct50 time.Duration
+	Pct30 time.Duration
+}
+
+type dayBuildStats struct {
+	Day          time.Time
+	NumBuilds    int
+	NumFailed    int
+	NumPassed    int
+	BuildTimes   durationPercentiles
+	JobWaitTimes map[string]durationPercentiles
+}
+
+func optionsForPage(page int) *buildkite.BuildsListOptions {
+	return &buildkite.BuildsListOptions{
+		ListOptions: buildkite.ListOptions{
+			Page:    page,
+			PerPage: 100,
+		},
+	}
+}
+
+func fetchAllBuilds(org string, pipeline string, client *buildkite.Client) ([]buildkite.Build, error) {
+	page := 0
+	var builds []buildkite.Build
+	for {
+		buildsOnPage, _, err := client.Builds.ListByPipeline(org, pipeline, optionsForPage(page))
+		if err != nil {
+			return nil, err
+		}
+		if len(buildsOnPage) == 0 {
+			break
+		}
+		builds = append(builds, buildsOnPage...)
+		page++
+	}
+	return builds, nil
+}
+
+func computeDurationPercentiles(durations []time.Duration) durationPercentiles {
+	if len(durations) == 0 {
+		return durationPercentiles{}
+	}
+
+	sort.Slice(durations, func(i, j int) bool {
+		return durations[i] < durations[j]
+	})
+
+	return durationPercentiles{
+		Max:   durations[len(durations)-1],
+		Min:   durations[0],
+		Pct90: durations[9*len(durations)/10],
+		Pct70: durations[7*len(durations)/10],
+		Pct50: durations[5*len(durations)/10],
+		Pct30: durations[3*len(durations)/10],
+	}
+}
+
+func computeDayBuildStats(builds []buildkite.Build) *dayBuildStats {
+	if len(builds) == 0 {
+		return nil
+	}
+	var stats dayBuildStats
+	createdAt := builds[0].CreatedAt
+	stats.Day = time.Date(createdAt.Year(), createdAt.Month(), createdAt.Day(), 0, 0, 0, 0,
+		createdAt.Location())
+	stats.NumBuilds = len(builds)
+
+	var buildTimes []time.Duration
+	jobWaitTimes := make(map[string][]time.Duration)
+	for _, build := range builds {
+		if build.FinishedAt != nil && build.CreatedAt != nil {
+			buildTime := time.Duration(0)
+			for _, job := range build.Jobs {
+				if job.Name == nil || job.StartedAt == nil || job.CreatedAt == nil ||
+					job.FinishedAt == nil {
+					continue
+				}
+				waitTime := job.StartedAt.Time.Sub(job.CreatedAt.Time)
+				jobBuildTime := job.FinishedAt.Time.Sub(job.StartedAt.Time)
+				if jobBuildTime > buildTime {
+					// Use the maximum job build time as the duration, instead of the build's
+					// start and finish time. This is because retries of jobs also count towards
+					// the build's time.
+					buildTime = jobBuildTime
+				}
+				jobWaitTimes[*job.Name] = append(jobWaitTimes[*job.Name], waitTime)
+			}
+			buildTimes = append(buildTimes, buildTime)
+		}
+
+		if *build.State == "passed" {
+			stats.NumPassed++
+		}
+		if *build.State == "failed" {
+			stats.NumFailed++
+		}
+	}
+
+	stats.BuildTimes = computeDurationPercentiles(buildTimes)
+
+	stats.JobWaitTimes = make(map[string]durationPercentiles, len(jobWaitTimes))
+	for jobName, durations := range jobWaitTimes {
+		stats.JobWaitTimes[jobName] = computeDurationPercentiles(durations)
+	}
+
+	return &stats
+}
+
+func buildStatsPerDay(builds []buildkite.Build) ([]*dayBuildStats, []string) {
+	if len(builds) == 0 {
+		return []*dayBuildStats{}, []string{}
+	}
+
+	sort.Slice(builds, func(i, j int) bool {
+		return builds[i].CreatedAt.Time.Before(builds[j].CreatedAt.Time)
+	})
+
+	var stats []*dayBuildStats
+	day := 0
+	var dayBuilds []buildkite.Build
+	jobNamesMap := make(map[string]bool)
+	for _, build := range builds {
+		if day != build.CreatedAt.Day() {
+			if len(dayBuilds) > 0 {
+				dayStats := computeDayBuildStats(dayBuilds)
+				for jobName := range dayStats.JobWaitTimes {
+					jobNamesMap[jobName] = true
+				}
+				stats = append(stats, dayStats)
+			}
+
+			dayBuilds = nil
+			day = build.CreatedAt.Day()
+		}
+		dayBuilds = append(dayBuilds, build)
+	}
+
+	if len(dayBuilds) > 0 {
+		dayStats := computeDayBuildStats(dayBuilds)
+		for jobName := range dayStats.JobWaitTimes {
+			jobNamesMap[jobName] = true
+		}
+		stats = append(stats, dayStats)
+
+	}
+
+	var jobNames []string
+	for jobName := range jobNamesMap {
+		jobNames = append(jobNames, jobName)
+	}
+	sort.Strings(jobNames)
+
+	return stats, jobNames
+}
+
+func durationPercentilesFieldNames(prefix string) []string {
+	fieldNames := []string{"Max", "Min", "Pct90", "Pc70", "Pct50", "Pct30"}
+	for i, value := range fieldNames {
+		fieldNames[i] = prefix + "." + value
+	}
+	return fieldNames
+}
+
+func appendDurationPercentiles(line []string, stats *durationPercentiles) []string {
+	line = append(line, strconv.Itoa(int(stats.Max.Seconds())))
+	line = append(line, strconv.Itoa(int(stats.Min.Seconds())))
+	line = append(line, strconv.Itoa(int(stats.Pct90.Seconds())))
+	line = append(line, strconv.Itoa(int(stats.Pct70.Seconds())))
+	line = append(line, strconv.Itoa(int(stats.Pct50.Seconds())))
+	line = append(line, strconv.Itoa(int(stats.Pct30.Seconds())))
+
+	return line
+}
+
+func main() {
+	apiToken := os.Getenv("BUILDKITE_API_TOKEN")
+	if apiToken == "" {
+		fmt.Println("BUILDKITE_API_TOKEN environment variable not set.")
+		os.Exit(1)
+	}
+	pipelineSlug := flag.String("pipeline_slug", "", "Slug of the Buildkite pipeline")
+	flag.Parse()
+
+	if *pipelineSlug == "" {
+		flag.Usage()
+		os.Exit(1)
+	}
+
+	config, _ := buildkite.NewTokenConfig(apiToken, false)
+	client := buildkite.NewClient(config.Client())
+
+	builds, _ := fetchAllBuilds("bazel", *pipelineSlug, client)
+	stats, jobNames := buildStatsPerDay(builds)
+
+	writer := csv.NewWriter(os.Stdout)
+	defer writer.Flush()
+
+	csvHeaders := []string{"Day", "NumBuilds", "NumFailed", "NumPassed"}
+	csvHeaders = append(csvHeaders, durationPercentilesFieldNames("BuildTimes")...)
+	for _, jobName := range jobNames {
+		csvHeaders = append(csvHeaders, durationPercentilesFieldNames("WaitTimes["+jobName+"]")...)
+	}
+	writer.Write(csvHeaders)
+
+	for _, dayStats := range stats {
+		var line []string
+		line = append(line, dayStats.Day.Format("2006-01-02"))
+		line = append(line, strconv.Itoa(dayStats.NumBuilds))
+		line = append(line, strconv.Itoa(dayStats.NumFailed))
+		line = append(line, strconv.Itoa(dayStats.NumPassed))
+
+		line = appendDurationPercentiles(line, &dayStats.BuildTimes)
+
+		for _, jobName := range jobNames {
+			if waitTimes, ok := dayStats.JobWaitTimes[jobName]; ok {
+				line = appendDurationPercentiles(line, &waitTimes)
+			} else {
+				line = appendDurationPercentiles(line, &durationPercentiles{})
+			}
+		}
+		writer.Write(line)
+	}
+}