cmd/relnote: find cutoff date automatically

Look for the date of CL that opened the tree to find the
cutoff for TODOs.

Add a flag for the date in case that doesn't work.

For golang/go#64169.

Change-Id: I756e5622339f5e1963c39b8e0bbd7eeb3fc23d85
Reviewed-on: https://go-review.googlesource.com/c/build/+/584401
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Russ Cox <rsc@golang.org>
diff --git a/cmd/relnote/relnote.go b/cmd/relnote/relnote.go
index e24d6c8..4103949 100644
--- a/cmd/relnote/relnote.go
+++ b/cmd/relnote/relnote.go
@@ -19,8 +19,9 @@
 )
 
 var (
-	verbose = flag.Bool("v", false, "print verbose logging")
-	goroot  = flag.String("goroot", runtime.GOROOT(), "root of Go repo containing docs")
+	verbose    = flag.Bool("v", false, "print verbose logging")
+	goroot     = flag.String("goroot", runtime.GOROOT(), "root of Go repo containing docs")
+	todosSince = flag.String("since", "", "earliest to look for TODOs, in YYYY-MM-DD format")
 )
 
 func usage() {
@@ -28,8 +29,8 @@
 	fmt.Fprintf(out, "usage:\n")
 	fmt.Fprintf(out, "   relnote generate\n")
 	fmt.Fprintf(out, "      generate release notes from doc/next\n")
-	fmt.Fprintf(out, "   relnote todo PREVIOUS_RELEASE_DATE\n")
-	fmt.Fprintf(out, "      report which release notes need to be written; use YYYY-MM-DD format for date of last release\n")
+	fmt.Fprintf(out, "   relnote todo\n")
+	fmt.Fprintf(out, "      report which release notes need to be written\n")
 	flag.PrintDefaults()
 }
 
@@ -54,18 +55,16 @@
 	if cmd := flag.Arg(0); cmd != "" {
 		switch cmd {
 		case "generate":
-			err = generate(version, flag.Arg(1))
+			err = generate(version, *goroot)
 		case "todo":
-			prevDate := flag.Arg(1)
-			if prevDate == "" {
-				log.Fatal("need previous release date")
+			var sinceDate time.Time
+			if *todosSince != "" {
+				sinceDate, err = time.Parse(time.DateOnly, *todosSince)
+				if err != nil {
+					log.Fatalf("-since flag: %v", err)
+				}
 			}
-			prevDateTime, err := time.Parse("2006-01-02", prevDate)
-			if err != nil {
-				log.Fatalf("previous release date: %s", err)
-			}
-			nextDir := filepath.Join(*goroot, "doc", "next")
-			err = todo(os.Stdout, os.DirFS(nextDir), prevDateTime)
+			err = todo(os.Stdout, *goroot, sinceDate)
 		default:
 			err = fmt.Errorf("unknown command %q", cmd)
 		}
diff --git a/cmd/relnote/todo.go b/cmd/relnote/todo.go
index bf34651..1faec09 100644
--- a/cmd/relnote/todo.go
+++ b/cmd/relnote/todo.go
@@ -10,6 +10,10 @@
 	"fmt"
 	"io"
 	"io/fs"
+	"log"
+	"os"
+	"os/exec"
+	"path/filepath"
 	"regexp"
 	"slices"
 	"strconv"
@@ -29,24 +33,59 @@
 
 // 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, fsys fs.FS, prevRelDate time.Time) error {
+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 }
 
-	if err := infoFromDocFiles(fsys, addToDo, addIssue); err != nil {
+	nextDir := filepath.Join(goroot, "doc", "next")
+	if err := infoFromDocFiles(os.DirFS(nextDir), addToDo, addIssue); err != nil {
 		return err
 	}
-	if !prevRelDate.IsZero() {
-		if err := todosFromCLs(prevRelDate, mentionedIssues, addToDo); 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.
diff --git a/cmd/relnote/todo_test.go b/cmd/relnote/todo_test.go
index cfa1323..95f2df0 100644
--- a/cmd/relnote/todo_test.go
+++ b/cmd/relnote/todo_test.go
@@ -5,13 +5,12 @@
 package main
 
 import (
-	"bytes"
+	"slices"
 	"testing"
 	"testing/fstest"
-	"time"
 )
 
-func TestToDo(t *testing.T) {
+func TestInfoFromDocFiles(t *testing.T) {
 	files := map[string]string{
 		"a.md": "TODO: write something",
 		"b.md": "nothing to do",
@@ -22,14 +21,20 @@
 	for name, contents := range files {
 		dir[name] = &fstest.MapFile{Data: []byte(contents)}
 	}
-	var buf bytes.Buffer
-	if err := todo(&buf, dir, time.Time{}); err != nil {
+	var got []ToDo
+	addToDo := func(td ToDo) { got = append(got, td) }
+	addIssue := func(int) {}
+	if err := infoFromDocFiles(dir, addToDo, addIssue); err != nil {
 		t.Fatal(err)
 	}
-	got := buf.String()
-	want := `TODO: write something (from a.md:1)
-`
-	if got != want {
-		t.Errorf("\ngot:\n%s\nwant:\n%s", got, want)
+	want := []ToDo{
+		{
+			message:    "TODO: write something",
+			provenance: "a.md:1",
+		},
+	}
+
+	if !slices.Equal(got, want) {
+		t.Errorf("\ngot:\n%+v\nwant:\n%+v", got, want)
 	}
 }