refactor: releasepr markdown handling

This commit is contained in:
Julian Tölle 2024-08-31 23:41:09 +02:00
parent 0750bd6b46
commit 94caead6aa
6 changed files with 466 additions and 83 deletions

View file

@ -7,7 +7,8 @@ import (
// A Section struct represents a section of elements.
type Section struct {
gast.BaseBlock
Name string
Name string
Hidden bool
}
// Dump implements Node.Dump.
@ -26,6 +27,10 @@ func (n *Section) Kind() gast.NodeKind {
return KindSection
}
func (n *Section) HideInOutput() {
n.Hidden = true
}
// NewSection returns a new Section node.
func NewSection(name string) *Section {
return &Section{Name: name}

View file

@ -21,8 +21,8 @@ var (
const (
sectionTrigger = "<!--"
SectionStartFormat = "<!-- section-start %s -->"
SectionEndFormat = "<!-- section-end %s -->"
SectionStartFormat = "<!-- section-start %s -->\n"
SectionEndFormat = "\n<!-- section-end %s -->"
)
type sectionParser struct{}
@ -91,6 +91,10 @@ func (s SectionMarkdownRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegi
func (s SectionMarkdownRenderer) renderSection(w util.BufWriter, _ []byte, node gast.Node, enter bool) (gast.WalkStatus, error) {
n := node.(*ast.Section)
if n.Hidden {
return gast.WalkContinue, nil
}
if enter {
// Add blank previous line if applicable
if node.PreviousSibling() != nil && node.HasBlankPreviousLines() {
@ -107,12 +111,10 @@ func (s SectionMarkdownRenderer) renderSection(w util.BufWriter, _ []byte, node
return gast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := w.WriteRune('\n'); err != nil {
return gast.WalkStop, err
}
}
return gast.WalkContinue, nil
// Somehow the goldmark-markdown renderer does not flush this properly on its own
return gast.WalkContinue, w.Flush()
}
type section struct{}

View file

@ -2,13 +2,17 @@ package markdown
import (
"bytes"
"strings"
markdown "github.com/teekennedy/goldmark-markdown"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
)
func New() goldmark.Markdown {
@ -34,3 +38,82 @@ func Format(input string) (string, error) {
return buf.String(), nil
}
func GetCodeBlockText(source []byte, language string, output *string) gast.Walker {
return func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if n.Kind() != gast.KindFencedCodeBlock {
return gast.WalkContinue, nil
}
codeBlock := n.(*gast.FencedCodeBlock)
if string(codeBlock.Language(source)) != language {
return gast.WalkContinue, nil
}
*output = textFromLines(source, codeBlock)
// Stop looking after we find the first result
return gast.WalkStop, nil
}
}
func GetSectionText(source []byte, name string, output *string) gast.Walker {
return func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if n.Kind() != ast.KindSection {
return gast.WalkContinue, nil
}
section := n.(*ast.Section)
if section.Name != name {
return gast.WalkContinue, nil
}
// Do not show section markings in output, we only care about the content
section.HideInOutput()
// Found the right section
outputBuffer := new(bytes.Buffer)
err := New().Renderer().Render(outputBuffer, source, section)
if err != nil {
return gast.WalkStop, err
}
*output = outputBuffer.String()
// Stop looking after we find the first result
return gast.WalkStop, nil
}
}
func textFromLines(source []byte, n gast.Node) string {
content := make([]byte, 0)
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
content = append(content, line.Value(source)...)
}
return strings.TrimSpace(string(content))
}
func WalkAST(source []byte, walkers ...gast.Walker) (err error) {
doc := New().Parser().Parse(text.NewReader(source))
for _, walker := range walkers {
err = gast.Walk(doc, walker)
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,252 @@
package markdown
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yuin/goldmark/ast"
)
func TestFormat(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "heading spacing",
input: "# Foo\n## Bar\n### Baz",
want: "# Foo\n\n## Bar\n\n### Baz\n",
wantErr: assert.NoError,
},
{
name: "no empty lines for list items",
input: "# Foo\n- 1\n- 2\n",
want: "# Foo\n\n- 1\n- 2\n",
wantErr: assert.NoError,
},
{
name: "sections",
input: "# Foo\n<!-- section-start foobar -->\n- 1\n- 2\n<!-- section-end foobar -->\n",
want: "# Foo\n\n<!-- section-start foobar -->\n- 1\n- 2\n\n<!-- section-end foobar -->\n",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Format(tt.input)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestGetCodeBlockText(t *testing.T) {
type args struct {
source []byte
language string
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no code block",
args: args{
source: []byte("# Foo"),
language: "missing",
},
want: "",
wantErr: assert.NoError,
},
{
name: "code block",
args: args{
source: []byte("```test\nContent\n```"),
language: "test",
},
want: "Content",
wantErr: assert.NoError,
},
{
name: "code block with other language",
args: args{
source: []byte("```unknown\nContent\n```"),
language: "test",
},
want: "",
wantErr: assert.NoError,
},
{
name: "multiple code blocks with different languages",
args: args{
source: []byte("```unknown\nContent\n```\n\n```test\n1337\n```"),
language: "test",
},
want: "1337",
wantErr: assert.NoError,
},
{
name: "multiple code blocks with same language returns first one",
args: args{
source: []byte("```test\nContent\n```\n\n```test\n1337\n```"),
language: "test",
},
want: "Content",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
err := WalkAST(tt.args.source,
GetCodeBlockText(tt.args.source, tt.args.language, &got),
)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestGetSectionText(t *testing.T) {
type args struct {
source []byte
name string
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no section",
args: args{
source: []byte("# Foo"),
name: "missing",
},
want: "",
wantErr: assert.NoError,
},
{
name: "section",
args: args{
source: []byte("<!-- section-start test -->\nContent\n<!-- section-end test -->"),
name: "test",
},
want: "Content\n",
wantErr: assert.NoError,
},
{
name: "section with other name",
args: args{
source: []byte("<!-- section-start unknown -->\nContent\n<!-- section-end unknown -->"),
name: "test",
},
want: "",
wantErr: assert.NoError,
},
{
name: "multiple sections with different names",
args: args{
source: []byte("<!-- section-start unknown -->\nContent\n<!-- section-end unknown -->\n\n<!-- section-start test -->\n1337\n<!-- section-end test -->"),
name: "test",
},
want: "1337\n",
wantErr: assert.NoError,
},
{
name: "multiple sections with same name returns first one",
args: args{
source: []byte("<!-- section-start test -->\nContent\n<!-- section-end test -->\n\n<!-- section-start test -->\n1337\n<!-- section-end test -->"),
name: "test",
},
want: "Content\n",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
err := WalkAST(tt.args.source,
GetSectionText(tt.args.source, tt.args.name, &got),
)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestWalkAST(t *testing.T) {
type args struct {
source []byte
walkers []ast.Walker
}
tests := []struct {
name string
args args
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty walker",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, nil
},
},
},
wantErr: assert.NoError,
},
{
name: "returns walker error",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, errors.New("test")
},
},
},
wantErr: assert.Error,
},
{
name: "runs all walkers",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, nil
},
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, errors.New("test")
},
},
},
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WalkAST(tt.args.source, tt.args.walkers...)
if !tt.wantErr(t, err) {
return
}
})
}
}

