cmd/relnote: add HTML output and delta-from-last-HTML support

Updates golang/go#20587

Change-Id: Ie4ba1a4c31d363310b654bb6dff5080b0528cb32
Reviewed-on: https://go-review.googlesource.com/45011
Reviewed-by: Ian Lance Taylor <iant@golang.org>
diff --git a/cmd/relnote/relnote.go b/cmd/relnote/relnote.go
index cf3633d..db771be 100644
--- a/cmd/relnote/relnote.go
+++ b/cmd/relnote/relnote.go
@@ -7,8 +7,12 @@
 package main
 
 import (
+	"bytes"
 	"context"
+	"flag"
 	"fmt"
+	"html"
+	"io/ioutil"
 	"log"
 	"regexp"
 	"sort"
@@ -18,52 +22,127 @@
 	"golang.org/x/build/maintner/godata"
 )
 
+var (
+	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' occurence 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=
+}
+
+func (c change) TextLine() string {
+	subj := clSubject(c.CL)
+	if c.Note != "yes" && c.Note != "y" {
+		subj = c.Note + ": " + subj
+	}
+	return fmt.Sprintf("https://golang.org/cl/%d: %s", c.CL.Number, subj)
+}
+
 func main() {
+	flag.Parse()
+
+	var existingHTML []byte
+	if *exclFile != "" {
+		var err error
+		existingHTML, err = ioutil.ReadFile(*exclFile)
+		if err != nil {
+			log.Fatal(err)
+		}
+	}
+
 	corpus, err := godata.Get(context.Background())
 	if err != nil {
 		log.Fatal(err)
 	}
 	ger := corpus.Gerrit()
-	changes := map[string][]string{} // keyed by pkg
+	changes := map[string][]change{} // keyed by pkg
 	ger.ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
 		if gp.Server() != "go.googlesource.com" {
 			return nil
 		}
 		gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
-			if relnote := clRelNote(cl); relnote != "" {
-				subj := cl.Commit.Msg
-				if i := strings.Index(subj, "\n"); i != -1 {
-					subj = subj[:i]
-				}
-				pkg := "??"
-				if i := strings.Index(subj, ":"); i != -1 {
-					pkg = subj[:i]
-				}
-				if relnote != "yes" {
-					subj = relnote + ": " + subj
-				}
-				change := fmt.Sprintf("https://golang.org/cl/%d: %s", cl.Number, subj)
-				changes[pkg] = append(changes[pkg], change)
+			relnote := clRelNote(cl)
+			if relnote == "" ||
+				bytes.Contains(existingHTML, []byte(fmt.Sprintf("CL %d", cl.Number))) {
+				return nil
 			}
+			pkg := clPackage(cl)
+			changes[pkg] = append(changes[pkg], change{
+				Note: relnote,
+				CL:   cl,
+			})
 			return nil
 		})
 		return nil
 	})
 
 	var pkgs []string
-	for pkg, lines := range changes {
+	for pkg, changes := range changes {
 		pkgs = append(pkgs, pkg)
-		sort.Strings(lines)
+		sort.Slice(changes, func(i, j int) bool {
+			return changes[i].CL.Number < changes[j].CL.Number
+		})
 	}
 	sort.Strings(pkgs)
-	for _, pkg := range pkgs {
-		fmt.Printf("%s\n", pkg)
-		for _, change := range changes[pkg] {
-			fmt.Printf("  %s\n", change)
+
+	if *htmlMode {
+		for _, pkg := range pkgs {
+			if !strings.HasPrefix(pkg, "cmd/") {
+				continue
+			}
+			for _, change := range changes[pkg] {
+				fmt.Printf("<!-- CL %d: %s -->\n", change.CL.Number, change.TextLine())
+			}
+		}
+		for _, pkg := range pkgs {
+			if strings.HasPrefix(pkg, "cmd/") {
+				continue
+			}
+			fmt.Printf("<dl id=%q><dt><a href=%q>%s</a></dt>\n  <dd>\n",
+				pkg, "/pkg/"+pkg+"/", pkg)
+			for _, change := range changes[pkg] {
+				changeURL := fmt.Sprintf("https://golang.org/cl/%d", change.CL.Number)
+				subj := clSubject(change.CL)
+				subj = strings.TrimPrefix(subj, pkg+": ")
+				fmt.Printf("    <p><!-- CL %d -->\n      TODO: <a href=%q>%s</a>: %s\n    </p>\n\n",
+					change.CL.Number, changeURL, changeURL, html.EscapeString(subj))
+			}
+			fmt.Printf("</dl><!-- %s -->\n\n", pkg)
+		}
+
+	} else {
+		for _, pkg := range pkgs {
+			fmt.Printf("%s\n", pkg)
+			for _, change := range changes[pkg] {
+				fmt.Printf("  %s\n", change.TextLine())
+			}
 		}
 	}
 }
 
+// clSubject returns the first line of the CL's commit message,
+// without the trailing newline.
+func clSubject(cl *maintner.GerritCL) string {
+	subj := cl.Commit.Msg
+	if i := strings.Index(subj, "\n"); i != -1 {
+		return subj[:i]
+	}
+	return subj
+}
+
+// clPackage returns the package name from the CL's commit message,
+// or "??" if it's formatted unconventionally.
+func clPackage(cl *maintner.GerritCL) string {
+	subj := clSubject(cl)
+	if i := strings.Index(subj, ":"); i != -1 {
+		return subj[:i]
+	}
+	return "??"
+}
+
 var relNoteRx = regexp.MustCompile(`RELNOTES?=(.+)`)
 
 func parseRelNote(s string) string {