relnote: use commit text to find accepted proposals

Accepted proposals should usually be mentioned in the release notes,
so include them even if they are not mentioned with RELNOTES=yes.

Also include any CL that is adding to the api/next directory.

CL 410361 contains the updates found by this new code.

For golang/go#51400.

Change-Id: I8c248c0ddcfad6a070ab7875e6eac0a24a3c9ec4
Reviewed-on: https://go-review.googlesource.com/c/build/+/410244
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
Reviewed-by: David Chase <drchase@google.com>
diff --git a/cmd/relnote/relnote.go b/cmd/relnote/relnote.go
index 6c17054..6889c54 100644
--- a/cmd/relnote/relnote.go
+++ b/cmd/relnote/relnote.go
@@ -17,6 +17,7 @@
 	"path"
 	"regexp"
 	"sort"
+	"strconv"
 	"strings"
 	"time"
 
@@ -27,25 +28,71 @@
 )
 
 var (
+	verbose  = flag.Bool("v", false, "print verbose logging")
 	htmlMode = flag.Bool("html", false, "write HTML output")
 	exclFile = flag.String("exclude-from", "", "optional path to release notes HTML file. If specified, any 'CL NNNN' occurrence in the content will cause that CL to be excluded from this tool's output.")
 )
 
 // change is a change that was noted via a RELNOTE= comment.
 type change struct {
-	CL   *maintner.GerritCL
-	Note string // the part after RELNOTE=
+	CL    *maintner.GerritCL
+	Note  string // the part after RELNOTE=
+	Issue *maintner.GitHubIssue
+}
+
+func (c change) ID() string {
+	switch {
+	default:
+		panic("invalid change")
+	case c.CL != nil:
+		return fmt.Sprintf("CL %d", c.CL.Number)
+	case c.Issue != nil:
+		return fmt.Sprintf("https://go.dev/issue/%d", c.Issue.Number)
+	}
+}
+
+func (c change) URL() string {
+	switch {
+	default:
+		panic("invalid change")
+	case c.CL != nil:
+		return fmt.Sprint("https://go.dev/cl/", c.CL.Number)
+	case c.Issue != nil:
+		return fmt.Sprint("https://go.dev/issue/", c.Issue.Number)
+	}
+}
+
+func (c change) Subject() string {
+	switch {
+	default:
+		panic("invalid change")
+	case c.CL != nil:
+		subj := c.CL.Subject()
+		subj = strings.TrimPrefix(subj, clPackage(c.CL)+":")
+		return strings.TrimSpace(subj)
+	case c.Issue != nil:
+		return issueSubject(c.Issue)
+	}
 }
 
 func (c change) TextLine() string {
-	subj := c.CL.Subject()
-	if c.Note != "yes" && c.Note != "y" {
-		subj = c.Note + ": " + subj
+	switch {
+	default:
+		panic("invalid change")
+	case c.CL != nil:
+		subj := c.CL.Subject()
+		if c.Note != "yes" && c.Note != "y" {
+			subj += "; " + c.Note
+		}
+		return subj
+	case c.Issue != nil:
+		return issueSubject(c.Issue)
 	}
-	return fmt.Sprintf("https://go.dev/cl/%d: %s", c.CL.Number, subj)
 }
 
 func main() {
+	log.SetPrefix("relnote: ")
+	log.SetFlags(0)
 	flag.Parse()
 
 	// Releases are every 6 months. Walk forward by 6 month increments to next release.
@@ -81,6 +128,8 @@
 		log.Fatal(err)
 	}
 	changes := map[string][]change{} // keyed by pkg
+	gh := corpus.GitHub().Repo("golang", "go")
+	addedIssue := make(map[int32]bool)
 	corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
 		if gp.Server() != "go.googlesource.com" {
 			return nil
@@ -97,26 +146,52 @@
 				// Was in a previous release; not for this one.
 				return nil
 			}
-			_, ok := matchedCLs[int(cl.Number)]
-			if !ok {
-				// Wasn't matched by the Gerrit API search query.
-				// Return before making further Gerrit API calls.
+			for _, num := range issueNumbers(cl) {
+				if bytes.Contains(existingHTML, []byte(fmt.Sprintf("https://go.dev/issue/%d", num))) || addedIssue[num] {
+					continue
+				}
+				if issue := gh.Issue(num); issue != nil && !issue.ClosedAt.Before(cutoff) && hasLabel(issue, "Proposal-Accepted") {
+					if *verbose {
+						log.Printf("CL %d mentions accepted proposal #%d (%s)", cl.Number, num, issue.Title)
+					}
+					pkg := issuePackage(issue)
+					changes[pkg] = append(changes[pkg], change{Issue: issue})
+					addedIssue[num] = true
+				}
+			}
+			if bytes.Contains(existingHTML, []byte(fmt.Sprintf("CL %d", cl.Number))) {
 				return nil
 			}
-			comments, err := gerritClient.ListChangeComments(context.Background(), fmt.Sprint(cl.Number))
-			if err != nil {
-				return err
+			var relnote string
+			if _, ok := matchedCLs[int(cl.Number)]; ok {
+				comments, err := gerritClient.ListChangeComments(context.Background(), fmt.Sprint(cl.Number))
+				if err != nil {
+					return err
+				}
+				relnote = clRelNote(cl, comments)
 			}
-			relnote := clRelNote(cl, comments)
-			if relnote == "" ||
-				bytes.Contains(existingHTML, []byte(fmt.Sprintf("CL %d", cl.Number))) {
-				return nil
+			if relnote == "" {
+				// Invent a RELNOTE=modified api/name.txt if the commit modifies any API files.
+				var api []string
+				for _, f := range cl.Commit.Files {
+					if strings.HasPrefix(f.File, "api/") && strings.HasSuffix(f.File, ".txt") {
+						api = append(api, f.File)
+					}
+				}
+				if len(api) > 0 {
+					relnote = "modified " + strings.Join(api, ", ")
+					if *verbose {
+						log.Printf("CL %d %s", cl.Number, relnote)
+					}
+				}
 			}
-			pkg := clPackage(cl)
-			changes[pkg] = append(changes[pkg], change{
-				Note: relnote,
-				CL:   cl,
-			})
+			if relnote != "" {
+				pkg := clPackage(cl)
+				changes[pkg] = append(changes[pkg], change{
+					Note: relnote,
+					CL:   cl,
+				})
+			}
 			return nil
 		})
 		return nil
@@ -126,7 +201,14 @@
 	for pkg, changes := range changes {
 		pkgs = append(pkgs, pkg)
 		sort.Slice(changes, func(i, j int) bool {
-			return changes[i].CL.Number < changes[j].CL.Number
+			x, y := &changes[i], &changes[j]
+			if (x.Issue != nil) != (y.Issue != nil) {
+				return x.Issue != nil
+			}
+			if x.Issue != nil {
+				return x.Issue.Number < y.Issue.Number
+			}
+			return x.CL.Number < y.CL.Number
 		})
 	}
 	sort.Strings(pkgs)
@@ -137,7 +219,7 @@
 				continue
 			}
 			for _, change := range changes[pkg] {
-				fmt.Printf("<!-- CL %d: %s -->\n", change.CL.Number, change.TextLine())
+				fmt.Printf("<!-- %s: %s -->\n", change.ID(), change.TextLine())
 			}
 		}
 		for _, pkg := range pkgs {
@@ -147,11 +229,8 @@
 			fmt.Printf("\n<dl id=%q><dt><a href=%q>%s</a></dt>\n  <dd>",
 				pkg, "/pkg/"+pkg+"/", pkg)
 			for _, change := range changes[pkg] {
-				changeURL := fmt.Sprintf("https://go.dev/cl/%d", change.CL.Number)
-				subj := change.CL.Subject()
-				subj = strings.TrimPrefix(subj, pkg+": ")
-				fmt.Printf("\n    <p><!-- CL %d -->\n      TODO: <a href=%q>%s</a>: %s\n    </p>\n",
-					change.CL.Number, changeURL, changeURL, html.EscapeString(subj))
+				fmt.Printf("\n    <p><!-- %s -->\n      TODO: <a href=%q>%s</a>: %s\n    </p>\n",
+					change.ID(), change.URL(), change.URL(), html.EscapeString(change.TextLine()))
 			}
 			fmt.Printf("  </dd>\n</dl><!-- %s -->\n", pkg)
 		}