View file

@ -6,15 +6,10 @@ import (
"fmt"
"log"
"regexp"
"strings"
"text/template"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/markdown"
ast2 "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
"github.com/apricote/releaser-pleaser/internal/versioning"
)
@ -140,31 +135,11 @@ func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) R
func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (ReleaseOverrides, error) {
source := []byte(pr.Description)
descriptionAST := markdown.New().Parser().Parse(text.NewReader(source))
err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if n.Type() != ast.TypeBlock || n.Kind() != ast.KindFencedCodeBlock {
return ast.WalkContinue, nil
}
codeBlock, ok := n.(*ast.FencedCodeBlock)
if !ok {
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
}
switch string(codeBlock.Language(source)) {
case DescriptionLanguagePrefix:
overrides.Prefix = textFromLines(source, codeBlock)
case DescriptionLanguageSuffix:
overrides.Suffix = textFromLines(source, codeBlock)
}
return ast.WalkContinue, nil
})
err := markdown.WalkAST(source,
markdown.GetCodeBlockText(source, DescriptionLanguagePrefix, &overrides.Prefix),
markdown.GetCodeBlockText(source, DescriptionLanguageSuffix, &overrides.Suffix),
)
if err != nil {
return ReleaseOverrides{}, err
}
@ -174,59 +149,15 @@ func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (Rele
func (pr *ReleasePullRequest) ChangelogText() (string, error) {
source := []byte(pr.Description)
gm := markdown.New()
descriptionAST := gm.Parser().Parse(text.NewReader(source))
var section *ast2.Section
err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if n.Type() != ast.TypeBlock || n.Kind() != ast2.KindSection {
return ast.WalkContinue, nil
}
anySection, ok := n.(*ast2.Section)
if !ok {
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
}
if anySection.Name != MarkdownSectionChangelog {
return ast.WalkContinue, nil
}
section = anySection
return ast.WalkStop, nil
})
var sectionText string
err := markdown.WalkAST(source, markdown.GetSectionText(source, MarkdownSectionChangelog, &sectionText))
if err != nil {
return "", err
}
if section == nil {
return "", nil
}
return sectionText, nil
outputBuffer := new(bytes.Buffer)
err = gm.Renderer().Render(outputBuffer, source, section)
if err != nil {
return "", err
}
return outputBuffer.String(), nil
}
func textFromLines(source []byte, n ast.Node) string {
content := make([]byte, 0)
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
content = append(content, line.Value(source)...)
}
return strings.TrimSpace(string(content))
}
func (pr *ReleasePullRequest) SetTitle(branch, version string) {

View file

@ -1,11 +1,121 @@
package releasepr
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/versioning"
)
func TestReleasePullRequest_GetOverrides(t *testing.T) {
tests := []struct {
name string
pr ReleasePullRequest
want ReleaseOverrides
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty",
pr: ReleasePullRequest{},
want: ReleaseOverrides{},
wantErr: assert.NoError,
},
{
// TODO: Test for multiple version flags
name: "single version flag",
pr: ReleasePullRequest{
Labels: []Label{LabelNextVersionTypeAlpha},
},
want: ReleaseOverrides{
NextVersionType: versioning.NextVersionTypeAlpha,
},
wantErr: assert.NoError,
},
{
name: "prefix in description",
pr: ReleasePullRequest{
Description: "```rp-prefix\n## Foo\n\n- Cool thing\n```",
},
want: ReleaseOverrides{Prefix: "## Foo\n\n- Cool thing"},
wantErr: assert.NoError,
},
{
name: "suffix in description",
pr: ReleasePullRequest{
Description: "```rp-suffix\n## Compatibility\n\nNo compatibility guarantees.\n```",
},
want: ReleaseOverrides{Suffix: "## Compatibility\n\nNo compatibility guarantees."},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.pr.GetOverrides()
if !tt.wantErr(t, err, fmt.Sprintf("GetOverrides()")) {
return
}
assert.Equalf(t, tt.want, got, "GetOverrides()")
})
}
}
func TestReleasePullRequest_ChangelogText(t *testing.T) {
tests := []struct {
name string
description string
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no section",
description: "# Foo\n",
want: "",
wantErr: assert.NoError,
},
{
name: "with section",
description: `# Foobar
<!-- section-start changelog -->
This is the changelog
## Awesome
### New
#### Changes
<!-- section-end changelog -->
Suffix Things
`,
want: `This is the changelog
## Awesome
### New
#### Changes
`,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pr := &ReleasePullRequest{
Description: tt.description,
}
got, err := pr.ChangelogText()
if !tt.wantErr(t, err, fmt.Sprintf("ChangelogText()")) {
return
}
assert.Equalf(t, tt.want, got, "ChangelogText()")
})
}
}
func TestReleasePullRequest_SetTitle(t *testing.T) {
type args struct {
branch string