| // 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 generates dashboards about issues and CLs in the Go |
| // Github and Gerrit projects. There is a user-friendly interface in |
| // the godash command-line tool at golang.org/x/build/cmd/godash |
| package godash |
| |
| import ( |
| "bytes" |
| "fmt" |
| "io" |
| "regexp" |
| "sort" |
| "strconv" |
| "strings" |
| "time" |
| |
| "github.com/google/go-github/github" |
| "golang.org/x/build/gerrit" |
| "golang.org/x/net/context" |
| ) |
| |
| type Issue struct { |
| Number int |
| Title string |
| Labels []string |
| Reporter string |
| Assignee string |
| Milestone string |
| State string |
| Created, Updated, Closed time.Time |
| } |
| |
| type Group struct { |
| Dir string |
| Items []*Item |
| } |
| |
| type Item struct { |
| Issue *Issue |
| CLs []*CL |
| } |
| |
| // Data represents all the data needed to compute the dashboard |
| type Data struct { |
| Issues map[int]*Issue |
| CLs []*CL |
| Milestones []*github.Milestone |
| // GoReleaseCycle is the minor version of the current |
| // under-development Go release. Issues and CLs for versions |
| // greater than the current Go release will be hidden. |
| GoReleaseCycle int |
| Now time.Time |
| |
| Reviewers *Reviewers |
| } |
| |
| var ( |
| releaseRE = regexp.MustCompile(`^Go1\.(\d+)$`) |
| ) |
| |
| func (d *Data) FetchData(ctx context.Context, gh *github.Client, ger *gerrit.Client, log func(string, ...interface{}), days int, clOnly, includeMerged bool) error { |
| d.Now = time.Now() |
| start := d.Now |
| m, err := getMilestones(ctx, gh) |
| if err != nil { |
| return err |
| } |
| d.Milestones = m |
| |
| // Find the lowest-numbered open release milestone. We assume |
| // that is the currently-in-development release, and anything |
| // later should be hidden. |
| d.GoReleaseCycle = 0 |
| for _, m := range d.Milestones { |
| if matches := releaseRE.FindStringSubmatch(*m.Title); matches != nil { |
| n, _ := strconv.Atoi(matches[1]) |
| if d.GoReleaseCycle == 0 || d.GoReleaseCycle > n { |
| d.GoReleaseCycle = n |
| } |
| } |
| } |
| since := d.Now.Add(-(time.Duration(days)*24 + 12) * time.Hour).UTC().Round(time.Second) |
| cls, err := fetchCLs(ctx, ger, d.Reviewers, d.GoReleaseCycle, "is:open") |
| if err != nil { |
| return err |
| } |
| log("Fetched %d open CLs in %.3f seconds", len(cls), time.Since(start).Seconds()) |
| start = time.Now() |
| |
| var open []*CL |
| for _, cl := range cls { |
| if !cl.Closed && (clOnly || !strings.HasPrefix(cl.Subject, "[dev.")) { |
| open = append(open, cl) |
| } |
| } |
| if includeMerged { |
| cls, err := fetchCLs(ctx, ger, d.Reviewers, d.GoReleaseCycle, "is:merged since:\""+since.Format("2006-01-02 15:04:05")+"\"") |
| if err != nil { |
| return err |
| } |
| log("Fetched %d merged CLs in %.3f seconds", len(cls), time.Since(start).Seconds()) |
| start = time.Now() |
| open = append(open, cls...) |
| } |
| d.CLs = open |
| |
| d.Issues = make(map[int]*Issue) |
| |
| if !clOnly { |
| res, err := listIssues(ctx, gh, github.IssueListByRepoOptions{State: "open"}) |
| if err != nil { |
| return err |
| } |
| log("Fetched %d open issues in %.3f seconds", len(res), time.Since(start).Seconds()) |
| start = time.Now() |
| res2, err := searchIssues(ctx, gh, "is:closed closed:>="+since.Format(time.RFC3339)) |
| if err != nil { |
| return err |
| } |
| 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 |
| } |
| } |
| return nil |
| } |
| |
| // GroupData returns information about all the issues and CLs, |
| // grouping related issues and CLs together and then grouping those |
| // items by directory affected. includeIssues specifies whether to |
| // include both CLs and issues or just CLs. allCLs specifies whether |
| // to include CLs from non-go projects (i.e. x/ repos). |
| func (d *Data) GroupData(includeIssues, allCLs bool) []*Group { |
| groupsByDir := make(map[string]*Group) |
| addGroup := func(item *Item) { |
| dir := item.Dir() |
| g := groupsByDir[dirKey(dir)] |
| if g == nil { |
| g = &Group{Dir: dir} |
| groupsByDir[dirKey(dir)] = g |
| } |
| g.Items = append(g.Items, item) |
| } |
| itemsByBug := map[int]*Item{} |
| |
| if includeIssues { |
| for _, issue := range d.Issues { |
| item := &Item{Issue: issue} |
| addGroup(item) |
| itemsByBug[issue.Number] = item |
| } |
| } |
| |
| for _, cl := range d.CLs { |
| found := false |
| for _, id := range cl.Issues { |
| item := itemsByBug[id] |
| if item != nil { |
| found = true |
| item.CLs = append(item.CLs, cl) |
| } |
| } |
| if !found { |
| if cl.Project == "go" || allCLs { |
| item := &Item{CLs: []*CL{cl}} |
| addGroup(item) |
| } |
| } |
| } |
| |
| var keys []string |
| for key, g := range groupsByDir { |
| sort.Sort(itemsBySummary(g.Items)) |
| keys = append(keys, key) |
| } |
| sort.Strings(keys) |
| var groups []*Group |
| for _, key := range keys { |
| g := groupsByDir[key] |
| groups = append(groups, g) |
| } |
| return groups |
| } |
| |
| var okDesc = map[string]bool{ |
| "all": true, |
| "build": true, |
| } |
| |
| func (item *Item) Dir() string { |
| for _, cl := range item.CLs { |
| if cl.Status == "merged" { |
| return "closed" |
| } |
| dirs := cl.Dirs() |
| desc := titleDir(cl.Subject) |
| |
| // Accept description if it is a global prefix like "all". |
| if okDesc[desc] { |
| return desc |
| } |
| |
| // Accept description if it matches one of the directories. |
| for _, dir := range dirs { |
| if dir == desc { |
| return dir |
| } |
| } |
| |
| // Otherwise use most common directory. |
| if len(dirs) > 0 { |
| return dirs[0] |
| } |
| |
| // Otherwise accept description. |
| return desc |
| } |
| if item.Issue != nil { |
| if item.Issue.State == "closed" { |
| return "closed" |
| } |
| if hasLabel(item.Issue, "Proposal") { |
| return "proposal" |
| } |
| if dir := titleDir(item.Issue.Title); dir != "" { |
| return dir |
| } |
| return "?" |
| } |
| return "?" |
| } |
| |
| func hasLabel(issue *Issue, label string) bool { |
| for _, lab := range issue.Labels { |
| if label == lab { |
| return true |
| } |
| } |
| return false |
| } |
| |
| func titleDir(title string) string { |
| if i := strings.Index(title, "\n"); i >= 0 { |
| title = title[:i] |
| } |
| title = strings.TrimSpace(title) |
| i := strings.Index(title, ":") |
| if i < 0 { |
| return "" |
| } |
| title = title[:i] |
| if i := strings.Index(title, ","); i >= 0 { |
| title = strings.TrimSpace(title[:i]) |
| } |
| if strings.Contains(title, " ") { |
| return "" |
| } |
| return title |
| } |
| |
| // Dirs returns the list of directories that this CL might be said to be about, |
| // in preference order. |
| func (cl *CL) Dirs() []string { |
| prefix := "" |
| if cl.Project != "go" { |
| prefix = "x/" + cl.Project + "/" |
| } |
| counts := map[string]int{} |
| for _, file := range cl.Files { |
| name := file |
| i := strings.LastIndex(name, "/") |
| if i >= 0 { |
| name = name[:i] |
| } else { |
| name = "" |
| } |
| name = strings.TrimPrefix(name, "src/") |
| if name == "src" { |
| name = "" |
| } |
| name = prefix + name |
| if name == "" { |
| name = "build" |
| } |
| counts[name]++ |
| } |
| |
| if _, ok := counts["test"]; ok { |
| counts["test"] -= 10000 // do not pick as most frequent |
| } |
| |
| var dirs dirCounts |
| for name, count := range counts { |
| dirs = append(dirs, dirCount{name, count}) |
| } |
| sort.Sort(dirs) |
| |
| var names []string |
| for _, d := range dirs { |
| names = append(names, d.name) |
| } |
| return names |
| } |
| |
| type dirCount struct { |
| name string |
| count int |
| } |
| |
| type dirCounts []dirCount |
| |
| func (x dirCounts) Len() int { return len(x) } |
| func (x dirCounts) Swap(i, j int) { x[i], x[j] = x[j], x[i] } |
| func (x dirCounts) Less(i, j int) bool { |
| if x[i].count != x[j].count { |
| return x[i].count > x[j].count |
| } |
| return x[i].name < x[j].name |
| } |
| |
| type itemsBySummary []*Item |
| |
| func (x itemsBySummary) Len() int { return len(x) } |
| func (x itemsBySummary) Swap(i, j int) { x[i], x[j] = x[j], x[i] } |
| func (x itemsBySummary) Less(i, j int) bool { return itemSummary(x[i]) < itemSummary(x[j]) } |
| |
| func itemSummary(it *Item) string { |
| if it.Issue != nil { |
| return it.Issue.Title |
| } |
| for _, cl := range it.CLs { |
| return cl.Subject |
| } |
| return "" |
| } |
| |
| func dirKey(s string) string { |
| if strings.Contains(s, ".") { |
| return "\x7F" + s |
| } |
| return s |
| } |
| |
| var milestoneRE = regexp.MustCompile(`^Go1\.(\d+)(|\.(\d+))(|[A-Z].*)$`) |
| |
| type milestone struct { |
| title string |
| major, minor int |
| due time.Time |
| } |
| |
| func (d *Data) GetActiveMilestones() []string { |
| var all []milestone |
| for _, dm := range d.Milestones { |
| if m := milestoneRE.FindStringSubmatch(*dm.Title); m != nil { |
| major, _ := strconv.Atoi(m[1]) |
| minor, _ := strconv.Atoi(m[3]) |
| if major <= d.GoReleaseCycle { |
| all = append(all, milestone{*dm.Title, major, minor, getTime(dm.DueOn)}) |
| } |
| } |
| } |
| sort.Sort(milestones(all)) |
| var titles []string |
| for _, m := range all { |
| titles = append(titles, m.title) |
| } |
| return titles |
| } |
| |
| type milestones []milestone |
| |
| func (x milestones) Len() int { return len(x) } |
| func (x milestones) Swap(i, j int) { x[i], x[j] = x[j], x[i] } |
| func (x milestones) Less(i, j int) bool { |
| a, b := x[i], x[j] |
| if a.major != b.major { |
| return a.major < b.major |
| } |
| if a.minor != b.minor { |
| return a.minor < b.minor |
| } |
| if !a.due.Equal(b.due) { |
| return a.due.Before(b.due) |
| } |
| return a.title < b.title |
| } |
| |
| type section struct { |
| title string |
| count int |
| body string |
| } |
| |
| func (d *Data) PrintIssues(w io.Writer) { |
| groups := d.GroupData(true, false) |
| |
| milestones := d.GetActiveMilestones() |
| |
| sections := []*section{} |
| |
| // Issues |
| for _, title := range milestones { |
| count, body := d.printGroups(groups, false, func(item *Item) bool { return item.Issue != nil && item.Issue.Milestone == title }) |
| sections = append(sections, §ion{ |
| title, count, body, |
| }) |
| } |
| // Pending CLs |
| // This uses a different grouping (by CL, not by issue) since |
| // otherwise we might print a CL twice. |
| count, body := d.printGroups(d.GroupData(false, false), false, func(item *Item) bool { return len(item.CLs) > 0 }) |
| sections = append(sections, §ion{ |
| "Pending CLs", |
| count, body, |
| }) |
| // Proposals |
| for _, group := range groups { |
| if group.Dir == "proposal" { |
| count, body := d.printGroups([]*Group{group}, false, func(*Item) bool { return true }) |
| sections = append(sections, §ion{ |
| "Pending Proposals", |
| count, body, |
| }) |
| } |
| } |
| // Closed |
| for _, group := range groups { |
| if group.Dir == "closed" { |
| count, body := d.printGroups([]*Group{group}, false, func(*Item) bool { return true }) |
| sections = append(sections, §ion{ |
| "Closed Last Week", |
| count, body, |
| }) |
| } |
| } |
| |
| var titles []string |
| for _, s := range sections { |
| if s.count > 0 { |
| titles = append(titles, fmt.Sprintf("%d %s", s.count, s.title)) |
| } |
| } |
| fmt.Fprintf(w, "%s\n", strings.Join(titles, " + ")) |
| for _, s := range sections { |
| if s.count > 0 { |
| fmt.Fprintf(w, "\n%s\n%s", s.title, s.body) |
| } |
| } |
| } |
| |
| func (d *Data) PrintCLs(w io.Writer) { |
| count, body := d.printGroups(d.GroupData(false, true), true, func(item *Item) bool { return len(item.CLs) > 0 }) |
| fmt.Fprintf(w, "%d Pending CLs\n", count) |
| fmt.Fprintf(w, "\n%s\n%s", "Pending CLs", body) |
| } |
| |
| func (d *Data) printGroups(groups []*Group, clDetail bool, match func(*Item) bool) (int, string) { |
| var output bytes.Buffer |
| var count int |
| for _, g := range groups { |
| if len(groups) != 1 && (g.Dir == "closed" || g.Dir == "proposal") { |
| // These groups shouldn't be shown when printing all groups. |
| continue |
| } |
| var header func() |
| header = func() { |
| if len(groups) > 1 { |
| fmt.Fprintf(&output, "\n%s\n", g.Dir) |
| } |
| header = func() {} |
| } |
| for _, item := range g.Items { |
| if !match(item) { |
| continue |
| } |
| printed := false |
| prefix := "" |
| if item.Issue != nil { |
| header() |
| printed = true |
| fmt.Fprintf(&output, " %-10s %s", fmt.Sprintf("#%d", item.Issue.Number), item.Issue.Title) |
| prefix = "\u2937 " |
| var tags []string |
| if strings.HasSuffix(item.Issue.Milestone, "Early") { |
| tags = append(tags, "early") |
| } |
| if strings.HasSuffix(item.Issue.Milestone, "Maybe") { |
| tags = append(tags, "maybe") |
| } |
| sort.Strings(item.Issue.Labels) |
| for _, label := range item.Issue.Labels { |
| switch label { |
| case "Documentation": |
| tags = append(tags, "doc") |
| case "Testing": |
| tags = append(tags, "test") |
| case "Started": |
| tags = append(tags, strings.ToLower(label)) |
| case "Proposal": |
| tags = append(tags, "proposal") |
| case "Proposal-Accepted": |
| tags = append(tags, "proposal-accepted") |
| case "Proposal-Declined": |
| tags = append(tags, "proposal-declined") |
| } |
| } |
| if len(tags) > 0 { |
| fmt.Fprintf(&output, " [%s]", strings.Join(tags, ", ")) |
| } |
| fmt.Fprintf(&output, "\n") |
| } |
| for _, cl := range item.CLs { |
| header() |
| printed = true |
| fmt.Fprintf(&output, " %-10s %s%s\n", fmt.Sprintf("%sCL %d", prefix, cl.Number), prefix, cl.Subject) |
| if clDetail { |
| fmt.Fprintf(&output, " %-10s %s\n", "", cl.Summary(d.Now)) |
| } |
| } |
| if printed { |
| count++ |
| } |
| } |
| } |
| return count, output.String() |
| } |