blob: c1684ba13d7d4701e14a04382c8a1b831d89beeb [file] [log] [blame]
// Copyright 2021 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.
// The relnotes command summarizes the Go changes in Gerrit marked with
// RELNOTE annotations for the release notes.
package main
import (
"bytes"
"context"
"flag"
"fmt"
"io/ioutil"
"log"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
"golang.org/x/build/maintner"
"golang.org/x/build/maintner/godata"
)
var (
milestone = flag.String("milestone", "", "milestone associated with the release")
filterDirs = flag.String("dirs", "", "comma-separated list of directories that should be touched for a CL to be considered relevant")
sinceCL = flag.Int("cl", -1, "the gerrit change number of the first CL to include in the output. Only changes submitted more recently than 'cl' will be included.")
project = flag.String("project", "vscode-go", "name of the golang project")
mdMode = flag.Bool("md", false, "write MD output")
exclFile = flag.String("exclude-from", "", "optional path to changelog MD file. If specified, any 'CL NNNN' occurence in the content will cause that CL to be excluded from this tool's output.")
)
// change is a change that has occurred since the last release.
type change struct {
CL *maintner.GerritCL
Note string // the part after RELNOTE=
Issues []*issue
pkg string
}
func (c change) TextLine() string {
subj := c.CL.Subject()
subj = c.Note + ": " + subj
return fmt.Sprintf("https://golang.org/cl/%d: %s", c.CL.Number, subj)
}
type issue struct {
*maintner.GitHubIssue
repo string
owner string
}
func (i *issue) link() string {
if i.owner == "golang" && i.repo == "go" {
return fmt.Sprintf("https://golang.org/issue/%v", i.Number)
}
return fmt.Sprintf("https://github.com/%s/%s/issues/%v", i.owner, i.repo, i.Number)
}
func main() {
flag.Parse()
var existingMD []byte
if *exclFile != "" {
var err error
existingMD, err = ioutil.ReadFile(*exclFile)
if err != nil {
log.Fatal(err)
}
}
corpus, err := godata.Get(context.Background())
if err != nil {
log.Fatal(err)
}
var dirs []string
for _, dir := range strings.FieldsFunc(*filterDirs, func(r rune) bool {
return unicode.IsSpace(r) || r == ','
}) {
dirs = append(dirs, filepath.ToSlash(dir))
}
ger := corpus.Gerrit()
// Find the cutoff time for changes to include.
start := time.Date(2020, time.August, 1, 00, 00, 00, 0, time.UTC)
ger.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
if gp.Server() != "go.googlesource.com" || gp.Project() != *project {
return nil
}
gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
if cl.Status != "merged" {
return nil
}
if *sinceCL >= 0 {
if int(cl.Number) == *sinceCL {
start = cl.Commit.CommitTime
}
} else if cl.Branch() == "release" && cl.Commit.CommitTime.After(start) {
// Try to figure out when the last release was
fmt.Println(cl.Commit.CommitTime)
start = cl.Commit.CommitTime
}
return nil
})
return nil
})
var changes []*change
authors := map[*maintner.GitPerson]bool{}
ger.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
if gp.Server() != "go.googlesource.com" || gp.Project() != *project {
return nil
}
gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
// Only include 'master'
if cl.Branch() != "master" {
return nil
}
if cl.Status != "merged" {
return nil
}
if cl.Commit.CommitTime.Before(start) {
// Was in a previous release; not for this one.
return nil
}
if bytes.Contains(existingMD, []byte(fmt.Sprintf("CL %d ", cl.Number))) {
return nil
}
// Check that at least one file is in a relevant directory before
// adding the CL.
if len(dirs) > 0 {
var found bool
for _, file := range cl.Commit.Files {
for _, dir := range dirs {
if strings.Contains(file.File, dir) {
found = true
break
}
}
}
if !found {
return nil
}
}
// try to determine type from issue labels
var issues []*issue
for _, ref := range cl.GitHubIssueRefs {
i := ref.Repo.Issue(ref.Number)
// Don't include pull requests.
if i.PullRequest {
continue
}
issues = append(issues, &issue{
repo: ref.Repo.ID().Repo,
owner: ref.Repo.ID().Owner,
GitHubIssue: i,
})
}
changes = append(changes, &change{
Note: clRelNote(cl),
CL: cl,
Issues: issues,
pkg: clPackage(cl),
})
authors[cl.Owner()] = true
return nil
})
return nil
})
sort.Slice(changes, func(i, j int) bool {
return changes[i].CL.Number < changes[j].CL.Number
})
if *mdMode {
fmt.Printf("## TODO: version - ")
now := time.Now()
fmt.Printf("%s\n\n", now.Format("2 Jan, 2006"))
fmt.Printf("### Changes\n\n")
mdPrintChanges(changes, true)
fmt.Printf("### Issues\n\n")
mdPrintIssues(changes, *milestone)
fmt.Printf("\n### Thanks\n\n")
mdPrintContributors(authors)
} else {
for _, change := range changes {
fmt.Printf(" %s\n", change.TextLine())
}
}
}
func mdPrintChanges(changes []*change, byPackage bool) {
printChange := func(change *change) {
fmt.Printf("- ")
content := change.CL.Subject()
if change.Note != "" && change.Note != "yes" && change.Note != "y" {
// Note contains content
content = change.Note
}
fmt.Printf("%s", content)
if len(change.CL.GitHubIssueRefs) > 0 {
fmt.Printf(" (")
for i, ref := range change.CL.GitHubIssueRefs {
if i == 0 {
fmt.Printf("[Issue %d](https://github.com/%s/issues/%d)", ref.Number, ref.Repo.ID().String(), ref.Number)
} else {
fmt.Printf(", [%d](https://github.com/%s/issues/%d)", ref.Number, ref.Repo.ID().String(), ref.Number)
}
}
fmt.Printf(")")
}
fmt.Printf(" <!-- CL %d -->\n", change.CL.Number)
}
// Group CLs by package or by number order.
if byPackage {
pkgMap := map[string][]*change{}
for _, change := range changes {
pkgMap[change.pkg] = append(pkgMap[change.pkg], change)
}
for _, changes := range pkgMap {
for _, change := range changes {
printChange(change)
}
}
} else {
for _, change := range changes {
printChange(change)
}
}
}
func mdPrintIssues(changes []*change, milestone string) {
var issues []*issue
for _, change := range changes {
issues = append(issues, change.Issues...)
}
sort.Slice(issues, func(i, j int) bool {
return issues[i].Number < issues[j].Number
})
for _, issue := range issues {
if !issue.Closed {
continue
}
fmt.Printf("%s: %s\n", issue.link(), issue.Milestone.Title)
}
}
// clPackage returns the package name from the CL's commit message,
// or "??" if it's formatted unconventionally.
func clPackage(cl *maintner.GerritCL) string {
subj := cl.Subject()
if i := strings.Index(subj, ":"); i != -1 {
return subj[:i]
}
return "??"
}
var relNoteRx = regexp.MustCompile(`RELNOTES?=(.+)`)
func parseRelNote(s string) string {
if m := relNoteRx.FindStringSubmatch(s); m != nil {
return m[1]
}
return ""
}
func clRelNote(cl *maintner.GerritCL) string {
msg := cl.Commit.Msg
if strings.Contains(msg, "RELNOTE") {
return parseRelNote(msg)
}
for _, comment := range cl.Messages {
if strings.Contains(comment.Message, "RELNOTE") {
return parseRelNote(comment.Message)
}
}
return ""
}
func mdPrintContributors(authors map[*maintner.GitPerson]bool) {
var names []string
for author := range authors {
names = append(names, author.Name())
}
sort.Strings(names)
if len(names) > 1 {
names[len(names)-1] = "and " + names[len(names)-1]
}
fmt.Printf("Thank you for your contribution, %s!\n", strings.Join(names, ", "))
}