From fe871a021348b7f121e622bbcd8f24c2aa5201ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 3 Aug 2024 09:26:51 +0200 Subject: [PATCH] feat(releasepr): release PRs can be updated - PR Description - Read prefix+suffix from PR description and put into changelog - Keep those overrides on PR description changes - Add pending level to new PRs --- changelog.go | 4 +- changelog.md.tpl | 7 + changelog_test.go | 52 +- cmd/rp/cmd/run.go | 24 +- forge.go | 40 +- go.mod | 2 +- go.sum | 4 +- internal/markdown/extensions/ast/section.go | 32 + internal/markdown/extensions/section.go | 88 ++ internal/markdown/goldmark.go | 17 + internal/markdown/renderer/markdown/LICENSE | 21 + internal/markdown/renderer/markdown/README.md | 4 + .../markdown/renderer/markdown/renderer.go | 836 ++++++++++++++++++ .../markdown/renderer/markdown/section.go | 35 + releasepr.go | 116 ++- releasepr.md.tpl | 25 + releasepr_test.go | 157 ++++ 17 files changed, 1442 insertions(+), 22 deletions(-) create mode 100644 internal/markdown/extensions/ast/section.go create mode 100644 internal/markdown/extensions/section.go create mode 100644 internal/markdown/goldmark.go create mode 100644 internal/markdown/renderer/markdown/LICENSE create mode 100644 internal/markdown/renderer/markdown/README.md create mode 100644 internal/markdown/renderer/markdown/renderer.go create mode 100644 internal/markdown/renderer/markdown/section.go create mode 100644 releasepr.md.tpl create mode 100644 releasepr_test.go diff --git a/changelog.go b/changelog.go index 7ba61c6..40c65d4 100644 --- a/changelog.go +++ b/changelog.go @@ -90,7 +90,7 @@ func UpdateChangelogFile(wt *git.Worktree, newEntry string) error { return nil } -func NewChangelogEntry(changesets []Changeset, version, link string) (string, error) { +func NewChangelogEntry(changesets []Changeset, version, link, prefix, suffix string) (string, error) { features := make([]AnalyzedCommit, 0) fixes := make([]AnalyzedCommit, 0) @@ -111,6 +111,8 @@ func NewChangelogEntry(changesets []Changeset, version, link string) (string, er "Fixes": fixes, "Version": version, "VersionLink": link, + "Prefix": prefix, + "Suffix": suffix, }) if err != nil { return "", err diff --git a/changelog.md.tpl b/changelog.md.tpl index 12ac5d1..85482e1 100644 --- a/changelog.md.tpl +++ b/changelog.md.tpl @@ -1,4 +1,7 @@ ## [{{.Version}}]({{.VersionLink}}) +{{- if .Prefix }} +{{ .Prefix }} +{{ end -}} {{- if (gt (len .Features) 0) }} ### Features @@ -13,3 +16,7 @@ - {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}} {{ end -}} {{- end -}} + +{{- if .Suffix }} +{{ .Suffix }} +{{ end -}} diff --git a/changelog_test.go b/changelog_test.go index 9751c17..91ffc85 100644 --- a/changelog_test.go +++ b/changelog_test.go @@ -99,6 +99,8 @@ func Test_NewChangelogEntry(t *testing.T) { changesets []Changeset version string link string + prefix string + suffix string } tests := []struct { name string @@ -188,6 +190,54 @@ func Test_NewChangelogEntry(t *testing.T) { - Foobar! - **sad**: So sad! +`, + wantErr: assert.NoError, + }, + { + name: "prefix", + args: args{ + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{ + { + Commit: Commit{}, + Type: "fix", + Description: "Foobar!", + }, + }}}, + version: "1.0.0", + link: "https://example.com/1.0.0", + prefix: "### Breaking Changes", + }, + want: `## [1.0.0](https://example.com/1.0.0) +### Breaking Changes + +### Bug Fixes + +- Foobar! +`, + wantErr: assert.NoError, + }, + { + name: "suffix", + args: args{ + changesets: []Changeset{{ChangelogEntries: []AnalyzedCommit{ + { + Commit: Commit{}, + Type: "fix", + Description: "Foobar!", + }, + }}}, + version: "1.0.0", + link: "https://example.com/1.0.0", + suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.", + }, + want: `## [1.0.0](https://example.com/1.0.0) +### Bug Fixes + +- Foobar! + +### Compatibility + +This version is compatible with flux-compensator v2.2 - v2.9. `, wantErr: assert.NoError, }, @@ -195,7 +245,7 @@ func Test_NewChangelogEntry(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewChangelogEntry(tt.args.changesets, tt.args.version, tt.args.link) + got, err := NewChangelogEntry(tt.args.changesets, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix) if !tt.wantErr(t, err) { return } diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index cb57897..0170306 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -167,7 +167,7 @@ func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Cha return err } - changelogEntry, err := rp.NewChangelogEntry(changesets, nextVersion, forge.ReleaseURL(nextVersion)) + changelogEntry, err := rp.NewChangelogEntry(changesets, nextVersion, forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix) if err != nil { return err } @@ -237,18 +237,28 @@ func reconcileReleasePR(ctx context.Context, forge rp.Forge, changesets []rp.Cha // Open/Update PR if pr == nil { - pr = &rp.ReleasePullRequest{ - Title: releaseCommitMessage, - Description: "TODO", - Labels: nil, - Head: rpBranch, + pr, err = rp.NewReleasePullRequest(rpBranch, flagBranch, nextVersion, changelogEntry) + if err != nil { + return err } - pr, err = forge.CreatePullRequest(ctx, pr) + err = forge.CreatePullRequest(ctx, pr) if err != nil { return err } logger.InfoContext(ctx, "opened pull request", "pr.title", pr.Title, "pr.id", pr.ID) + } else { + pr.SetTitle(flagBranch, nextVersion) + err = pr.SetDescription(changelogEntry) + if err != nil { + return err + } + + err = forge.UpdatePullRequest(ctx, pr) + if err != nil { + return err + } + logger.InfoContext(ctx, "updated pull request", "pr.title", pr.Title, "pr.id", pr.ID) } return nil diff --git a/forge.go b/forge.go index b8ee226..853133c 100644 --- a/forge.go +++ b/forge.go @@ -49,7 +49,8 @@ type Forge interface { // exists, it returns nil. PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error) - CreatePullRequest(context.Context, *ReleasePullRequest) (*ReleasePullRequest, error) + CreatePullRequest(context.Context, *ReleasePullRequest) error + UpdatePullRequest(context.Context, *ReleasePullRequest) error } type ForgeOptions struct { @@ -332,7 +333,7 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele } for _, pr := range prs { - if pr.GetBase().GetLabel() == g.options.BaseBranch && pr.GetHead().GetLabel() == branch && pr.GetState() == GitHubPRStateOpen { + if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == GitHubPRStateOpen { labels := make([]string, 0, len(pr.Labels)) for _, label := range pr.Labels { labels = append(labels, label.GetName()) @@ -343,7 +344,7 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele Title: pr.GetTitle(), Description: pr.GetBody(), Labels: labels, - Head: pr.GetHead().GetLabel(), + Head: pr.GetHead().GetRef(), }, nil } } @@ -357,7 +358,8 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*Rele return nil, nil } -func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) (*ReleasePullRequest, error) { +func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { + // TODO: Labels ghPR, _, err := g.client.PullRequests.Create( ctx, g.options.Owner, g.options.Repo, &github.NewPullRequest{ @@ -368,12 +370,36 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) }, ) if err != nil { - return nil, err + return err } - pr.ID = int(*ghPR.ID) // TODO: String ID? + _, _, err = g.client.Issues.AddLabelsToIssue( + ctx, g.options.Owner, g.options.Repo, + ghPR.GetNumber(), pr.Labels, + ) + if err != nil { + return err + } - return pr, nil + // TODO: String ID? + pr.ID = ghPR.GetNumber() + + return nil +} + +func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) error { + _, _, err := g.client.PullRequests.Edit( + ctx, g.options.Owner, g.options.Repo, pr.ID, + &github.PullRequest{ + Title: &pr.Title, + Body: &pr.Description, + }, + ) + if err != nil { + return err + } + + return nil } func (g *GitHubOptions) autodiscover() { diff --git a/go.mod b/go.mod index 5da35b9..e55cfa9 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/leodido/go-conventionalcommits v0.12.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 - github.com/yuin/goldmark v1.7.3 + github.com/yuin/goldmark v1.7.4 ) require ( diff --git a/go.sum b/go.sum index d485246..385a5a2 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,8 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.3 h1:fdk0a/y60GsS4NbEd13GSIP+d8OjtTkmluY32Dy1Z/A= -github.com/yuin/goldmark v1.7.3/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= +github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= diff --git a/internal/markdown/extensions/ast/section.go b/internal/markdown/extensions/ast/section.go new file mode 100644 index 0000000..43937c3 --- /dev/null +++ b/internal/markdown/extensions/ast/section.go @@ -0,0 +1,32 @@ +package ast + +import ( + gast "github.com/yuin/goldmark/ast" +) + +// A Section struct represents a section of elements. +type Section struct { + gast.BaseBlock + Name string +} + +// Dump implements Node.Dump. +func (n *Section) Dump(source []byte, level int) { + m := map[string]string{ + "Name": n.Name, + } + gast.DumpHelper(n, source, level, m, nil) +} + +// KindSection is a NodeKind of the Section node. +var KindSection = gast.NewNodeKind("Section") + +// Kind implements Node.Kind. +func (n *Section) Kind() gast.NodeKind { + return KindSection +} + +// NewSection returns a new Section node. +func NewSection(name string) *Section { + return &Section{Name: name} +} diff --git a/internal/markdown/extensions/section.go b/internal/markdown/extensions/section.go new file mode 100644 index 0000000..e85808a --- /dev/null +++ b/internal/markdown/extensions/section.go @@ -0,0 +1,88 @@ +package extensions + +import ( + "regexp" + + "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/ast" +) + +var sectionStartRegex = regexp.MustCompile(`^`) +var sectionEndRegex = regexp.MustCompile(`^`) + +const ( + sectionTrigger = "" + SectionEndFormat = "" +) + +type sectionParser struct { +} + +func (s *sectionParser) Open(_ gast.Node, reader text.Reader, _ parser.Context) (gast.Node, parser.State) { + line, _ := reader.PeekLine() + + if result := sectionStartRegex.FindSubmatch(line); result != nil { + reader.AdvanceLine() + return ast.NewSection(string(result[1])), parser.HasChildren + } + + return nil, parser.NoChildren +} + +func (s *sectionParser) Continue(node gast.Node, reader text.Reader, _ parser.Context) parser.State { + n := node.(*ast.Section) + + line, _ := reader.PeekLine() + + if result := sectionEndRegex.FindSubmatch(line); result != nil { + if string(result[1]) == n.Name { + reader.AdvanceLine() + return parser.Close + } + } + + return parser.Continue | parser.HasChildren +} + +func (s *sectionParser) Close(_ gast.Node, _ text.Reader, _ parser.Context) { + // Nothing to do +} + +func (s *sectionParser) CanInterruptParagraph() bool { + return true +} + +func (s *sectionParser) CanAcceptIndentedLine() bool { + return false +} + +var defaultSectionParser = §ionParser{} + +// NewSectionParser returns a new BlockParser that can parse +// a section block. Section blocks can be used to group various nodes under a parent ast node. +// This parser must take precedence over the parser.HTMLParser. +func NewSectionParser() parser.BlockParser { + return defaultSectionParser +} + +func (s *sectionParser) Trigger() []byte { + return []byte(sectionTrigger) +} + +type section struct { +} + +// Section is an extension that allow you to use group content under a shared parent ast node. +var Section = §ion{} + +func (e *section) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(NewSectionParser(), 0), + )) +} diff --git a/internal/markdown/goldmark.go b/internal/markdown/goldmark.go new file mode 100644 index 0000000..b78f089 --- /dev/null +++ b/internal/markdown/goldmark.go @@ -0,0 +1,17 @@ +package markdown + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" + + "github.com/apricote/releaser-pleaser/internal/markdown/extensions" + "github.com/apricote/releaser-pleaser/internal/markdown/renderer/markdown" +) + +func New() goldmark.Markdown { + return goldmark.New( + goldmark.WithExtensions(extensions.Section), + goldmark.WithRenderer(renderer.NewRenderer(renderer.WithNodeRenderers(util.Prioritized(markdown.NewRenderer(), 1)))), + ) +} diff --git a/internal/markdown/renderer/markdown/LICENSE b/internal/markdown/renderer/markdown/LICENSE new file mode 100644 index 0000000..0edc41c --- /dev/null +++ b/internal/markdown/renderer/markdown/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Rolf Lewis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/internal/markdown/renderer/markdown/README.md b/internal/markdown/renderer/markdown/README.md new file mode 100644 index 0000000..c84792a --- /dev/null +++ b/internal/markdown/renderer/markdown/README.md @@ -0,0 +1,4 @@ +This directory is a vendored copy of https://github.com/RolfLewis/goldmark-down/blob/main/markdown.go. +The original repository is set to a `main` package which can not be imported. + +It is under the MIT license. \ No newline at end of file diff --git a/internal/markdown/renderer/markdown/renderer.go b/internal/markdown/renderer/markdown/renderer.go new file mode 100644 index 0000000..7a369e7 --- /dev/null +++ b/internal/markdown/renderer/markdown/renderer.go @@ -0,0 +1,836 @@ +package markdown + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + + "github.com/yuin/goldmark/ast" + exast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" + + rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" +) + +type blockState struct { + node ast.Node + fresh bool +} + +type listState struct { + marker byte + ordered bool + index int +} + +type Renderer struct { + listStack []listState + openBlocks []blockState + prefixStack []string + prefix []byte + atNewline bool +} + +// NewRenderer returns a new Renderer with given options. +func NewRenderer() renderer.NodeRenderer { + r := &Renderer{} + + return r +} + +func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + // default registrations + // blocks + reg.Register(ast.KindDocument, r.renderDocument) + reg.Register(ast.KindHeading, r.renderHeading) + reg.Register(ast.KindBlockquote, r.renderBlockquote) + reg.Register(ast.KindCodeBlock, r.renderCodeBlock) + reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) + reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock) + reg.Register(ast.KindList, r.renderList) + reg.Register(ast.KindListItem, r.renderListItem) + reg.Register(ast.KindParagraph, r.renderParagraph) + reg.Register(ast.KindTextBlock, r.renderTextBlock) + reg.Register(ast.KindThematicBreak, r.renderThematicBreak) + + // inlines + reg.Register(ast.KindAutoLink, r.renderAutoLink) + reg.Register(ast.KindCodeSpan, r.renderCodeSpan) + reg.Register(ast.KindEmphasis, r.renderEmphasis) + reg.Register(ast.KindImage, r.renderImage) + reg.Register(ast.KindLink, r.renderLink) + reg.Register(ast.KindRawHTML, r.renderRawHTML) + reg.Register(ast.KindText, r.renderText) + reg.Register(ast.KindString, r.renderString) + + // GFM Extensions + // Tables + reg.Register(exast.KindTable, r.renderTable) + reg.Register(exast.KindTableHeader, r.renderTableHeader) + reg.Register(exast.KindTableRow, r.renderTableRow) + reg.Register(exast.KindTableCell, r.renderTableCell) + // Strikethrough + reg.Register(exast.KindStrikethrough, r.renderStrikethrough) + // Checkbox + reg.Register(exast.KindTaskCheckBox, r.renderTaskCheckBox) + + // releaser-pleaser Extensions + // Section + reg.Register(rpexast.KindSection, r.renderSection) +} + +func (r *Renderer) write(w io.Writer, buf []byte) (int, error) { + written := 0 + for len(buf) > 0 { + if r.atNewline { + if err := r.beginLine(w); err != nil { + return 0, fmt.Errorf(": %w", err) + } + } + + atNewline := false + newline := bytes.IndexByte(buf, '\n') + if newline == -1 { + newline = len(buf) - 1 + } else { + atNewline = true + } + + n, err := w.Write(buf[:newline+1]) + written += n + r.atNewline = n > 0 && atNewline && n == newline+1 + if len(r.openBlocks) != 0 { + r.openBlocks[len(r.openBlocks)-1].fresh = false + } + if err != nil { + return written, fmt.Errorf(": %w", err) + } + buf = buf[n:] + } + return written, nil +} + +func (r *Renderer) beginLine(w io.Writer) error { + if len(r.openBlocks) != 0 { + current := r.openBlocks[len(r.openBlocks)-1] + if current.node.Kind() == ast.KindParagraph && !current.fresh { + return nil + } + } + + n, err := w.Write(r.prefix) + if n != 0 { + r.atNewline = r.prefix[len(r.prefix)-1] == '\n' + } + if err != nil { + return fmt.Errorf(": %w", err) + } + return nil +} + +func (r *Renderer) writeLines(w util.BufWriter, source []byte, lines *text.Segments) error { + for i := 0; i < lines.Len(); i++ { + line := lines.At(i) + if _, err := r.write(w, line.Value(source)); err != nil { + return fmt.Errorf(": %w", err) + } + } + return nil +} + +func (r *Renderer) writeByte(w io.Writer, c byte) error { + if _, err := r.write(w, []byte{c}); err != nil { + return fmt.Errorf(": %w", err) + } + return nil +} + +// WriteString writes a string to an io.Writer, ensuring that appropriate indentation and prefices are added at the +// beginning of each line. +func (r *Renderer) writeString(w io.Writer, s string) (int, error) { + n, err := r.write(w, []byte(s)) + if err != nil { + return n, fmt.Errorf(": %w", err) + } + return n, nil +} + +// PushIndent adds the specified amount of indentation to the current line prefix. +func (r *Renderer) pushIndent(amount int) { + r.pushPrefix(strings.Repeat(" ", amount)) +} + +// PushPrefix adds the specified string to the current line prefix. +func (r *Renderer) pushPrefix(prefix string) { + r.prefixStack = append(r.prefixStack, prefix) + r.prefix = append(r.prefix, []byte(prefix)...) +} + +// PopPrefix removes the last piece added by a call to PushIndent or PushPrefix from the current line prefix. +func (r *Renderer) popPrefix() { + r.prefix = r.prefix[:len(r.prefix)-len(r.prefixStack[len(r.prefixStack)-1])] + r.prefixStack = r.prefixStack[:len(r.prefixStack)-1] +} + +// OpenBlock ensures that each block begins on a new line, and that blank lines are inserted before blocks as +// indicated by node.HasPreviousBlankLines. +func (r *Renderer) openBlock(w util.BufWriter, source []byte, node ast.Node) error { + r.openBlocks = append(r.openBlocks, blockState{ + node: node, + fresh: true, + }) + + hasBlankPreviousLines := node.HasBlankPreviousLines() + + // FIXME: standard goldmark table parser doesn't recognize Blank Previous Lines so we'll always add one + if node.Kind() == exast.KindTable { + hasBlankPreviousLines = true + } + + // Work around the fact that the first child of a node notices the same set of preceding blank lines as its parent. + if p := node.Parent(); p != nil && p.FirstChild() == node { + if p.Kind() == ast.KindDocument || p.Kind() == ast.KindListItem || p.HasBlankPreviousLines() { + hasBlankPreviousLines = false + } + } + + if hasBlankPreviousLines { + if err := r.writeByte(w, '\n'); err != nil { + return fmt.Errorf(": %w", err) + } + } + + r.openBlocks[len(r.openBlocks)-1].fresh = true + + return nil +} + +// CloseBlock marks the current block as closed. +func (r *Renderer) closeBlock(w io.Writer) error { + if !r.atNewline { + if err := r.writeByte(w, '\n'); err != nil { + return fmt.Errorf(": %w", err) + } + } + + r.openBlocks = r.openBlocks[:len(r.openBlocks)-1] + return nil +} + +// RenderDocument renders an *ast.Document node to the given BufWriter. +func (r *Renderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + r.listStack, r.prefixStack, r.prefix, r.atNewline = nil, nil, nil, false + return ast.WalkContinue, nil +} + +// RenderHeading renders an *ast.Heading node to the given BufWriter. +func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, strings.Repeat("#", node.(*ast.Heading).Level)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.writeByte(w, ' '); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderBlockquote renders an *ast.Blockquote node to the given BufWriter. +func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, "> "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + r.pushPrefix("> ") + } else { + r.popPrefix() + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderCodeBlock renders an *ast.CodeBlock node to the given BufWriter. +func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + r.popPrefix() + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // // Each line of a code block needs to be aligned at the same offset, and a code block must start with at least four + // // spaces. To achieve this, we unconditionally add four spaces to the first line of the code block and indent the + // // rest as necessary. + // if _, err := r.writeString(w, " "); err != nil { + // return ast.WalkStop, fmt.Errorf(": %w", err) + // } + + r.pushIndent(4) + if err := r.writeLines(w, source, node.Lines()); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +// RenderFencedCodeBlock renders an *ast.FencedCodeBlock node to the given BufWriter. +func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + code := node.(*ast.FencedCodeBlock) + + // Write the start of the fenced code block. + fence := []byte("```") + if _, err := r.write(w, fence); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + language := code.Language(source) + if _, err := r.write(w, language); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, nil + } + + // Write the contents of the fenced code block. + if err := r.writeLines(w, source, node.Lines()); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // Write the end of the fenced code block. + if err := r.beginLine(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if _, err := r.write(w, fence); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +// RenderHTMLBlock renders an *ast.HTMLBlock node to the given BufWriter. +func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // Write the contents of the HTML block. + if err := r.writeLines(w, source, node.Lines()); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // Write the closure line, if any. + html := node.(*ast.HTMLBlock) + if html.HasClosure() { + if _, err := r.write(w, html.ClosureLine.Value(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderList renders an *ast.List node to the given BufWriter. +func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + list := node.(*ast.List) + r.listStack = append(r.listStack, listState{ + marker: list.Marker, + ordered: list.IsOrdered(), + index: list.Start, + }) + } else { + r.listStack = r.listStack[:len(r.listStack)-1] + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderListItem renders an *ast.ListItem node to the given BufWriter. +func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + markerWidth := 2 // marker + space + + state := &r.listStack[len(r.listStack)-1] + if state.ordered { + width, err := r.writeString(w, strconv.FormatInt(int64(state.index), 10)) + if err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + state.index++ + markerWidth += width // marker, space, and digits + } + + if _, err := r.write(w, []byte{state.marker, ' '}); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + r.pushIndent(markerWidth) + } else { + r.popPrefix() + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderParagraph renders an *ast.Paragraph node to the given BufWriter. +func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + // A paragraph that follows another paragraph or a blockquote must be preceded by a blank line. + if !node.HasBlankPreviousLines() { + if prev := node.PreviousSibling(); prev != nil && (prev.Kind() == ast.KindParagraph || prev.Kind() == ast.KindBlockquote) { + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderTextBlock renders an *ast.TextBlock node to the given BufWriter. +func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderThematicBreak renders an *ast.ThematicBreak node to the given BufWriter. +func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil + } + + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + // TODO: this prints an extra no line + if _, err := r.writeString(w, "--------"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +// RenderAutoLink renders an *ast.AutoLink node to the given BufWriter. +func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + if err := r.writeByte(w, '<'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if _, err := r.write(w, node.(*ast.AutoLink).Label(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if err := r.writeByte(w, '>'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) shouldPadCodeSpan(source []byte, node *ast.CodeSpan) bool { + c := node.FirstChild() + if c == nil { + return false + } + + segment := c.(*ast.Text).Segment + text := segment.Value(source) + + var firstChar byte + if len(text) > 0 { + firstChar = text[0] + } + + allWhitespace := true + for { + if util.FirstNonSpacePosition(text) != -1 { + allWhitespace = false + break + } + c = c.NextSibling() + if c == nil { + break + } + segment = c.(*ast.Text).Segment + text = segment.Value(source) + } + if allWhitespace { + return false + } + + var lastChar byte + if len(text) > 0 { + lastChar = text[len(text)-1] + } + + return firstChar == '`' || firstChar == ' ' || lastChar == '`' || lastChar == ' ' +} + +// RenderCodeSpan renders an *ast.CodeSpan node to the given BufWriter. +func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + code := node.(*ast.CodeSpan) + delimiter := []byte{'`'} + pad := r.shouldPadCodeSpan(source, code) + + if _, err := r.write(w, delimiter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + if pad { + if err := r.writeByte(w, ' '); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + text := c.(*ast.Text).Segment + if _, err := r.write(w, text.Value(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + if pad { + if err := r.writeByte(w, ' '); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + if _, err := r.write(w, delimiter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkSkipChildren, nil +} + +// RenderEmphasis renders an *ast.Emphasis node to the given BufWriter. +func (r *Renderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + em := node.(*ast.Emphasis) + if _, err := r.writeString(w, strings.Repeat("*", em.Level)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +func (r *Renderer) escapeLinkDest(dest []byte) []byte { + requiresEscaping := false + for _, c := range dest { + if c <= 32 || c == '(' || c == ')' || c == 127 { + requiresEscaping = true + break + } + } + if !requiresEscaping { + return dest + } + + escaped := make([]byte, 0, len(dest)+2) + escaped = append(escaped, '<') + for _, c := range dest { + if c == '<' || c == '>' { + escaped = append(escaped, '\\') + } + escaped = append(escaped, c) + } + escaped = append(escaped, '>') + return escaped +} + +func (r *Renderer) linkTitleDelimiter(title []byte) byte { + for i, c := range title { + if c == '"' && (i == 0 || title[i-1] != '\\') { + return '\'' + } + } + return '"' +} + +func (r *Renderer) renderLinkOrImage(w util.BufWriter, open string, dest, title []byte, enter bool) error { + if enter { + if _, err := r.writeString(w, open); err != nil { + return fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, "]("); err != nil { + return fmt.Errorf(": %w", err) + } + + if _, err := r.write(w, r.escapeLinkDest(dest)); err != nil { + return fmt.Errorf(": %w", err) + } + if len(title) != 0 { + delimiter := r.linkTitleDelimiter(title) + if _, err := fmt.Fprintf(w, ` %c%s%c`, delimiter, string(title), delimiter); err != nil { + return fmt.Errorf(": %w", err) + } + } + + if err := r.writeByte(w, ')'); err != nil { + return fmt.Errorf(": %w", err) + } + } + return nil +} + +// RenderImage renders an *ast.Image node to the given BufWriter. +func (r *Renderer) renderImage(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + img := node.(*ast.Image) + if err := r.renderLinkOrImage(w, "![", img.Destination, img.Title, enter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +// RenderLink renders an *ast.Link node to the given BufWriter. +func (r *Renderer) renderLink(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + link := node.(*ast.Link) + if err := r.renderLinkOrImage(w, "[", link.Destination, link.Title, enter); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +// RenderRawHTML renders an *ast.RawHTML node to the given BufWriter. +func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkSkipChildren, nil + } + + raw := node.(*ast.RawHTML) + for i := 0; i < raw.Segments.Len(); i++ { + segment := raw.Segments.At(i) + if _, err := r.write(w, segment.Value(source)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkSkipChildren, nil +} + +// RenderText renders an *ast.Text node to the given BufWriter. +func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + text := node.(*ast.Text) + value := text.Segment.Value(source) + + if _, err := r.write(w, value); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + switch { + case text.HardLineBreak(): + if _, err := r.writeString(w, "\\\n"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + case text.SoftLineBreak(): + if err := r.writeByte(w, '\n'); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +// RenderString renders an *ast.String node to the given BufWriter. +func (r *Renderer) renderString(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + return ast.WalkContinue, nil + } + + str := node.(*ast.String) + if _, err := r.write(w, str.Value); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, "| "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, " |\n|"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + for x := 0; x < node.ChildCount(); x++ { // use as column count + if _, err := r.writeString(w, " --- |"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, "| "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, " |"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTableCell(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if !enter { + if node.NextSibling() != nil { + if _, err := r.writeString(w, " | "); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + } + + return ast.WalkContinue, nil +} + +func (r *Renderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if _, err := r.writeString(w, "~~"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + return ast.WalkContinue, nil +} + +func (r *Renderer) renderTaskCheckBox(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + if enter { + var fill byte = ' ' + if task := node.(*exast.TaskCheckBox); task.IsChecked { + fill = 'x' + } + + if _, err := r.write(w, []byte{'[', fill, ']', ' '}); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} diff --git a/internal/markdown/renderer/markdown/section.go b/internal/markdown/renderer/markdown/section.go new file mode 100644 index 0000000..612ea06 --- /dev/null +++ b/internal/markdown/renderer/markdown/section.go @@ -0,0 +1,35 @@ +package markdown + +import ( + "fmt" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/util" + + "github.com/apricote/releaser-pleaser/internal/markdown/extensions" + rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" +) + +func (r *Renderer) renderSection(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) { + n := node.(*rpexast.Section) + + if enter { + if err := r.openBlock(w, source, node); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if _, err := r.writeString(w, fmt.Sprintf(extensions.SectionStartFormat, n.Name)+"\n"); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } else { + if _, err := r.writeString(w, "\n"+fmt.Sprintf(extensions.SectionEndFormat, n.Name)); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + + if err := r.closeBlock(w); err != nil { + return ast.WalkStop, fmt.Errorf(": %w", err) + } + } + + return ast.WalkContinue, nil +} diff --git a/releasepr.go b/releasepr.go index b53d853..933e23f 100644 --- a/releasepr.go +++ b/releasepr.go @@ -1,13 +1,34 @@ package rp import ( + "bytes" + _ "embed" "fmt" + "log" + "text/template" "github.com/yuin/goldmark/ast" - "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" + + "github.com/apricote/releaser-pleaser/internal/markdown" + east "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast" ) +var ( + releasePRTemplate *template.Template +) + +//go:embed releasepr.md.tpl +var rawReleasePRTemplate string + +func init() { + var err error + releasePRTemplate, err = template.New("releasepr").Parse(rawReleasePRTemplate) + if err != nil { + log.Fatalf("failed to parse release pr template: %v", err) + } +} + type ReleasePullRequest struct { ID int Title string @@ -17,6 +38,20 @@ type ReleasePullRequest struct { Head string } +func NewReleasePullRequest(head, branch, version, changelogEntry string) (*ReleasePullRequest, error) { + rp := &ReleasePullRequest{ + Head: head, + Labels: []string{LabelReleasePending}, + } + + rp.SetTitle(branch, version) + if err := rp.SetDescription(changelogEntry); err != nil { + return nil, err + } + + return rp, nil +} + type ReleaseOverrides struct { Prefix string Suffix string @@ -57,6 +92,9 @@ const ( LabelNextVersionTypeRC = "rp-next-version::rc" LabelNextVersionTypeBeta = "rp-next-version::beta" LabelNextVersionTypeAlpha = "rp-next-version::alpha" + + LabelReleasePending = "rp-release::pending" + LabelReleaseTagged = "rp-release::tagged" ) const ( @@ -64,6 +102,10 @@ const ( DescriptionLanguageSuffix = "rp-suffix" ) +const ( + MarkdownSectionOverrides = "overrides" +) + func (pr *ReleasePullRequest) GetOverrides() (ReleaseOverrides, error) { overrides := ReleaseOverrides{} overrides = pr.parseVersioningFlags(overrides) @@ -95,7 +137,7 @@ func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) R func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (ReleaseOverrides, error) { source := []byte(pr.Description) - descriptionAST := parser.NewParser().Parse(text.NewReader(source)) + descriptionAST := markdown.New().Parser().Parse(text.NewReader(source)) err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { @@ -127,6 +169,51 @@ func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (Rele return overrides, nil } +func (pr *ReleasePullRequest) getCurrentOverridesText() (string, error) { + source := []byte(pr.Description) + gm := markdown.New() + descriptionAST := gm.Parser().Parse(text.NewReader(source)) + + var section *east.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() != east.KindSection { + return ast.WalkContinue, nil + } + + anySection, ok := n.(*east.Section) + if !ok { + return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n) + } + + if anySection.Name != MarkdownSectionOverrides { + return ast.WalkContinue, nil + } + + section = anySection + return ast.WalkStop, nil + }) + if err != nil { + return "", err + } + + if section == nil { + return "", 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) @@ -137,5 +224,28 @@ func textFromLines(source []byte, n ast.Node) string { } return string(content) - +} + +func (pr *ReleasePullRequest) SetTitle(branch, version string) { + pr.Title = fmt.Sprintf("chore(%s): release %s", branch, version) +} + +func (pr *ReleasePullRequest) SetDescription(changelogEntry string) error { + overrides, err := pr.getCurrentOverridesText() + if err != nil { + return err + } + + var description bytes.Buffer + err = releasePRTemplate.Execute(&description, map[string]any{ + "Changelog": changelogEntry, + "Overrides": overrides, + }) + if err != nil { + return err + } + + pr.Description = description.String() + + return nil } diff --git a/releasepr.md.tpl b/releasepr.md.tpl new file mode 100644 index 0000000..03b4bc5 --- /dev/null +++ b/releasepr.md.tpl @@ -0,0 +1,25 @@ +{{ .Changelog }} + +--- + +## releaser-pleaser Instructions +{{ if .Overrides }} +{{- .Overrides -}} +{{- else }} + +> If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +### Prefix + +```rp-prefix +``` + +### Suffix + +```rp-suffix +``` + + + +{{ end }} +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) diff --git a/releasepr_test.go b/releasepr_test.go new file mode 100644 index 0000000..4bbdf21 --- /dev/null +++ b/releasepr_test.go @@ -0,0 +1,157 @@ +package rp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReleasePullRequest_SetTitle(t *testing.T) { + type args struct { + branch string + version string + } + tests := []struct { + name string + pr *ReleasePullRequest + args args + want string + }{ + { + name: "simple update", + pr: &ReleasePullRequest{Title: "foo: bar"}, + args: args{ + branch: "main", + version: "v1.0.0", + }, + want: "chore(main): release v1.0.0", + }, + { + name: "no previous title", + pr: &ReleasePullRequest{}, + args: args{ + branch: "release-1.x", + version: "v1.1.1-rc.0", + }, + want: "chore(release-1.x): release v1.1.1-rc.0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.pr.SetTitle(tt.args.branch, tt.args.version) + + assert.Equal(t, tt.want, tt.pr.Title) + }) + } +} + +func TestReleasePullRequest_SetDescription(t *testing.T) { + + tests := []struct { + name string + pr *ReleasePullRequest + changelogEntry string + want string + wantErr assert.ErrorAssertionFunc + }{ + { + name: "empty description", + pr: &ReleasePullRequest{}, + changelogEntry: `## v1.0.0`, + want: `## v1.0.0 + +--- + +## releaser-pleaser Instructions + + +> If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +### Prefix + +` + "```" + `rp-prefix +` + "```" + ` + +### Suffix + +` + "```" + `rp-suffix +` + "```" + ` + + + + +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) +`, + wantErr: assert.NoError, + }, + { + name: "existing overrides", + pr: &ReleasePullRequest{ + Description: `## v0.1.0 + +### Features + +- bedazzle + +--- + +## releaser-pleaser Instructions + + +> If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +### Prefix + +` + "```" + `rp-prefix +This release is awesome! +` + "```" + ` + +### Suffix + +` + "```" + `rp-suffix +` + "```" + ` + + + +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) +`, + }, + changelogEntry: `## v1.0.0`, + want: `## v1.0.0 + +--- + +## releaser-pleaser Instructions + + +> If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +### Prefix + +` + "```" + `rp-prefix +This release is awesome! +` + "```" + ` + +### Suffix + +` + "```" + `rp-suffix +` + "```" + ` + + + +#### PR by [releaser-pleaser](https://github.com/apricote/releaser-pleaser) +`, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.pr.SetDescription(tt.changelogEntry) + if !tt.wantErr(t, err) { + return + } + + assert.Equal(t, tt.want, tt.pr.Description) + }) + } +}