cmd/relnote: added markdown summary info from issue and CLs for each TODO

Formats into markdown for easy copy-paste into documents.
The issue and CL links are clickable and go.dev-relative.

Explicitly skips 9-todo.md in processing, and ends with
a suggestion that perhaps you'd like to put the output there.

Change-Id: I078488fb913080614818bfee9375ebf87a1bc675
Reviewed-on: https://go-review.googlesource.com/c/build/+/784620
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/relnote/todo.go b/cmd/relnote/todo.go
index 5b02702..1709918 100644
--- a/cmd/relnote/todo.go
+++ b/cmd/relnote/todo.go
@@ -29,6 +29,8 @@
 type ToDo struct {
 	message    string // what is to be done
 	provenance string // where the TODO came from
+	summary    string // summary of the CL or issue
+	isIssue    bool   // is this an issue or a CL
 }
 
 // todo prints a report to w on which release notes need to be written.
@@ -100,7 +102,8 @@
 		if err != nil {
 			return err
 		}
-		if !d.IsDir() && strings.HasSuffix(path, ".md") {
+		// Don't recursively process 9-todo.md itself
+		if !d.IsDir() && strings.HasSuffix(path, ".md") && d.Name() != "9-todo.md" {
 			if err := infoFromFile(fsys, path, mentioned, addToDo); err != nil {
 				return err
 			}
@@ -202,6 +205,10 @@
 	})
 }
 
+func clProvenance(cl *maintner.GerritCL) string {
+	return fmt.Sprintf("RELNOTE comment in [CL %d](/cl/%[1]d)", cl.Number)
+}
+
 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 {
@@ -213,7 +220,8 @@
 		}
 		add(ToDo{
 			message:    "TODO: " + rn,
-			provenance: fmt.Sprintf("RELNOTE comment in https://go.dev/cl/%d", cl.Number),
+			provenance: clProvenance(cl),
+			summary:    cl.Commit.Msg,
 		})
 	}
 	return nil
@@ -227,9 +235,18 @@
 		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.
+
+			message := fmt.Sprintf("TODO: accepted [proposal %d](/issue/%[1]d)", num)
+			// This add can create duplicate entries, to be filtered out after sorting.
 			add(ToDo{
-				message:    fmt.Sprintf("TODO: accepted proposal https://go.dev/issue/%d", num),
-				provenance: fmt.Sprintf("https://go.dev/cl/%d", cl.Number),
+				message: message,
+				summary: issue.Title,
+				isIssue: true,
+			})
+			add(ToDo{
+				message:    message,
+				provenance: clProvenance(cl),
+				summary:    cl.Commit.Msg,
 			})
 		}
 	}
@@ -319,23 +336,75 @@
 	return slices.Compact(list)
 }
 
+func fixURL(s string) string {
+	return strings.ReplaceAll(s, "https://go.dev/", "/")
+}
+
 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.
+
+	// sort for deterministic output
+	slices.SortFunc(todos, func(a, b ToDo) int {
+		// Sort issues first, for canonical ordering
+		// of the summary information and for duplicate
+		// removal.
+		if a.isIssue != b.isIssue {
+			if a.isIssue {
+				return -1
+			}
+			return 1
+		}
+		if c := strings.Compare(a.message, b.message); c != 0 {
+			return c
+		}
+		return strings.Compare(a.provenance, b.provenance)
+		// TODO LATER: the "best" sort order might be by the summary
+		// line of the issue, since those tend to lead with packages
+		// and that would group packages together in the output.
+	})
+
 	byMessage := map[string][]ToDo{}
-	for _, td := range todos {
+
+	for i, td := range todos {
+		// there will be duplicates marked isIssue, skip the non-first ones
+		if i > 0 && td.isIssue && td.message == todos[i-1].message {
+			continue
+		}
 		byMessage[td.message] = append(byMessage[td.message], td)
 	}
 	msgs := slices.Sorted(maps.Keys(byMessage)) // sort for deterministic output
 	for _, msg := range msgs {
 		var provs []string
+		var summaries []string
 		for _, td := range byMessage[msg] {
-			provs = append(provs, td.provenance)
+			summaries = append(summaries, fixURL(td.summary))
+			if td.provenance != "" {
+				provs = append(provs, fixURL(td.provenance))
+			}
 		}
-		slices.Sort(provs) // for deterministic output
-		if _, err := fmt.Fprintf(w, "%s (from %s)\n", msg, strings.Join(provs, ", ")); err != nil {
+
+		// Lead with newline to put some space between the TODO blocks
+		if _, err := fmt.Fprintf(w, "\n### %s (from %s)\n", fixURL(msg), strings.Join(provs, ", ")); err != nil {
 			return err
 		}
+		for _, s := range summaries {
+			if s == "" {
+				continue
+			}
+			if n := strings.Index(s, "\n"); n >= 0 {
+				s = s[:n]
+			}
+			if strings.Index(s, "`") != -1 {
+				// Per https://daringfireball.net/projects/markdown/syntax#code
+				// this is how to code-wrap text containing *single* backticks.
+				// Not planning to support multiple consecutive backticks here.
+				fmt.Fprintf(w, "- `` %s ``\n", s)
+			} else {
+				fmt.Fprintf(w, "- `%s`\n", s)
+			}
+		}
 	}
+	fmt.Fprintf(w, "\nThe ###TODO markdown above this line is usually copied or appended to doc/next/9-todo.md\n")
 	return nil
 }