feat(releaser): initial release v0.4.0
Complete GitFlow release automation tool for Conventional Commits workflows: - Core pipeline: branch parsing, tag discovery, commit analysis, version bump - Maven pom.xml read/write, git commit/tag, HTTPS push with token auth - GitLab release creation via API with auto-generated release notes - Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab) - CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern - Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template - Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows - Taskfile with build/test/cov/lint/fuzz/ci/docker tasks - 96% test coverage with real in-memory git repos and fuzz tests for all parsers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/commits"
|
||||
)
|
||||
|
||||
// headerSubjectRe captures the subject (description) part of a conventional commit header.
|
||||
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
|
||||
|
||||
// Generate produces grouped markdown release notes from a list of commit messages.
|
||||
// Commits are grouped into Breaking Changes, Features, and Bug Fixes.
|
||||
// Commits with no releasable type are omitted.
|
||||
func Generate(tagName string, messages []string) string {
|
||||
var breaking, feats, fixes []string
|
||||
|
||||
for _, msg := range messages {
|
||||
t := commits.Parse(msg)
|
||||
if t == commits.TypeNone {
|
||||
continue
|
||||
}
|
||||
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
|
||||
subject := extractSubject(first)
|
||||
|
||||
switch t {
|
||||
case commits.TypeBreaking:
|
||||
breaking = append(breaking, subject)
|
||||
case commits.TypeFeat:
|
||||
feats = append(feats, subject)
|
||||
case commits.TypeFix:
|
||||
fixes = append(fixes, subject)
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "## %s\n", tagName)
|
||||
writeSection(&sb, "Breaking Changes", breaking)
|
||||
writeSection(&sb, "Features", feats)
|
||||
writeSection(&sb, "Bug Fixes", fixes)
|
||||
|
||||
return strings.TrimRight(sb.String(), "\n")
|
||||
}
|
||||
|
||||
func writeSection(sb *strings.Builder, title string, items []string) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(sb, "\n### %s\n", title)
|
||||
for _, item := range items {
|
||||
fmt.Fprintf(sb, "- %s\n", item)
|
||||
}
|
||||
}
|
||||
|
||||
// extractSubject returns the description part of a conventional commit header.
|
||||
// Falls back to the raw header if the pattern does not match.
|
||||
func extractSubject(header string) string {
|
||||
m := headerSubjectRe.FindStringSubmatch(header)
|
||||
if m != nil {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return strings.TrimSpace(header)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
messages := []string{
|
||||
"feat: add OAuth2 login",
|
||||
"fix: correct null pointer in user service",
|
||||
"feat(search): improve performance",
|
||||
"chore: update dependencies", // ignored
|
||||
"Merge branch 'feature/x' into main", // ignored
|
||||
"feat!: remove legacy API",
|
||||
"fix: handle empty response",
|
||||
"WIP something", // ignored
|
||||
}
|
||||
|
||||
got := Generate("v1.2.4", messages)
|
||||
|
||||
must := []string{
|
||||
"## v1.2.4",
|
||||
"### Breaking Changes",
|
||||
"- remove legacy API",
|
||||
"### Features",
|
||||
"- add OAuth2 login",
|
||||
"- improve performance",
|
||||
"### Bug Fixes",
|
||||
"- correct null pointer in user service",
|
||||
"- handle empty response",
|
||||
}
|
||||
for _, want := range must {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
absent := []string{"update dependencies", "Merge branch", "WIP"}
|
||||
for _, s := range absent {
|
||||
if strings.Contains(got, s) {
|
||||
t.Errorf("unexpected %q in output:\n%s", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateOnlyFixes(t *testing.T) {
|
||||
messages := []string{
|
||||
"fix: patch SQL injection",
|
||||
"fix: escape HTML output",
|
||||
}
|
||||
got := Generate("v1.2.1", messages)
|
||||
|
||||
if strings.Contains(got, "### Features") {
|
||||
t.Error("Features section should be absent when there are no feat commits")
|
||||
}
|
||||
if strings.Contains(got, "### Breaking Changes") {
|
||||
t.Error("Breaking Changes section should be absent")
|
||||
}
|
||||
if !strings.Contains(got, "### Bug Fixes") {
|
||||
t.Error("Bug Fixes section should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEmpty(t *testing.T) {
|
||||
got := Generate("v1.2.0", []string{"chore: bump deps", "docs: update readme"})
|
||||
if strings.Contains(got, "###") {
|
||||
t.Errorf("no sections expected when no releasable commits, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSubject(t *testing.T) {
|
||||
cases := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"feat: add login", "add login"},
|
||||
{"feat(auth): add OAuth2", "add OAuth2"},
|
||||
{"feat!: remove API", "remove API"},
|
||||
{"FIX:typo", "typo"},
|
||||
{"plain message", "plain message"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := extractSubject(c.header)
|
||||
if got != c.want {
|
||||
t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzGenerate verifies that Generate never panics on arbitrary inputs and
|
||||
// always includes the tag name in the output.
|
||||
func FuzzGenerate(f *testing.F) {
|
||||
f.Add("v1.2.4", "feat: add thing")
|
||||
f.Add("v1.0.0", "fix: patch")
|
||||
f.Add("1.2.0", "feat!: breaking change")
|
||||
f.Add("v0.0.1", "")
|
||||
f.Add("", "feat: msg")
|
||||
f.Add("v1.0.0", "BREAKING CHANGE: dropped")
|
||||
f.Add("tag\nwith\nnewline", "feat: something")
|
||||
f.Add("v1.0.0", strings.Repeat("feat: x\n", 1000))
|
||||
|
||||
f.Fuzz(func(t *testing.T, tag, msg string) {
|
||||
result := Generate(tag, []string{msg})
|
||||
if !strings.Contains(result, tag) {
|
||||
t.Errorf("output does not contain tag %q\noutput: %s", tag, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user