commit 827d49be6814f7c36c53ffdef2a667366ba2aef9
parent 4171be12bcdfdcd997aefa33fd0933c43b8543ea
Author: Russ Cox <rsc@golang.org>
Date: Sat, 25 Apr 2015 21:00:16 -0400
godash: initial version of new Go dashboards
Diffstat:
| A | godash/main.go | | | 458 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 458 insertions(+), 0 deletions(-)
diff --git a/godash/main.go b/godash/main.go
@@ -0,0 +1,458 @@
+// 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.
+
+// Godash generates Go dashboards about issues and CLs.
+//
+// Usage:
+//
+// godash [-cl] [-html] [-rcache] [-wcache]
+//
+// By default, godash prints a textual release dashboard to standard output.
+// The release dashboard shows all open issues in the milestones for the upcoming
+// release (currently Go 1.5), along with all open CLs mentioning those issues,
+// and all other open CLs working in the main Go repository.
+//
+// If the -cl flag is specified, godash instead prints a CL dashboard, showing all
+// open CLs, along with information about review status and review latency.
+//
+// If the -html flag is specified, godash prints HTML instead of text.
+//
+// Godash expects to find golang.org/x/build/cmd/cl and rsc.io/github/issue
+// on its $PATH, to read data from Gerrit and GitHub.
+// If the -wcache flag is specified, godash writes gathered data to $HOME/.godash-cache.
+// If the -rcache flag is specified, godash reads data from $HOME/.godash-cache
+// instead of Gerrit and GitHub. These flags are useful to avoid network delays and
+// ensure consistency when generating multiple forms of dashboard; they are also
+// useful when adjusting the output code.
+//
+// https://swtch.com/godash is periodically updated with the HTML versions of
+// the two dashboards.
+//
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "html"
+ "io/ioutil"
+ "log"
+ "os"
+ "os/exec"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+const Release = "Go1.5"
+
+type CL struct {
+ Number int
+ Subject string
+ Project string
+ Author string
+ Reviewer string
+ NeedsReview bool
+ NeedsReviewChanged time.Time
+ Start time.Time
+ Issues []int
+ Closed bool
+ Scores map[string]int
+ Files []string
+}
+
+type Issue struct {
+ Number int
+ Title string
+ Labels []string
+ Assignee string
+ Milestone string
+}
+
+type Group struct {
+ Dir string
+ Items []*Item
+}
+
+type Item struct {
+ Issue *Issue
+ CLs []*CL
+}
+
+var (
+ cls []*CL
+ issues []*Issue
+ maybe []*Issue
+ groups []*Group
+ output bytes.Buffer
+ skipCL int
+
+ now = time.Now()
+
+ flagCL = flag.Bool("cl", false, "print CLs only (no issues)")
+ flagHTML = flag.Bool("html", false, "print HTML output")
+
+ cache = map[string]string{}
+ cacheFile = os.Getenv("HOME") + "/.godash-cache"
+ readCache = flag.Bool("rcache", false, "read from cached copy of data")
+ writeCache = flag.Bool("wcache", false, "write cached copy of data")
+)
+
+func main() {
+ log.SetFlags(0)
+ log.SetPrefix("godash: ")
+ flag.Parse()
+ if flag.NArg() != 0 {
+ flag.Usage()
+ }
+ fetchData()
+ groupData()
+ what := "release"
+ if *flagCL {
+ what = "CL"
+ }
+ fmt.Fprintf(&output, "Go %s dashboard\n", what)
+ fmt.Fprintf(&output, "%v\n\n", time.Now().UTC().Format(time.UnixDate))
+ if *flagHTML {
+ fmt.Fprintf(&output, "HOWTO\n\n")
+ }
+ if *flagCL {
+ fmt.Fprintf(&output, "%d CLs\n", len(cls)-skipCL)
+ } else {
+ fmt.Fprintf(&output, "%d %s + %d %sMaybe + %d CLs\n", len(issues)-len(maybe), Release, len(maybe), Release, len(cls)-skipCL)
+ }
+ printGroups()
+ if *flagHTML {
+ printHTML()
+ return
+ }
+ os.Stdout.Write(output.Bytes())
+}
+
+func printHTML() {
+ data := html.EscapeString(output.String())
+ i := strings.Index(data, "\n")
+ if i < 0 {
+ i = len(data)
+ }
+ fmt.Printf("<html>\n")
+ fmt.Printf("<title>%s</title>\n", data[:i])
+ fmt.Printf("<style>\n")
+ fmt.Printf(".maybe {color: #777}\n")
+ fmt.Printf(".late {color: #700; text-decoration: underline;}\n")
+ fmt.Printf("</style>\n")
+ fmt.Printf("<pre>\n")
+ data = regexp.MustCompile(`(?m)^HOWTO`).ReplaceAllString(data, `<a href="index.html">about the dashboard</a>`)
+ data = regexp.MustCompile(`(CL (\d+))\b`).ReplaceAllString(data, "<a href='https://golang.org/cl/$2'>$1</a>")
+ data = regexp.MustCompile(`(#(\d\d\d+))\b`).ReplaceAllString(data, "<a href='https://golang.org/issue/$2'>$1</a>")
+ data = regexp.MustCompile(`(?m)^([\?A-Za-z0-9][^\n]*)`).ReplaceAllString(data, "<b>$1</b>")
+ data = regexp.MustCompile(`(?m)^([^\n]*\[maybe[^\n]*)`).ReplaceAllString(data, "<span class='maybe'>$1</span>")
+ data = regexp.MustCompile(`(?m)^( +)(.*)( → )(.*)(, [\d/]+ days)(, waiting for reviewer)`).ReplaceAllString(data, "$1$2$3<b>$4</b>$5$6")
+ data = regexp.MustCompile(`(?m)^( +)(.*)( → )(.*)(, [\d/]+ days)(, waiting for author)`).ReplaceAllString(data, "$1<b>$2</b>$3$4$5$6")
+ data = regexp.MustCompile(`(→ )(.*, \d\d+)(/\d+ days)(, waiting for reviewer)`).ReplaceAllString(data, "$1<b class='late'>$2</b>$3$4")
+ fmt.Printf("%s\n", data)
+}
+
+func fetchData() {
+ if *readCache {
+ data, err := ioutil.ReadFile(cacheFile)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := json.Unmarshal(data, &cache); err != nil {
+ log.Fatal("loading cache: %v", err)
+ }
+ }
+
+ readJSON(&cls, "CLs", "cl", "-json")
+ if !*flagCL {
+ readJSON(&issues, Release+" issues", "issue", "-json", "milestone:"+Release)
+ readJSON(&maybe, Release+"Maybe issues", "issue", "-json", "milestone:"+Release+"Maybe")
+ }
+ issues = append(issues, maybe...)
+
+ if *writeCache {
+ flushCache()
+ }
+}
+
+func flushCache() {
+ data, err := json.Marshal(cache)
+ if err != nil {
+ log.Fatal("marshaling cache: %v", err)
+ }
+ if err := ioutil.WriteFile(cacheFile, data, 0666); err != nil {
+ log.Fatal("writing cache: %v", err)
+ }
+}
+
+func readJSON(dst interface{}, desc string, cmd ...string) {
+ var data []byte
+ if *readCache {
+ data = []byte(cache[desc])
+ if len(data) == 0 {
+ log.Fatalf("%s not cached", desc)
+ }
+ } else {
+ log.Printf("RUN %q", cmd)
+ var err error
+ data, err = exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
+ if err != nil {
+ log.Fatalf("fetching %s: %v\n%s", desc, err, data)
+ }
+ }
+ if *writeCache {
+ cache[desc] = string(data)
+ }
+ if err := json.Unmarshal(data, dst); err != nil {
+ log.Fatalf("parsing %s: %v", desc, err)
+ }
+}
+
+func groupData() {
+ 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{}
+
+ for _, issue := range issues {
+ item := &Item{Issue: issue}
+ addGroup(item)
+ itemsByBug[issue.Number] = item
+ }
+
+ for _, cl := range 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" || *flagCL {
+ item := &Item{CLs: []*CL{cl}}
+ addGroup(item)
+ } else {
+ skipCL++
+ }
+ }
+ }
+
+ var keys []string
+ for key, g := range groupsByDir {
+ sort.Sort(itemsBySummary(g.Items))
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ groups = append(groups, groupsByDir[key])
+ }
+}
+
+func printGroups() {
+ for _, g := range groups {
+ fmt.Fprintf(&output, "\n%s\n", g.Dir)
+ for _, item := range g.Items {
+ prefix := ""
+ if item.Issue != nil {
+ 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, "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))
+ }
+ }
+ if len(tags) > 0 {
+ fmt.Fprintf(&output, " [%s]", strings.Join(tags, ", "))
+ }
+ fmt.Fprintf(&output, "\n")
+ }
+ for _, cl := range item.CLs {
+ fmt.Fprintf(&output, " %-10s %s%s\n", fmt.Sprintf("%sCL %d", prefix, cl.Number), prefix, cl.Subject)
+ if *flagCL {
+ fmt.Fprintf(&output, " %-10s %s\n", "", cl.Status())
+ }
+ }
+ }
+ }
+}
+
+var okDesc = map[string]bool{
+ "all": true,
+ "build": true,
+}
+
+func (item *Item) Dir() string {
+ for _, cl := range item.CLs {
+ 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 dir := titleDir(item.Issue.Title); dir != "" {
+ return dir
+ }
+ return "?"
+ }
+ return "?"
+}
+
+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
+}
+
+func (cl *CL) Status() string {
+ var buf bytes.Buffer
+ who := "author"
+ if cl.NeedsReview {
+ who = "reviewer"
+ }
+ rev := cl.Reviewer
+ if rev == "" {
+ rev = "???"
+ }
+ fmt.Fprintf(&buf, "%s → %s, %d/%d days, waiting for %s", cl.Author, rev, int(now.Sub(cl.NeedsReviewChanged).Seconds()/86400), int(now.Sub(cl.Start).Seconds()/86400), who)
+ for _, id := range cl.Issues {
+ fmt.Fprintf(&buf, " #%d", id)
+ }
+ return buf.String()
+}