tools/relnotes: Update gopls and vscode-go release note script

Features:
- Sort by issue number
- Use CL author's username in contribution section
- Specify semantic version of the release in cmd
- Identify CLs that relate to gopls:
	- Commit message says `internal/lsp/*:` or `gopls/*:`
	- Referenced issue is in golang/vscode-go
	- Referenced issue is in golang/go, and is labeled “gopls”

Change-Id: I8934216da5c8ab573403e00b027d5f6ae44e6d75
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/406294
Reviewed-by: Suzy Mueller <suzmue@golang.org>
Reviewed-by: Robert Findley <rfindley@google.com>
diff --git a/tools/relnotes/relnotes.go b/tools/relnotes/relnotes.go
index 98c4450..80d92a0 100644
--- a/tools/relnotes/relnotes.go
+++ b/tools/relnotes/relnotes.go
@@ -9,10 +9,13 @@
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"flag"
 	"fmt"
 	"io/ioutil"
 	"log"
+	"net/http"
+
 	"path/filepath"
 	"regexp"
 	"sort"
@@ -27,17 +30,26 @@
 )
 
 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")
-	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")
+	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.")
+	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
@@ -86,7 +98,7 @@
 	})
 
 	var changes []*generic.Changelist
-	authors := map[*maintner.GitPerson]bool{}
+	cls := map[*maintner.GerritCL]bool{}
 	ger.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
 		if gp.Server() != "go.googlesource.com" || gp.Project() != *project {
 			return nil
@@ -124,34 +136,50 @@
 					return nil
 				}
 			}
-			changes = append(changes, golang.GerritToGenericCL(cl))
-			authors[cl.Owner()] = true
+			if isGoplsChangeList(golang.GerritToGenericCL(cl)) {
+				changes = append(changes, golang.GerritToGenericCL(cl))
+				cls[cl] = true
+			}
 			return nil
 		})
 		return nil
 	})
 
-	sort.Slice(changes, func(i, j int) bool {
-		return changes[i].Number < changes[j].Number
-	})
+	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")
 
-	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(changes, true)
+	fmt.Printf("### Issues\n\n")
+	mdPrintIssues(changes, *milestone)
+	fmt.Printf("\n\n")
 
-		fmt.Printf("### Issues\n\n")
-		mdPrintIssues(changes, *milestone)
+	fmt.Printf("### Release comments\n\n")
+	mdPrintReleaseComments(changes)
+	fmt.Printf("\n\n")
 
-		fmt.Printf("\n### Thanks\n\n")
-		mdPrintContributors(authors)
-	} else {
-		for _, change := range changes {
-			fmt.Printf("  %s\n", change.Subject)
+	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) {
@@ -178,7 +206,7 @@
 		}
 		fmt.Printf(" <!-- CL %d -->\n", change.Number)
 	}
-	// Group CLs by category or by number order.
+	// Group CLs by category or by first associated issue number.
 	if byCategory {
 		pkgMap := map[string][]*generic.Changelist{}
 		for _, change := range changes {
@@ -190,7 +218,29 @@
 			}
 		}
 	} else {
-		for _, change := range changes {
+		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)
 		}
 	}
@@ -212,6 +262,22 @@
 	}
 }
 
+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)
+			}
+		}
+	}
+}
+
 // clPackage returns the package name from the CL's commit message,
 // or "??" if it's formatted unconventionally.
 func clPackage(cl *maintner.GerritCL) string {
@@ -243,17 +309,90 @@
 	return ""
 }
 
-func mdPrintContributors(authors map[*maintner.GitPerson]bool) {
-	var names []string
-	for author := range authors {
-		// It would be great to look up the GitHub username by using:
-		// https://pkg.go.dev/golang.org/x/build/internal/gophers#GetPerson.
-		names = append(names, author.Name())
+func mdPrintContributors(cls map[*maintner.GerritCL]bool) {
+	var usernames []string
+	for changelist := range cls {
+		author, err := fetchCLAuthorName(changelist, *project)
+		if err != nil {
+			log.Fatal("Error fetching Github information for %s: %v\n", changelist.Owner(), err)
+		}
+		usernames = append(usernames, author)
 	}
-	sort.Strings(names)
-	if len(names) > 1 {
-		names[len(names)-1] = "and " + names[len(names)-1]
+	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(names, ", "))
+	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
 }