blob: 9eed06a8c0612ee820a9271c76e239cafda64173 [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"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"log"
"net/http"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
"github.com/stamblerre/work-stats/generic"
"github.com/stamblerre/work-stats/golang"
"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")
exclFile = flag.String("exclude-from", "", "optional path to changelog MD file. If specified, any 'CL NNNN' occurrence in the content will cause that CL to be excluded from this tool's output.")
semanticVersion = flag.String("semver", "", "the semantic version of the new release")
githubTokenFilePath = flag.String("token", "", "the absolute path to the github token file")
)
func main() {
flag.Parse()
if *semanticVersion == "" {
log.Fatal("Must provide -semver.")
}
if *githubTokenFilePath == "" {
log.Fatal("Must provide -token.")
}
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 []*generic.Changelist
cls := map[*maintner.GerritCL]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
}
}
if isGoplsChangeList(golang.GerritToGenericCL(cl)) {
changes = append(changes, golang.GerritToGenericCL(cl))
cls[cl] = true
}
return nil
})
return nil
})
fmt.Printf("# Version: %s\n\n", *semanticVersion)
fmt.Printf("## TODO: version - ")
now := time.Now()
fmt.Printf("%s\n\n", now.Format("2 Jan, 2006"))
fmt.Printf("### Changes\n\n")
mdPrintChanges(changes, false)
fmt.Printf("\n\n")
fmt.Printf("### Issues\n\n")
mdPrintIssues(changes, *milestone)
fmt.Printf("\n\n")
fmt.Printf("### Release comments\n\n")
mdPrintReleaseComments(changes)
fmt.Printf("\n\n")
fmt.Printf("\n### Thanks\n\n")
mdPrintContributors(cls)
}
func isGoplsChangeList(cl *generic.Changelist) bool {
if strings.Contains(cl.Subject, "internal/lsp") || strings.Contains(cl.Subject, "gopls") {
return true
}
for _, issue := range cl.AssociatedIssues {
if issue.Repo == "golang/vscode-go" {
return true
}
for _, label := range issue.Labels {
if label == "gopls" {
return true
}
}
}
return false
}
func mdPrintChanges(changes []*generic.Changelist, byCategory bool) {
printChange := func(change *generic.Changelist) {
fmt.Printf("- ")
content := change.Subject
note := releaseNote(change)
if note != "" && note != "yes" && note != "y" {
// The release note contains content.
content = note
}
fmt.Printf("%s", content)
if len(change.AssociatedIssues) > 0 {
fmt.Printf(" (")
for i, issue := range change.AssociatedIssues {
if i == 0 {
fmt.Printf("[Issue %d](https://%s)", issue.Number, issue.Link)
} else {
fmt.Printf(", [%d](https://%s)", issue.Number, issue.Link)
}
}
fmt.Printf(")")
}
fmt.Printf(" <!-- CL %d -->\n", change.Number)
}
// Group CLs by category or by first associated issue number.
if byCategory {
pkgMap := map[string][]*generic.Changelist{}
for _, change := range changes {
pkgMap[change.Category()] = append(pkgMap[change.Category()], change)
}
for _, changes := range pkgMap {
for _, change := range changes {
printChange(change)
}
}
} else {
sort.Slice(changes, func(i, j int) bool {
// Sort first by associated issue, then by CL number.
var iIssue, jIssue int // first associated issues
if len(changes[i].AssociatedIssues) > 0 {
iIssue = changes[i].AssociatedIssues[0].Number
}
if len(changes[j].AssociatedIssues) > 0 {
jIssue = changes[j].AssociatedIssues[0].Number
}
if iIssue != 0 && jIssue != 0 {
return iIssue < jIssue // sort CLs with issues first
}
return iIssue != 0 || changes[i].Number < changes[j].Number
})
currentChange := -1
for i, change := range changes {
if len(change.AssociatedIssues) > 0 && change.AssociatedIssues[0].Number != currentChange {
currentChange = change.AssociatedIssues[0].Number
fmt.Printf("CL(s) for issue %d:\n", currentChange)
} else if len(change.AssociatedIssues) == 0 && (i == 0 || len(changes[i-1].AssociatedIssues) > 0) {
fmt.Printf("CL(s) not associated with any issue:\n")
}
printChange(change)
}
}
}
func mdPrintIssues(changes []*generic.Changelist, milestone string) {
var issues []*generic.Issue
for _, change := range changes {
issues = append(issues, change.AssociatedIssues...)
}
sort.Slice(issues, func(i, j int) bool {
return issues[i].Link < issues[j].Link
})
for _, issue := range issues {
if !issue.Closed() {
continue
}
fmt.Printf("%s: %s\n", issue.Link, issue.Milestone)
}
}
func mdPrintReleaseComments(changes []*generic.Changelist) {
type Issue struct {
repo string
number int
}
printedIssues := make(map[Issue]bool)
for _, change := range changes {
for _, issue := range change.AssociatedIssues {
if _, ok := printedIssues[Issue{issue.Repo, issue.Number}]; !ok {
printedIssues[Issue{issue.Repo, issue.Number}] = true
printIssueReleaseComment(issue.Repo, issue.Number)
}
}
}
}
var relNoteRx = regexp.MustCompile(`RELNOTES?=(.+)`)
func parseRelNote(s string) string {
if m := relNoteRx.FindStringSubmatch(s); m != nil {
return m[1]
}
return ""
}
func releaseNote(cl *generic.Changelist) string {
if strings.Contains(cl.Message, "RELNOTE") {
return parseRelNote(cl.Message)
}
for _, comment := range cl.Comments {
if strings.Contains(comment, "RELNOTE") {
return parseRelNote(comment)
}
}
return ""
}
func mdPrintContributors(cls map[*maintner.GerritCL]bool) {
var usernames []string
for changelist := range cls {
author, err := fetchCLAuthorName(changelist, *project)
if err != nil {
log.Fatalf("Error fetching Github information for %s: %v\n", changelist.Owner(), err)
}
usernames = append(usernames, author)
}
usernames = unique(usernames)
if len(usernames) > 1 {
usernames[len(usernames)-1] = "and " + usernames[len(usernames)-1]
}
fmt.Printf("Thank you for your contribution, %s!\n", strings.Join(usernames, ", "))
}
func getURL(url string) ([]byte, error) {
req, _ := http.NewRequest("GET", url, nil)
if token, err := ioutil.ReadFile(*githubTokenFilePath); err == nil {
req.Header.Set("Authorization", "token "+strings.TrimSpace(string(token)))
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Fatalf("Error fetching Github information at %s: %v\n", url, err)
}
return body, nil
}
func fetchCLAuthorName(changelist *maintner.GerritCL, repo string) (string, error) {
githubRepoMapping := map[string]string{
"tools": "golang/tools",
"vscode-go": "golang/vscode-go",
}
body, err := getURL(fmt.Sprintf("https://api.github.com/repos/%s/commits/%s", githubRepoMapping[repo], changelist.Commit.Hash))
if err != nil {
return "", err
}
var resp map[string]interface{}
if err := json.Unmarshal(body, &resp); err != nil {
return "", err
}
if authorInfo, _ := resp["author"].(map[string]interface{}); authorInfo != nil {
if username, ok := authorInfo["login"].(string); ok {
return "@" + username, nil
}
}
return changelist.Owner().Name(), nil
}
// printIssueReleaseComment collects the release comments, which marked by the annotation *Release*, from the issues included in this release.
func printIssueReleaseComment(repo string, issueNumber int) {
body, err := getURL(fmt.Sprintf("https://api.github.com/repos/%s/issues/%d/comments", repo, issueNumber))
if err != nil {
log.Fatal(err)
}
var issueComments []interface{}
if err := json.Unmarshal(body, &issueComments); err != nil {
log.Fatalf("Error fetching Github information for issue %d:\n", issueNumber)
}
for _, comment := range issueComments {
c, _ := comment.(map[string]interface{})
if str, ok := c["body"].(string); ok && strings.Contains(str, "*Release*") {
fmt.Println(str)
return
}
}
}
// unique returns a ascendingly sorted set of unique strings among its input
func unique(input []string) []string {
m := make(map[string]bool)
for _, entry := range input {
m[entry] = true
}
var list []string
for key := range m {
list = append(list, key)
}
sort.Strings(list)
return list
}