@@ -159,7 +238,7 @@
 		for _, pkg := range pkgs {
 			fmt.Printf("%s\n", pkg)
 			for _, change := range changes[pkg] {
-				fmt.Printf("  %s\n", change.TextLine())
+				fmt.Printf("  %s: %s\n", change.URL(), change.TextLine())
 			}
 		}
 	}
@@ -183,14 +262,27 @@
 	return m, nil
 }
 
+// packagePrefix returns the package prefix at the start of s.
+// For example packagePrefix("net/http: add HTTP 5 support") == "net/http".
+// If there's no package prefix, packagePrefix returns "".
+func packagePrefix(s string) string {
+	i := strings.Index(s, ":")
+	if i < 0 {
+		return ""
+	}
+	s = s[:i]
+	if strings.Trim(s, "abcdefghijklmnopqrstuvwxyz0123456789/") != "" {
+		return ""
+	}
+	return s
+}
+
 // clPackage returns the package import path from the CL's commit message,
 // or "??" if it's formatted unconventionally.
 func clPackage(cl *maintner.GerritCL) string {
-	var pkg string
-	if i := strings.Index(cl.Subject(), ":"); i == -1 {
+	pkg := packagePrefix(cl.Subject())
+	if pkg == "" {
 		return "??"
-	} else {
-		pkg = cl.Subject()[:i]
 	}
 	if r := repos.ByGerritProject[cl.Project.Project()]; r == nil {
 		return "??"
@@ -233,3 +325,52 @@
 }
 
 var relNoteRx = regexp.MustCompile(`RELNOTES?=(.+)`)
+
+// issuePackage returns the package import path from the issue's title,
+// or "??" if it's formatted unconventionally.
+func issuePackage(issue *maintner.GitHubIssue) string {
+	pkg := packagePrefix(issue.Title)
+	if pkg == "" {
+		return "??"
+	}
+	return pkg
+}
+
+// issueSubject returns the issue's title with the package prefix removed.
+func issueSubject(issue *maintner.GitHubIssue) string {
+	pkg := packagePrefix(issue.Title)
+	if pkg == "" {
+		return issue.Title
+	}
+	return strings.TrimSpace(strings.TrimPrefix(issue.Title, pkg+":"))
+}
+
+func hasLabel(issue *maintner.GitHubIssue, label string) bool {
+	for _, l := range issue.Labels {
+		if l.Name == label {
+			return true
+		}
+	}
+	return false
+}
+
+var numbersRE = regexp.MustCompile(`(?m)(?:^|\s)#([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) []int32 {
+	var re *regexp.Regexp
+	if cl.Project.Project() == "go" {
+		re = numbersRE
+	} else {
+		re = golangGoNumbersRE
+	}
+
+	var list []int32
+	for _, s := range re.FindAllStringSubmatch(cl.Commit.Msg, -1) {
+		if n, err := strconv.Atoi(s[1]); err == nil && n < 1e9 {
+			list = append(list, int32(n))
+		}
+	}
+	return list
+}