blob: 28defc7508e30e164b468c8b666473607401dc0c [file] [log] [blame]
// 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"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/build/maintner"
)
const (
labelProposal = "Proposal"
prefixProposal = "proposal:"
prefixDev = "[dev."
// The title of the current release milestone in GitHub.
curMilestoneTitle = "Go1.16"
)
// The start date of the current release milestone.
var curMilestoneStart = time.Date(2020, 8, 12, 0, 0, 0, 0, time.UTC)
// titleDirs returns a slice of prefix directories contained in a title. For
// devapp,maintner: my cool new change, it will return ["devapp", "maintner"].
// If there is no dir prefix, it will return nil.
func titleDirs(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 nil
}
var (
b bytes.Buffer
r []string
)
for j := 0; j < i; j++ {
switch title[j] {
case ' ':
continue
case ',':
r = append(r, b.String())
b.Reset()
continue
default:
b.WriteByte(title[j])
}
}
if b.Len() > 0 {
r = append(r, b.String())
}
return r
}
type releaseData struct {
LastUpdated string
Sections []section
BurndownJSON template.JS
// dirty is set if this data needs to be updated due to a corpus change.
dirty bool
}
type section struct {
Title string
Count int
Groups []group
}
type group struct {
Dir string
Items []item
}
type item struct {
Issue *maintner.GitHubIssue
CLs []*gerritCL
FirstPerformance bool // set if this item is the first item which is labeled "performance"
}
func (i *item) ReleaseBlocker() bool {
if i.Issue == nil {
return false
}
return i.Issue.HasLabel("release-blocker")
}
func (i *item) CurrentBlocker() bool {
return i.Issue.Milestone.Title == curMilestoneTitle && i.ReleaseBlocker()
}
func (i *item) EarlyInCycle() bool {
return !i.ReleaseBlocker() && i.Issue.HasLabel("early-in-cycle")
}
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 {
// Sort release-blocker issues to the front
ri := x[i].Issue != nil && x[i].Issue.HasLabel("release-blocker")
rj := x[j].Issue != nil && x[j].Issue.HasLabel("release-blocker")
if ri != rj {
return ri
}
// Sort performance issues to the end.
pi := x[i].Issue != nil && x[i].Issue.HasLabel("Performance")
pj := x[j].Issue != nil && x[j].Issue.HasLabel("Performance")
if pi != pj {
return !pi
}
// Otherwise sort by the item summary.
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 ""
}
var milestoneRE = regexp.MustCompile(`^Go1\.(\d+)(|\.(\d+))(|[A-Z].*)$`)
type milestone struct {
title string
major, minor int
}
type milestonesByGoVersion []milestone
func (x milestonesByGoVersion) Len() int { return len(x) }
func (x milestonesByGoVersion) Swap(i, j int) { x[i], x[j] = x[j], x[i] }
func (x milestonesByGoVersion) 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
}
return a.title < b.title
}
var annotationRE = regexp.MustCompile(`(?m)^R=(.+)\b`)
type gerritCL struct {
*maintner.GerritCL
NoPrefixTitle string // CL title without the directory prefix (e.g., "improve ListenAndServe" without leading "net/http: ").
Closed bool
Milestone string
}
// ReviewURL returns the code review address of cl.
func (cl *gerritCL) ReviewURL() string {
s := cl.Project.Server()
if s == "go.googlesource.com" {
return fmt.Sprintf("https://golang.org/cl/%d", cl.Number)
}
subd := strings.TrimSuffix(s, ".googlesource.com")
if subd == s {
return ""
}
return fmt.Sprintf("https://%s-review.googlesource.com/%d", subd, cl.Number)
}
// burndownData is encoded to JSON and embedded in the page for use when
// rendering a burndown chart using JavaScript.
type burndownData struct {
Milestone string `json:"milestone"`
Entries []burndownEntry `json:"entries"`
}
type burndownEntry struct {
DateStr string `json:"dateStr"` // "12-25"
Open int `json:"open"`
Blockers int `json:"blockers"`
}
func (s *server) updateReleaseData() {
log.Println("Updating release data ...")
s.cMu.Lock()
defer s.cMu.Unlock()
dirToCLs := map[string][]*gerritCL{}
issueToCLs := map[int32][]*gerritCL{}
s.corpus.Gerrit().ForeachProjectUnsorted(func(p *maintner.GerritProject) error {
p.ForeachOpenCL(func(cl *maintner.GerritCL) error {
if strings.HasPrefix(cl.Subject(), prefixDev) {
return nil
}
var (
pkgs, title = ParsePrefixedChangeTitle(projectRoot(p), cl.Subject())
closed bool
closedVersion int32
milestone string
)
for _, m := range cl.Messages {
if closed && closedVersion < m.Version {
closed = false
}
sm := annotationRE.FindStringSubmatch(m.Message)
if sm == nil {
continue
}
val := sm[1]
if val == "close" || val == "closed" {
closedVersion = m.Version
closed = true
} else if milestoneRE.MatchString(val) {
milestone = val
}
}
gcl := &gerritCL{
GerritCL: cl,
NoPrefixTitle: title,
Closed: closed,
Milestone: milestone,
}
for _, r := range cl.GitHubIssueRefs {
issueToCLs[r.Number] = append(issueToCLs[r.Number], gcl)
}
if len(pkgs) == 0 {
dirToCLs[""] = append(dirToCLs[""], gcl)
} else {
for _, p := range pkgs {
dirToCLs[p] = append(dirToCLs[p], gcl)
}
}
return nil
})
return nil
})
dirToIssues := map[string][]*maintner.GitHubIssue{}
var curMilestoneIssues []*maintner.GitHubIssue
s.repo.ForeachIssue(func(issue *maintner.GitHubIssue) error {
// Only include issues in active milestones.
if issue.Milestone.IsUnknown() || issue.Milestone.Closed || issue.Milestone.IsNone() {
return nil
}
if issue.Milestone.Title == curMilestoneTitle {
curMilestoneIssues = append(curMilestoneIssues, issue)
}
// Only open issues are displayed on the page using dirToIssues.
if issue.Closed {
return nil
}
dirs := titleDirs(issue.Title)
if len(dirs) == 0 {
dirToIssues[""] = append(dirToIssues[""], issue)
} else {
for _, d := range dirs {
dirToIssues[d] = append(dirToIssues[d], issue)
}
}
return nil
})
bd := burndownData{Milestone: curMilestoneTitle}
for t, now := curMilestoneStart, time.Now(); t.Before(now); t = t.Add(24 * time.Hour) {
var e burndownEntry
for _, issue := range curMilestoneIssues {
if issue.Created.After(t) || (issue.Closed && issue.ClosedAt.Before(t)) {
continue
}
if issue.HasLabel("release-blocker") {
e.Blockers++
}
e.Open++
}
e.DateStr = t.Format("01-02")
bd.Entries = append(bd.Entries, e)
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(bd); err != nil {
log.Printf("json.Encode: %v", err)
}
s.data.release.BurndownJSON = template.JS(buf.String())
s.data.release.Sections = nil
s.appendOpenIssues(dirToIssues, issueToCLs)
s.appendPendingCLs(dirToCLs)
s.appendPendingProposals(issueToCLs)
s.appendClosedIssues()
s.data.release.LastUpdated = time.Now().UTC().Format(time.UnixDate)
s.data.release.dirty = false
}
// projectRoot returns the import path corresponding to the repo root
// of the Gerrit project p. For golang.org/x subrepos, the golang.org
// part is omitted for previty.
func projectRoot(p *maintner.GerritProject) string {
switch p.Server() {
case "go.googlesource.com":
switch subrepo := p.Project(); subrepo {
case "go":
// Main Go repo.
return ""
case "dl":
// dl is a special subrepo, there's no /x/ in its import path.
return "golang.org/dl"
case "gddo":
// There is no golang.org/x/gddo vanity import path, and
// the canonical import path for gddo is on GitHub.
return "github.com/golang/gddo"
default:
// For brevity, use x/subrepo rather than golang.org/x/subrepo.
return "x/" + subrepo
}
case "code.googlesource.com":
switch p.Project() {
case "gocloud":
return "cloud.google.com/go"
case "google-api-go-client":
return "google.golang.org/api"
}
}
return p.ServerSlashProject()
}
// requires s.cMu be locked.
func (s *server) appendOpenIssues(dirToIssues map[string][]*maintner.GitHubIssue, issueToCLs map[int32][]*gerritCL) {
var issueDirs []string
for d := range dirToIssues {
issueDirs = append(issueDirs, d)
}
sort.Strings(issueDirs)
ms := s.allMilestones()
for _, m := range ms {
var (
issueGroups []group
issueCount int
)
for _, d := range issueDirs {
issues, ok := dirToIssues[d]
if !ok {
continue
}
var items []item
for _, i := range issues {
if i.Milestone.Title != m.title {
continue
}
items = append(items, item{
Issue: i,
CLs: issueToCLs[i.Number],
})
issueCount++
}
if len(items) == 0 {
continue
}
sort.Sort(itemsBySummary(items))
for idx := range items {
if items[idx].Issue.HasLabel("Performance") && !items[idx].Issue.HasLabel("release-blocker") {
items[idx].FirstPerformance = true
break
}
}
issueGroups = append(issueGroups, group{
Dir: d,
Items: items,
})
}
s.data.release.Sections = append(s.data.release.Sections, section{
Title: m.title,
Count: issueCount,
Groups: issueGroups,
})
}
}
// requires s.cMu be locked.
func (s *server) appendPendingCLs(dirToCLs map[string][]*gerritCL) {
var clDirs []string
for d := range dirToCLs {
clDirs = append(clDirs, d)
}
sort.Strings(clDirs)
var (
clGroups []group
clCount int
)
for _, d := range clDirs {
if cls, ok := dirToCLs[d]; ok {
clCount += len(cls)
g := group{Dir: d}
g.Items = append(g.Items, item{CLs: cls})
sort.Sort(itemsBySummary(g.Items))
clGroups = append(clGroups, g)
}
}
s.data.release.Sections = append(s.data.release.Sections, section{
Title: "Pending CLs",
Count: clCount,
Groups: clGroups,
})
}
// requires s.cMu be locked.
func (s *server) appendPendingProposals(issueToCLs map[int32][]*gerritCL) {
var proposals group
s.repo.ForeachIssue(func(issue *maintner.GitHubIssue) error {
if issue.Closed {
return nil
}
if issue.HasLabel(labelProposal) || strings.HasPrefix(issue.Title, prefixProposal) {
proposals.Items = append(proposals.Items, item{
Issue: issue,
CLs: issueToCLs[issue.Number],
})
}
return nil
})
sort.Sort(itemsBySummary(proposals.Items))
s.data.release.Sections = append(s.data.release.Sections, section{
Title: "Pending Proposals",
Count: len(proposals.Items),
Groups: []group{proposals},
})
}
// requires s.cMu be locked.
func (s *server) appendClosedIssues() {
var (
closed group
lastWeek = time.Now().Add(-(7*24 + 12) * time.Hour)
)
s.repo.ForeachIssue(func(issue *maintner.GitHubIssue) error {
if !issue.Closed {
return nil
}
if issue.Updated.After(lastWeek) {
closed.Items = append(closed.Items, item{Issue: issue})
}
return nil
})
sort.Sort(itemsBySummary(closed.Items))
s.data.release.Sections = append(s.data.release.Sections, section{
Title: "Closed Last Week",
Count: len(closed.Items),
Groups: []group{closed},
})
}
// requires s.cMu be read locked.
func (s *server) allMilestones() []milestone {
var ms []milestone
s.repo.ForeachMilestone(func(m *maintner.GitHubMilestone) error {
if m.Closed {
return nil
}
sm := milestoneRE.FindStringSubmatch(m.Title)
if sm == nil {
return nil
}
major, _ := strconv.Atoi(sm[1])
minor, _ := strconv.Atoi(sm[3])
ms = append(ms, milestone{
title: m.Title,
major: major,
minor: minor,
})
return nil
})
sort.Sort(milestonesByGoVersion(ms))
return ms
}
// handleRelease serves dev.golang.org/release.
func (s *server) handleRelease(t *template.Template, w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
s.cMu.RLock()
dirty := s.data.release.dirty
s.cMu.RUnlock()
if dirty {
s.updateReleaseData()
}
s.cMu.RLock()
defer s.cMu.RUnlock()
if err := t.Execute(w, s.data.release); err != nil {
log.Printf("t.Execute(w, nil) = %v", err)
return
}
}