Skip to content

Commit

Permalink
relnote: a package for working with release notes
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
jba committed Nov 17, 2023
1 parent 7c9d6b2 commit 44d28ab
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 0 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,5 @@ require (
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
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,8 @@ modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfp
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=
Expand Down
140 changes: 140 additions & 0 deletions relnote/relnote.go
Original file line number Diff line number Diff line change
@@ -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()
}
73 changes: 73 additions & 0 deletions relnote/relnote_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

0 comments on commit 44d28ab

Please sign in to comment.