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")