blob: 1faec09916988531048adce6ef96f9c056be4280 [file] [log] [blame]
// Copyright 2024 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 (
"bufio"
"context"
"fmt"
"io"
"io/fs"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"golang.org/x/build/gerrit"
"golang.org/x/build/maintner"
"golang.org/x/build/maintner/godata"
"golang.org/x/exp/maps"
)
type ToDo struct {
message string // what is to be done
provenance string // where the TODO came from
}
// todo prints a report to w on which release notes need to be written.
// It takes the doc/next directory of the repo and the date of the last release.
func todo(w io.Writer, goroot string, treeOpenDate time.Time) error {
// If not provided, determine when the tree was opened by looking
// at when the version file was updated.
if treeOpenDate.IsZero() {
var err error
treeOpenDate, err = findTreeOpenDate(goroot)
if err != nil {
return err
}
}
log.Printf("collecting TODOs from %s since %s", goroot, treeOpenDate.Format(time.DateOnly))
var todos []ToDo
addToDo := func(td ToDo) { todos = append(todos, td) }
mentionedIssues := map[int]bool{} // issues mentioned in the existing relnotes
addIssue := func(num int) { mentionedIssues[num] = true }
nextDir := filepath.Join(goroot, "doc", "next")
if err := infoFromDocFiles(os.DirFS(nextDir), addToDo, addIssue); err != nil {
return err
}
if err := todosFromCLs(treeOpenDate, mentionedIssues, addToDo); err != nil {
return err
}
return writeToDos(w, todos)
}
// findTreeOpenDate returns the time of the most recent commit to the file that
// determines the version of Go under development.
func findTreeOpenDate(goroot string) (time.Time, error) {
versionFilePath := filepath.FromSlash("src/internal/goversion/goversion.go")
if _, err := exec.LookPath("git"); err != nil {
return time.Time{}, fmt.Errorf("looking for git binary: %v", err)
}
// List the most recent commit to versionFilePath, displaying the date and subject.
outb, err := exec.Command("git", "-C", goroot, "log", "-n", "1",
"--format=%cs %s", "--", versionFilePath).Output()
if err != nil {
return time.Time{}, err
}
out := string(outb)
// The commit messages follow a standard form. Check for the right words to avoid mistakenly
// choosing the wrong commit.
const updateString = "update version to"
if !strings.Contains(strings.ToLower(out), updateString) {
return time.Time{}, fmt.Errorf("cannot determine tree-open date: most recent commit for %s does not contain %q",
versionFilePath, updateString)
}
dateString, _, _ := strings.Cut(out, " ")
return time.Parse(time.DateOnly, dateString)
}
// Collect TODOs and issue numbers from the markdown files in the main repo.
func infoFromDocFiles(fsys fs.FS, addToDo func(ToDo), addIssue func(int)) error {
// This is essentially a grep.
return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(path, ".md") {
if err := infoFromFile(fsys, path, addToDo, addIssue); err != nil {
return err
}
}
return nil
})
}
var issueRE = regexp.MustCompile("/issue/([0-9]+)")
func infoFromFile(dir fs.FS, filename string, addToDo func(ToDo), addIssue func(int)) error {
f, err := dir.Open(filename)
if err != nil {
return err
}
defer f.Close()
scan := bufio.NewScanner(f)
ln := 0
for scan.Scan() {
ln++
line := scan.Text()
if strings.Contains(line, "TODO") {
addToDo(ToDo{
message: line,
provenance: fmt.Sprintf("%s:%d", filename, ln),
})
}
for _, matches := range issueRE.FindAllStringSubmatch(line, -1) {
num, err := strconv.Atoi(matches[1])
if err != nil {
return fmt.Errorf("%s:%d: %v", filename, ln, err)
}
addIssue(num)
}
}
return scan.Err()
}
func todosFromCLs(cutoff time.Time, mentionedIssues map[int]bool, add func(ToDo)) error {
ctx := context.Background()
// The maintner corpus doesn't track inline comments. See go.dev/issue/24863.
// So we need to use a Gerrit API client to fetch them instead. If maintner starts
// tracking inline comments in the future, this extra complexity can be dropped.
gerritClient := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
matchedCLs, err := findCLsWithRelNote(gerritClient, cutoff)
if err != nil {
return err
}
corpus, err := godata.Get(ctx)
if err != nil {
return err
}
gh := corpus.GitHub().Repo("golang", "go")
return corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
if gp.Server() != "go.googlesource.com" {
return nil
}
return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
if cl.Status != "merged" {
return nil
}
if cl.Branch() != "master" {
// Ignore CLs sent to development or release branches.
return nil
}
if cl.Commit.CommitTime.Before(cutoff) {
// Was in a previous release; not for this one.
return nil
}
// Add a TODO if the CL has a "RELNOTE=" comment.
// These are deprecated, but we look for them just in case.
if _, ok := matchedCLs[int(cl.Number)]; ok {
if err := todoFromRelnote(ctx, cl, gerritClient, add); err != nil {
return err
}
}
// Add a TODO if the CL refers to an accepted proposal.
todoFromProposal(cl, gh, mentionedIssues, add)
return nil
})
})
}
func todoFromRelnote(ctx context.Context, cl *maintner.GerritCL, gc *gerrit.Client, add func(ToDo)) error {
comments, err := gc.ListChangeComments(ctx, fmt.Sprint(cl.Number))
if err != nil {
return err
}
if rn := clRelNote(cl, comments); rn != "" {
if rn == "yes" || rn == "y" {
rn = "UNKNOWN"
}
add(ToDo{
message: "TODO:" + rn,
provenance: fmt.Sprintf("RELNOTE comment in https://go.dev/cl/%d", cl.Number),
})
}
return nil
}
func todoFromProposal(cl *maintner.GerritCL, gh *maintner.GitHubRepo, mentionedIssues map[int]bool, add func(ToDo)) {
for _, num := range issueNumbers(cl) {
if mentionedIssues[num] {
continue
}
if issue := gh.Issue(int32(num)); issue != nil && hasLabel(issue, "Proposal-Accepted") {
// Add a TODO for all issues, regardless of when or whether they are closed.
// Any work on an accepted proposal is potentially worthy of a release note.
add(ToDo{
message: fmt.Sprintf("TODO: accepted proposal https://go.dev/issue/%d", num),
provenance: fmt.Sprintf("https://go.dev/cl/%d", cl.Number),
})
}
}
}
func hasLabel(issue *maintner.GitHubIssue, label string) bool {
for _, l := range issue.Labels {
if l.Name == label {
return true
}
}
return false
}
// findCLsWithRelNote finds CLs that contain a RELNOTE marker by
// using a Gerrit API client. Returned map is keyed by CL number.
func findCLsWithRelNote(client *gerrit.Client, since time.Time) (map[int]*gerrit.ChangeInfo, error) {
// Gerrit search operators are documented at
// https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators.
query := fmt.Sprintf(`status:merged branch:master since:%s (comment:"RELNOTE" OR comment:"RELNOTES")`,
since.Format("2006-01-02"))
cs, err := client.QueryChanges(context.Background(), query)
if err != nil {
return nil, err
}
m := make(map[int]*gerrit.ChangeInfo) // CL Number → CL.
for _, c := range cs {
m[c.ChangeNumber] = c
}
return m, nil
}
// clRelNote extracts a RELNOTE note from a Gerrit CL commit
// message and any inline comments. If there isn't a RELNOTE
// note, it returns the empty string.
func clRelNote(cl *maintner.GerritCL, comments map[string][]gerrit.CommentInfo) string {
msg := cl.Commit.Msg
if strings.Contains(msg, "RELNOTE") {
return parseRelNote(msg)
}
// Since July 2020, Gerrit UI has replaced top-level comments
// with patchset-level inline comments, so don't bother looking
// for RELNOTE= in cl.Messages—there won't be any. Instead, do
// look through all inline comments that we got via Gerrit API.
for _, cs := range comments {
for _, c := range cs {
if strings.Contains(c.Message, "RELNOTE") {
return parseRelNote(c.Message)
}
}
}
return ""
}
var relNoteRx = regexp.MustCompile(`RELNOTES?=(.+)`)
// parseRelNote parses a RELNOTE annotation from the string s.
// It returns the empty string if no such annotation exists.
func parseRelNote(s string) string {
m := relNoteRx.FindStringSubmatch(s)
if m == nil {
return ""
}
return m[1]
}
var numbersRE = regexp.MustCompile(`(?m)(?:^|\s|golang/go)#([0-9]{3,})`)
var golangGoNumbersRE = regexp.MustCompile(`(?m)golang/go#([0-9]{3,})`)
// issueNumbers returns the golang/go issue numbers referred to by the CL.
func issueNumbers(cl *maintner.GerritCL) []int {
var re *regexp.Regexp
if cl.Project.Project() == "go" {
re = numbersRE
} else {
re = golangGoNumbersRE
}
var list []int
for _, s := range re.FindAllStringSubmatch(cl.Commit.Msg, -1) {
if n, err := strconv.Atoi(s[1]); err == nil && n < 1e9 {
list = append(list, n)
}
}
// Remove duplicates.
slices.Sort(list)
return slices.Compact(list)
}
func writeToDos(w io.Writer, todos []ToDo) error {
// Group TODOs with the same message. This simplifies the output when a single
// issue is implemented by multiple CLs.
byMessage := map[string][]ToDo{}
for _, td := range todos {
byMessage[td.message] = append(byMessage[td.message], td)
}
msgs := maps.Keys(byMessage)
slices.Sort(msgs) // for deterministic output
for _, msg := range msgs {
var provs []string
for _, td := range byMessage[msg] {
provs = append(provs, td.provenance)
}
if _, err := fmt.Fprintf(w, "%s (from %s)\n", msg, strings.Join(provs, ", ")); err != nil {
return err
}
}
return nil
}