relnote: a package for working with release notes
This CL starts a package that will be used in at least two places
as part of the current improvements to the release notes process.
- This repo will use it to find incomplete note fragments and
also to merge them into a final document.
- The main repo will use it in tests that validate the fragments.
It has few dependencies because it will be vendored into the main repo.
Aside from the standard library, it depends only on rsc.io/markdown,
which itself depends only on some packages in x/tools and x/text.
For golang/go#64169.
Change-Id: Ifa558834f491bc6a249cbb540574fdb9009e9f8d
Reviewed-on: https://go-review.googlesource.com/c/build/+/542495
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/go.mod b/go.mod
index 523e53a..aa27f49 100644
--- a/go.mod
+++ b/go.mod
@@ -156,4 +156,5 @@
google.golang.org/genproto/googleapis/bytestream v0.0.0-20230807174057-1744710a1577 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+ rsc.io/markdown v0.0.0-20231114125513-6fc7bf989e0c // indirect
)
diff --git a/go.sum b/go.sum
index e13cbad..7e02b2b 100644
--- a/go.sum
+++ b/go.sum
@@ -1430,6 +1430,8 @@
modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/markdown v0.0.0-20231114125513-6fc7bf989e0c h1:HaCtoXmNbydct3DDtBACBsvfOdbU5sH7TdtkrBmylco=
+rsc.io/markdown v0.0.0-20231114125513-6fc7bf989e0c/go.mod h1:NAB5d9ChqypB0BfWUzhyn7GTyPwr2Q0KxmrAFJnOT/g=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
diff --git a/relnote/relnote.go b/relnote/relnote.go
new file mode 100644
index 0000000..6362145
--- /dev/null
+++ b/relnote/relnote.go
@@ -0,0 +1,140 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package relnote supports working with release notes.
+//
+// Its main feature is the ability to merge Markdown fragments into a single
+// document.
+//
+// This package has minimal imports, so that it can be vendored into the
+// main go repo.
+//
+// # Fragments
+//
+// A release note fragment is designed to be merged into a final document.
+// The merging is done by matching headings, and inserting the contents
+// of that heading (that is, the non-heading blocks following it) into
+// the merged document.
+//
+// If the text of a heading begins with '+', then it doesn't have to match
+// with an existing heading. If it doesn't match, the heading and its contents
+// are both inserted into the result.
+//
+// A fragment must begin with a non-empty matching heading.
+package relnote
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ md "rsc.io/markdown"
+)
+
+// NewParser returns a properly configured Markdown parser.
+func NewParser() *md.Parser {
+ var p md.Parser
+ p.HeadingIDs = true
+ return &p
+}
+
+// CheckFragment reports problems in a release-note fragment.
+func CheckFragment(data string) error {
+ doc := NewParser().Parse(data)
+ if len(doc.Blocks) == 0 {
+ return errors.New("empty content")
+ }
+ if !isHeading(doc.Blocks[0]) {
+ return errors.New("does not start with a heading")
+ }
+ htext := text(doc.Blocks[0])
+ if strings.TrimSpace(htext) == "" {
+ return errors.New("starts with an empty heading")
+ }
+ if !headingTextMustMatch(htext) {
+ return errors.New("starts with a non-matching heading (text begins with a '+')")
+ }
+ // Check that the content of each heading either contains a TODO or at least one sentence.
+ cur := doc.Blocks[0] // the heading beginning the current section
+ found := false // did we find the content we were looking for in this section?
+ for _, b := range doc.Blocks[1:] {
+ if isHeading(b) {
+ if !found {
+ break
+ }
+ cur = b
+ found = false
+ } else {
+ t := text(b)
+ // Check for a TODO or standard end-of-sentence punctuation
+ // (as a crude approximation to a full sentence).
+ found = strings.Contains(t, "TODO") || strings.ContainsAny(t, ".?!")
+ }
+ }
+ if !found {
+ return fmt.Errorf("section with heading %q needs a TODO or a sentence", text(cur))
+ }
+ return nil
+}
+
+// isHeading reports whether b is a Heading node.
+func isHeading(b md.Block) bool {
+ _, ok := b.(*md.Heading)
+ return ok
+}
+
+// headingTextMustMatch reports whether s is the text of a heading
+// that must be matched against another heading.
+//
+// Headings beginning with '+' don't require a match; all others do.
+func headingTextMustMatch(s string) bool {
+ return len(s) == 0 || s[0] != '+'
+}
+
+// text returns all the text in a block, without any formatting.
+func text(b md.Block) string {
+ switch b := b.(type) {
+ case *md.Heading:
+ return text(b.Text)
+ case *md.Text:
+ return inlineText(b.Inline)
+ case *md.CodeBlock:
+ return strings.Join(b.Text, "\n")
+ case *md.HTMLBlock:
+ return strings.Join(b.Text, "\n")
+ case *md.List:
+ return blocksText(b.Items)
+ case *md.Item:
+ return blocksText(b.Blocks)
+ case *md.Empty:
+ return ""
+ case *md.Paragraph:
+ return text(b.Text)
+ case *md.Quote:
+ return blocksText(b.Blocks)
+ default:
+ panic(fmt.Sprintf("unknown block type %T", b))
+ }
+}
+
+// blocksText returns all the text in a slice of block nodes.
+func blocksText(bs []md.Block) string {
+ var d strings.Builder
+ for _, b := range bs {
+ io.WriteString(&d, text(b))
+ fmt.Fprintln(&d)
+ }
+ return d.String()
+}
+
+// inlineText returns all the next in a slice of inline nodes.
+func inlineText(ins []md.Inline) string {
+ var buf bytes.Buffer
+ for _, in := range ins {
+ in.PrintText(&buf)
+ }
+ return buf.String()
+}
diff --git a/relnote/relnote_test.go b/relnote/relnote_test.go
new file mode 100644
index 0000000..d79773d
--- /dev/null
+++ b/relnote/relnote_test.go
@@ -0,0 +1,73 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package relnote
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCheckFragment(t *testing.T) {
+ for _, test := range []struct {
+ in string
+ // part of err.Error(), or empty if success
+ want string
+ }{
+ {
+ // has a TODO
+ "# heading\nTODO(jba)",
+ "",
+ },
+ {
+ // has a sentence
+ "# heading\nSomething.",
+ "",
+ },
+ {
+ // sentence is inside some formatting
+ "# heading\n- _Some_*thing.*",
+ "",
+ },
+ {
+ // multiple sections have what they need
+ "# H1\n\nTODO\n\n## H2\nOk.",
+ "",
+ },
+ {
+ // questions and exclamations are OK
+ "# H1\n Are questions ok? \n# H2\n Must write this note!",
+ "",
+ },
+ {
+ "TODO\n# heading",
+ "does not start with a heading",
+ },
+ {
+ "# \t\nTODO",
+ "starts with an empty heading",
+ },
+ {
+ "# +heading\nTODO",
+ "starts with a non-matching head",
+ },
+ {
+ "# heading",
+ "needs",
+ },
+ {
+ "# H1\n non-final section has a problem\n## H2\n TODO",
+ "needs",
+ },
+ } {
+ got := CheckFragment(test.in)
+ if test.want == "" {
+ if got != nil {
+ t.Errorf("%q: got %q, want nil", test.in, got)
+ }
+ } else if got == nil || !strings.Contains(got.Error(), test.want) {
+ t.Errorf("%q: got %q, want error containing %q", test.in, got, test.want)
+ }
+ }
+}