golang.org/x/build@v0.0.0-20240506185731-218518f32b70/devapp/release.go (about) 1 // Copyright 2017 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package main 6 7 import ( 8 "bytes" 9 "encoding/json" 10 "errors" 11 "fmt" 12 "html/template" 13 "log" 14 "net/http" 15 "regexp" 16 "sort" 17 "strconv" 18 "strings" 19 "time" 20 21 "golang.org/x/build/maintner" 22 "golang.org/x/build/maintner/maintnerd/maintapi/version" 23 ) 24 25 const ( 26 labelProposal = "Proposal" 27 28 prefixProposal = "proposal:" 29 prefixDev = "[dev." 30 ) 31 32 // titleDirs returns a slice of prefix directories contained in a title. For 33 // devapp,maintner: my cool new change, it will return ["devapp", "maintner"]. 34 // If there is no dir prefix, it will return nil. 35 func titleDirs(title string) []string { 36 if i := strings.Index(title, "\n"); i >= 0 { 37 title = title[:i] 38 } 39 title = strings.TrimSpace(title) 40 i := strings.Index(title, ":") 41 if i < 0 { 42 return nil 43 } 44 var ( 45 b bytes.Buffer 46 r []string 47 ) 48 for j := 0; j < i; j++ { 49 switch title[j] { 50 case ' ': 51 continue 52 case ',': 53 r = append(r, b.String()) 54 b.Reset() 55 continue 56 default: 57 b.WriteByte(title[j]) 58 } 59 } 60 if b.Len() > 0 { 61 r = append(r, b.String()) 62 } 63 return r 64 } 65 66 type releaseData struct { 67 LastUpdated string 68 Sections []section 69 BurndownJSON template.JS 70 CurMilestone string // The title of the current release milestone in GitHub. For example, "Go1.18". 71 72 // dirty is set if this data needs to be updated due to a corpus change. 73 dirty bool 74 } 75 76 type section struct { 77 Title string 78 Count int 79 Groups []group 80 } 81 82 type group struct { 83 Dir string 84 Items []item 85 } 86 87 type item struct { 88 Issue *maintner.GitHubIssue 89 CLs []*gerritCL 90 FirstPerformance bool // set if this item is the first item which is labeled "performance" 91 } 92 93 func (i *item) ReleaseBlocker() bool { 94 if i.Issue == nil { 95 return false 96 } 97 return i.Issue.HasLabel("release-blocker") 98 } 99 100 func (i *item) EarlyInCycle() bool { 101 return !i.ReleaseBlocker() && i.Issue.HasLabel("early-in-cycle") 102 } 103 104 type itemsBySummary []item 105 106 func (x itemsBySummary) Len() int { return len(x) } 107 func (x itemsBySummary) Swap(i, j int) { x[i], x[j] = x[j], x[i] } 108 func (x itemsBySummary) Less(i, j int) bool { 109 // Sort release-blocker issues to the front 110 ri := x[i].Issue != nil && x[i].Issue.HasLabel("release-blocker") 111 rj := x[j].Issue != nil && x[j].Issue.HasLabel("release-blocker") 112 if ri != rj { 113 return ri 114 } 115 // Sort performance issues to the end. 116 pi := x[i].Issue != nil && x[i].Issue.HasLabel("Performance") 117 pj := x[j].Issue != nil && x[j].Issue.HasLabel("Performance") 118 if pi != pj { 119 return !pi 120 } 121 // Otherwise sort by the item summary. 122 return itemSummary(x[i]) < itemSummary(x[j]) 123 } 124 125 func itemSummary(it item) string { 126 if it.Issue != nil { 127 return it.Issue.Title 128 } 129 for _, cl := range it.CLs { 130 return cl.Subject() 131 } 132 return "" 133 } 134 135 var milestoneRE = regexp.MustCompile(`^Go1\.(\d+)(|\.(\d+))(|[A-Z].*)$`) 136 137 type milestone struct { 138 title string 139 major, minor int 140 } 141 142 type milestonesByGoVersion []milestone 143 144 func (x milestonesByGoVersion) Len() int { return len(x) } 145 func (x milestonesByGoVersion) Swap(i, j int) { x[i], x[j] = x[j], x[i] } 146 func (x milestonesByGoVersion) Less(i, j int) bool { 147 a, b := x[i], x[j] 148 if a.major != b.major { 149 return a.major < b.major 150 } 151 if a.minor != b.minor { 152 return a.minor < b.minor 153 } 154 return a.title < b.title 155 } 156 157 var annotationRE = regexp.MustCompile(`(?m)^R=(.+)\b`) 158 159 type gerritCL struct { 160 *maintner.GerritCL 161 NoPrefixTitle string // CL title without the directory prefix (e.g., "improve ListenAndServe" without leading "net/http: "). 162 Closed bool 163 Milestone string 164 } 165 166 // ReviewURL returns the code review address of cl. 167 func (cl *gerritCL) ReviewURL() string { 168 s := cl.Project.Server() 169 if s == "go.googlesource.com" { 170 return fmt.Sprintf("https://golang.org/cl/%d", cl.Number) 171 } 172 subd := strings.TrimSuffix(s, ".googlesource.com") 173 if subd == s { 174 return "" 175 } 176 return fmt.Sprintf("https://%s-review.googlesource.com/%d", subd, cl.Number) 177 } 178 179 // burndownData is encoded to JSON and embedded in the page for use when 180 // rendering a burndown chart using JavaScript. 181 type burndownData struct { 182 Milestone string `json:"milestone"` 183 Entries []burndownEntry `json:"entries"` 184 } 185 186 type burndownEntry struct { 187 DateStr string `json:"dateStr"` // "12-25" 188 Open int `json:"open"` 189 Blockers int `json:"blockers"` 190 } 191 192 func (s *server) updateReleaseData() { 193 log.Println("Updating release data ...") 194 s.cMu.Lock() 195 defer s.cMu.Unlock() 196 197 dirToCLs := map[string][]*gerritCL{} 198 issueToCLs := map[int32][]*gerritCL{} 199 s.corpus.Gerrit().ForeachProjectUnsorted(func(p *maintner.GerritProject) error { 200 p.ForeachOpenCL(func(cl *maintner.GerritCL) error { 201 if strings.HasPrefix(cl.Subject(), prefixDev) { 202 return nil 203 } 204 205 var ( 206 pkgs, title = ParsePrefixedChangeTitle(projectRoot(p), cl.Subject()) 207 closed bool 208 closedVersion int32 209 milestone string 210 ) 211 for _, m := range cl.Messages { 212 if closed && closedVersion < m.Version { 213 closed = false 214 } 215 sm := annotationRE.FindStringSubmatch(m.Message) 216 if sm == nil { 217 continue 218 } 219 val := sm[1] 220 if val == "close" || val == "closed" { 221 closedVersion = m.Version 222 closed = true 223 } else if milestoneRE.MatchString(val) { 224 milestone = val 225 } 226 } 227 gcl := &gerritCL{ 228 GerritCL: cl, 229 NoPrefixTitle: title, 230 Closed: closed, 231 Milestone: milestone, 232 } 233 234 for _, r := range cl.GitHubIssueRefs { 235 issueToCLs[r.Number] = append(issueToCLs[r.Number], gcl) 236 } 237 if len(pkgs) == 0 { 238 dirToCLs[""] = append(dirToCLs[""], gcl) 239 } else { 240 for _, p := range pkgs { 241 dirToCLs[p] = append(dirToCLs[p], gcl) 242 } 243 } 244 return nil 245 }) 246 return nil 247 }) 248 249 // Determine current milestone based on the highest go1.X tag. 250 var highestGo1X int 251 s.proj.ForeachNonChangeRef(func(ref string, _ maintner.GitHash) error { 252 if !strings.HasPrefix(ref, "refs/tags/go1.") { 253 return nil 254 } 255 tagName := ref[len("refs/tags/"):] 256 if _, x, _, ok := version.ParseTag(tagName); ok && x > highestGo1X { 257 highestGo1X = x 258 } 259 return nil 260 }) 261 // The title of the current release milestone in GitHub. For example, "Go1.18". 262 curMilestoneTitle := fmt.Sprintf("Go1.%d", highestGo1X+1) 263 // The start date of the current release milestone, approximated by taking the 264 // Go 1.17 release date, and adding 6 months for each successive major release. 265 var monthsSinceGo117Release = time.Month(6 * (highestGo1X - 17)) 266 curMilestoneStart := time.Date(2021, time.August+monthsSinceGo117Release, 1, 0, 0, 0, 0, time.UTC) 267 268 dirToIssues := map[string][]*maintner.GitHubIssue{} 269 s.repo.ForeachIssue(func(issue *maintner.GitHubIssue) error { 270 // Only open issues in active milestones are displayed on the page using dirToIssues. 271 if issue.Closed || 272 issue.Milestone.IsUnknown() || issue.Milestone.Closed || issue.Milestone.IsNone() { 273 return nil 274 } 275 dirs := titleDirs(issue.Title) 276 if len(dirs) == 0 { 277 dirToIssues[""] = append(dirToIssues[""], issue) 278 } else { 279 for _, d := range dirs { 280 dirToIssues[d] = append(dirToIssues[d], issue) 281 } 282 } 283 return nil 284 }) 285 286 // Find issues that have been in the current milestone. 287 var curMilestoneIssues []*maintner.GitHubIssue 288 s.repo.ForeachIssue(func(issue *maintner.GitHubIssue) error { 289 if issue.Closed && issue.ClosedAt.Before(curMilestoneStart) { 290 // Old issue, couldn't be relevant to current milestone. 291 return nil 292 } 293 if !issue.Milestone.IsUnknown() && issue.Milestone.Title == curMilestoneTitle { 294 // Easy case: the issue is still in current milestone. 295 curMilestoneIssues = append(curMilestoneIssues, issue) 296 return nil 297 } 298 // Check if the issue was ever in the current milestone. 299 issue.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { 300 if e.Type == "milestoned" && e.Milestone == curMilestoneTitle { 301 curMilestoneIssues = append(curMilestoneIssues, issue) 302 return errStopIteration 303 } 304 return nil 305 }) 306 return nil 307 }) 308 309 bd := burndownData{Milestone: curMilestoneTitle} 310 for t, now := curMilestoneStart, time.Now(); t.Before(now); t = t.Add(24 * time.Hour) { 311 var e burndownEntry 312 for _, issue := range curMilestoneIssues { 313 if issue.Created.After(t) || (issue.Closed && issue.ClosedAt.Before(t)) { 314 continue 315 } 316 var inCurMilestoneAtT bool 317 issue.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { 318 if e.Created.After(t) { 319 return errStopIteration 320 } 321 switch e.Type { 322 case "milestoned": 323 inCurMilestoneAtT = e.Milestone == curMilestoneTitle 324 case "demilestoned": 325 inCurMilestoneAtT = false 326 } 327 return nil 328 }) 329 if !inCurMilestoneAtT { 330 continue 331 } 332 if issue.HasLabel("release-blocker") { 333 e.Blockers++ 334 } 335 e.Open++ 336 } 337 e.DateStr = t.Format("01-02") 338 bd.Entries = append(bd.Entries, e) 339 } 340 341 var buf bytes.Buffer 342 if err := json.NewEncoder(&buf).Encode(bd); err != nil { 343 log.Printf("json.Encode: %v", err) 344 } 345 s.data.release.BurndownJSON = template.JS(buf.String()) 346 s.data.release.Sections = nil 347 s.appendOpenIssues(dirToIssues, issueToCLs) 348 s.appendPendingCLs(dirToCLs) 349 s.appendPendingProposals(issueToCLs) 350 s.appendClosedIssues() 351 s.data.release.CurMilestone = curMilestoneTitle 352 s.data.release.LastUpdated = time.Now().UTC().Format(time.UnixDate) 353 s.data.release.dirty = false 354 } 355 356 // projectRoot returns the import path corresponding to the repo root 357 // of the Gerrit project p. For golang.org/x subrepos, the golang.org 358 // part is omitted for previty. 359 func projectRoot(p *maintner.GerritProject) string { 360 switch p.Server() { 361 case "go.googlesource.com": 362 switch subrepo := p.Project(); subrepo { 363 case "go": 364 // Main Go repo. 365 return "" 366 case "dl": 367 // dl is a special subrepo, there's no /x/ in its import path. 368 return "golang.org/dl" 369 case "gddo": 370 // There is no golang.org/x/gddo vanity import path, and 371 // the canonical import path for gddo is on GitHub. 372 return "github.com/golang/gddo" 373 default: 374 // For brevity, use x/subrepo rather than golang.org/x/subrepo. 375 return "x/" + subrepo 376 } 377 case "code.googlesource.com": 378 switch p.Project() { 379 case "gocloud": 380 return "cloud.google.com/go" 381 case "google-api-go-client": 382 return "google.golang.org/api" 383 } 384 } 385 return p.ServerSlashProject() 386 } 387 388 // requires s.cMu be locked. 389 func (s *server) appendOpenIssues(dirToIssues map[string][]*maintner.GitHubIssue, issueToCLs map[int32][]*gerritCL) { 390 var issueDirs []string 391 for d := range dirToIssues { 392 issueDirs = append(issueDirs, d) 393 } 394 sort.Strings(issueDirs) 395 ms := s.allMilestones() 396 for _, m := range ms { 397 var ( 398 issueGroups []group 399 issueCount int 400 ) 401 for _, d := range issueDirs { 402 issues, ok := dirToIssues[d] 403 if !ok { 404 continue 405 } 406 var items []item 407 for _, i := range issues { 408 if i.Milestone.Title != m.title { 409 continue 410 } 411 412 items = append(items, item{ 413 Issue: i, 414 CLs: issueToCLs[i.Number], 415 }) 416 issueCount++ 417 } 418 if len(items) == 0 { 419 continue 420 } 421 sort.Sort(itemsBySummary(items)) 422 for idx := range items { 423 if items[idx].Issue.HasLabel("Performance") && !items[idx].Issue.HasLabel("release-blocker") { 424 items[idx].FirstPerformance = true 425 break 426 } 427 } 428 issueGroups = append(issueGroups, group{ 429 Dir: d, 430 Items: items, 431 }) 432 } 433 s.data.release.Sections = append(s.data.release.Sections, section{ 434 Title: m.title, 435 Count: issueCount, 436 Groups: issueGroups, 437 }) 438 } 439 } 440 441 // requires s.cMu be locked. 442 func (s *server) appendPendingCLs(dirToCLs map[string][]*gerritCL) { 443 var clDirs []string 444 for d := range dirToCLs { 445 clDirs = append(clDirs, d) 446 } 447 sort.Strings(clDirs) 448 var ( 449 clGroups []group 450 clCount int 451 ) 452 for _, d := range clDirs { 453 if cls, ok := dirToCLs[d]; ok { 454 clCount += len(cls) 455 g := group{Dir: d} 456 g.Items = append(g.Items, item{CLs: cls}) 457 sort.Sort(itemsBySummary(g.Items)) 458 clGroups = append(clGroups, g) 459 } 460 } 461 s.data.release.Sections = append(s.data.release.Sections, section{ 462 Title: "Pending CLs", 463 Count: clCount, 464 Groups: clGroups, 465 }) 466 } 467 468 // requires s.cMu be locked. 469 func (s *server) appendPendingProposals(issueToCLs map[int32][]*gerritCL) { 470 var proposals group 471 s.repo.ForeachIssue(func(issue *maintner.GitHubIssue) error { 472 if issue.Closed { 473 return nil 474 } 475 if issue.HasLabel(labelProposal) || strings.HasPrefix(issue.Title, prefixProposal) { 476 proposals.Items = append(proposals.Items, item{ 477 Issue: issue, 478 CLs: issueToCLs[issue.Number], 479 }) 480 } 481 return nil 482 }) 483 sort.Sort(itemsBySummary(proposals.Items)) 484 s.data.release.Sections = append(s.data.release.Sections, section{ 485 Title: "Pending Proposals", 486 Count: len(proposals.Items), 487 Groups: []group{proposals}, 488 }) 489 } 490 491 // requires s.cMu be locked. 492 func (s *server) appendClosedIssues() { 493 var ( 494 closed group 495 lastWeek = time.Now().Add(-(7*24 + 12) * time.Hour) 496 ) 497 s.repo.ForeachIssue(func(issue *maintner.GitHubIssue) error { 498 if !issue.Closed { 499 return nil 500 } 501 if issue.Updated.After(lastWeek) { 502 closed.Items = append(closed.Items, item{Issue: issue}) 503 } 504 return nil 505 }) 506 sort.Sort(itemsBySummary(closed.Items)) 507 s.data.release.Sections = append(s.data.release.Sections, section{ 508 Title: "Closed Last Week", 509 Count: len(closed.Items), 510 Groups: []group{closed}, 511 }) 512 } 513 514 // requires s.cMu be read locked. 515 func (s *server) allMilestones() []milestone { 516 var ms []milestone 517 s.repo.ForeachMilestone(func(m *maintner.GitHubMilestone) error { 518 if m.Closed { 519 return nil 520 } 521 sm := milestoneRE.FindStringSubmatch(m.Title) 522 if sm == nil { 523 return nil 524 } 525 major, _ := strconv.Atoi(sm[1]) 526 minor, _ := strconv.Atoi(sm[3]) 527 ms = append(ms, milestone{ 528 title: m.Title, 529 major: major, 530 minor: minor, 531 }) 532 return nil 533 }) 534 sort.Sort(milestonesByGoVersion(ms)) 535 return ms 536 } 537 538 // handleRelease serves dev.golang.org/release. 539 func (s *server) handleRelease(t *template.Template, w http.ResponseWriter, r *http.Request) { 540 w.Header().Set("Content-Type", "text/html; charset=utf-8") 541 s.cMu.RLock() 542 dirty := s.data.release.dirty 543 s.cMu.RUnlock() 544 if dirty { 545 s.updateReleaseData() 546 } 547 548 s.cMu.RLock() 549 defer s.cMu.RUnlock() 550 if err := t.Execute(w, s.data.release); err != nil { 551 log.Printf("t.Execute(w, nil) = %v", err) 552 return 553 } 554 } 555 556 // errStopIteration is used to stop iteration over issues or comments. 557 // It has no special meaning. 558 var errStopIteration = errors.New("stop iteration")