tools/relnotes: make some changes to generate release notes for gopls

This change makes it easier to use this tool to generate release notes
for gopls as well. Filter directories are needed for the x/tools repo,
and the milestone check helps to make sure that issues are accurately
categorized.

Change-Id: Ib8287247f8f40b621f100413777c6ac220087687
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/337489
Trust: Rebecca Stambler <rstambler@golang.org>
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Suzy Mueller <suzmue@golang.org>
diff --git a/tools/relnotes/relnotes.go b/tools/relnotes/relnotes.go
index 369b29d..c1684ba 100644
--- a/tools/relnotes/relnotes.go
+++ b/tools/relnotes/relnotes.go
@@ -2,6 +2,8 @@
 // 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 (
@@ -11,27 +13,32 @@
 	"fmt"
 	"io/ioutil"
 	"log"
+	"path/filepath"
 	"regexp"
 	"sort"
 	"strings"
 	"time"
+	"unicode"
 
 	"golang.org/x/build/maintner"
 	"golang.org/x/build/maintner/godata"
 )
 
 var (
-	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.")
+	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 []*maintner.GitHubIssue
+	Issues []*issue
+	pkg    string
 }
 
 func (c change) TextLine() string {
@@ -40,6 +47,19 @@
 	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()
 
@@ -57,10 +77,17 @@
 		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.
-	cutoff := time.Date(2020, time.August, 1, 00, 00, 00, 0, time.UTC)
+	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
@@ -69,21 +96,21 @@
 			if cl.Status != "merged" {
 				return nil
 			}
-			if *sinceCl >= 0 {
-				if int(cl.Number) == *sinceCl {
-					cutoff = cl.Commit.CommitTime
+			if *sinceCL >= 0 {
+				if int(cl.Number) == *sinceCL {
+					start = cl.Commit.CommitTime
 				}
-			} else if cl.Branch() == "release" && cl.Commit.CommitTime.After(cutoff) {
+			} else if cl.Branch() == "release" && cl.Commit.CommitTime.After(start) {
 				// Try to figure out when the last release was
 				fmt.Println(cl.Commit.CommitTime)
-				cutoff = cl.Commit.CommitTime
+				start = cl.Commit.CommitTime
 			}
 			return nil
 		})
 		return nil
 	})
 
-	changes := map[string][]change{} // keyed by pkg
+	var changes []*change
 	authors := map[*maintner.GitPerson]bool{}
 	ger.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
 		if gp.Server() != "go.googlesource.com" || gp.Project() != *project {
@@ -97,7 +124,7 @@
 			if cl.Status != "merged" {
 				return nil
 			}
-			if cl.Commit.CommitTime.Before(cutoff) {
+			if cl.Commit.CommitTime.Before(start) {
 				// Was in a previous release; not for this one.
 				return nil
 			}
@@ -106,17 +133,43 @@
 				return nil
 			}
 
-			// try to determine type from issue labels
-			var issues []*maintner.GitHubIssue
-			for _, ref := range cl.GitHubIssueRefs {
-				issues = append(issues, ref.Repo.Issue(ref.Number))
+			// 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
+				}
 			}
 
-			pkg := clPackage(cl)
-			changes[pkg] = append(changes[pkg], change{
+			// 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
@@ -125,58 +178,84 @@
 		return nil
 	})
 
-	var pkgs []string
-	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
-		})
-	}
-	sort.Strings(pkgs)
+	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(pkgs, changes)
+		mdPrintChanges(changes, true)
+
+		fmt.Printf("### Issues\n\n")
+		mdPrintIssues(changes, *milestone)
 
 		fmt.Printf("\n### Thanks\n\n")
-		mdPrintContributers(authors)
+		mdPrintContributors(authors)
 	} else {
-		for _, pkg := range pkgs {
-			for _, change := range changes[pkg] {
-				fmt.Printf("  %s\n", change.TextLine())
-			}
+		for _, change := range changes {
+			fmt.Printf("  %s\n", change.TextLine())
 		}
 	}
 }
 
-func mdPrintChanges(pkgs []string, changes map[string][]change) {
-	for _, pkg := range pkgs {
-		for _, change := range changes[pkg] {
-			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)
+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)
 	}
 }
 
@@ -212,7 +291,7 @@
 	return ""
 }
 
-func mdPrintContributers(authors map[*maintner.GitPerson]bool) {
+func mdPrintContributors(authors map[*maintner.GitPerson]bool) {
 	var names []string
 	for author := range authors {
 		names = append(names, author.Name())