Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ func run(stdout, stderr io.Writer, getenv func(string) string) int {
}
}

if raw := strings.TrimSpace(getenv("SEMREL_PLUGIN_SECTIONS_JSON")); raw != "" {
var sections []plugin.SectionRule
if err := json.Unmarshal([]byte(raw), &sections); err != nil {
fmt.Fprintln(stderr, "generator-release-notes: invalid SEMREL_PLUGIN_SECTIONS_JSON:", err)
} else {
options.Sections = sections
}
}

if _, err := io.WriteString(stdout, plugin.New().Generate(ctx, options)); err != nil {
fmt.Fprintln(stderr, "generator-release-notes:", err)
return 1
Expand Down
51 changes: 51 additions & 0 deletions cmd/plugin/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,57 @@ func TestRunWritesReleaseNotes(t *testing.T) {
require.Contains(t, stdout.String(), "### Features")
}

func TestRunWithCustomSectionsJSON(t *testing.T) {
t.Parallel()

getenv := func(key string) string {
switch key {
case "SEMREL_VERSION":
return "1.3.0"
case "SEMREL_COMMITS":
return `["feat: add search", "docs: update README", "chore: tidy up"]`
case "SEMREL_PLUGIN_SECTIONS_JSON":
return `[{"type":"feat","section":"Highlights"},{"type":"docs","hidden":true}]`
}
return ""
}

var stdout bytes.Buffer
var stderr bytes.Buffer

code := run(&stdout, &stderr, getenv)

require.Equal(t, 0, code)
require.Contains(t, stdout.String(), "### Highlights")
require.Contains(t, stdout.String(), "feat: add search")
require.NotContains(t, stdout.String(), "update README")
require.Contains(t, stdout.String(), "### Other")
require.Contains(t, stdout.String(), "chore: tidy up")
}

func TestRunIgnoresInvalidSectionsJSON(t *testing.T) {
t.Parallel()

getenv := func(key string) string {
switch key {
case "SEMREL_COMMITS":
return `["feat: add search"]`
case "SEMREL_PLUGIN_SECTIONS_JSON":
return `[`
}
return ""
}

var stdout bytes.Buffer
var stderr bytes.Buffer

code := run(&stdout, &stderr, getenv)

require.Equal(t, 0, code)
require.Contains(t, stderr.String(), "invalid SEMREL_PLUGIN_SECTIONS_JSON")
require.Contains(t, stdout.String(), "### Features")
}

func TestRunRejectsInvalidCommitJSON(t *testing.T) {
t.Parallel()

Expand Down
88 changes: 84 additions & 4 deletions internal/plugin/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ const (
otherChangesSection = "Other"
)

// SectionRule maps a conventional-commit type to a custom changelog section
// heading, mirroring the "presetConfig.types" mapping supported by
// @semantic-release/release-notes-generator. When Hidden is true, commits of
// this Type are omitted from the release notes entirely (Section is ignored).
type SectionRule struct {
// Type is the conventional-commit type this rule applies to (e.g. "feat",
// "fix", "chore"). Matching is case-insensitive.
Type string `json:"type"`
// Section is the heading commits of this Type are grouped under (e.g.
// "Features", "Dependencies"). Ignored when Hidden is true.
Section string `json:"section,omitempty"`
// Hidden, when true, drops commits of this Type instead of assigning them
// to a section.
Hidden bool `json:"hidden,omitempty"`
}

type ReleaseContext struct {
Version string
CurrentVersion string
Expand Down Expand Up @@ -82,6 +98,10 @@ type GenerateOptions struct {
// release. It is populated by the caller (e.g. from SEMREL_PLUGIN_CONTRIBUTORS_JSON).
// When empty the section is silently skipped regardless of NewContributors.
Contributors []Contributor
// Sections, when non-empty, overrides the built-in feat/fix→section
// mapping used to group release-note entries. Types without a matching
// rule fall back to the "Other" section.
Sections []SectionRule
}

type Generator struct{}
Expand Down Expand Up @@ -119,7 +139,7 @@ func (g *Generator) Generate(ctx ReleaseContext, options ...GenerateOptions) str
var aiEntries []aiEntry

for _, commit := range ctx.Commits {
section, line := classifyCommit(commit)
section, line := classifyCommit(commit, generateOptions)
if section == "" || line == "" {
continue
}
Expand All @@ -145,7 +165,7 @@ func (g *Generator) Generate(ctx ReleaseContext, options ...GenerateOptions) str
}
builder.WriteString("## What's Changed")

for _, section := range []string{featuresSection, bugFixesSection, otherChangesSection} {
for _, section := range sectionOrder(generateOptions) {
lines := sections[section]
if len(lines) == 0 {
continue
Expand Down Expand Up @@ -211,7 +231,7 @@ func (g *Generator) Generate(ctx ReleaseContext, options ...GenerateOptions) str
return builder.String()
}

func classifyCommit(commit string) (string, string) {
func classifyCommit(commit string, options GenerateOptions) (string, string) {
trimmed := strings.TrimSpace(commit)
if trimmed == "" {
return "", ""
Expand All @@ -233,7 +253,27 @@ func classifyCommit(commit string) (string, string) {
line = "BREAKING: " + header
}

switch strings.ToLower(matches[1]) {
commitType := strings.ToLower(matches[1])

if rule, ok := findSectionRule(options.Sections, commitType); ok {
if rule.Hidden {
return "", ""
}
section := strings.TrimSpace(rule.Section)
if section == "" {
return "", ""
}
return section, line
}

if len(options.Sections) > 0 {
// A custom mapping is configured but doesn't cover this type; bucket it
// with the other unmapped commits instead of applying the built-in
// feat/fix defaults.
return otherChangesSection, line
}

switch commitType {
case "feat":
return featuresSection, line
case "fix", "perf", "revert":
Expand All @@ -243,6 +283,46 @@ func classifyCommit(commit string) (string, string) {
}
}

// findSectionRule returns the first rule whose Type matches commitType
// case-insensitively.
func findSectionRule(rules []SectionRule, commitType string) (SectionRule, bool) {
for _, rule := range rules {
if strings.EqualFold(strings.TrimSpace(rule.Type), commitType) {
return rule, true
}
}
return SectionRule{}, false
}

// sectionOrder returns the release-note section headings in the order they
// should be rendered. Without a custom Sections mapping this is the built-in
// fixed order. With a custom mapping, each distinct configured section is
// rendered in declaration order, followed by "Other" for any commit types
// the mapping doesn't cover.
func sectionOrder(options GenerateOptions) []string {
if len(options.Sections) == 0 {
return []string{featuresSection, bugFixesSection, otherChangesSection}
}

var order []string
seen := map[string]bool{}
for _, rule := range options.Sections {
if rule.Hidden {
continue
}
section := strings.TrimSpace(rule.Section)
if section == "" || seen[section] {
continue
}
seen[section] = true
order = append(order, section)
}
if !seen[otherChangesSection] {
order = append(order, otherChangesSection)
}
return order
}

func breakingChangeText(message string) (string, bool) {
for _, line := range strings.Split(message, "\n") {
trimmed := strings.TrimSpace(line)
Expand Down
39 changes: 37 additions & 2 deletions internal/plugin/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,50 @@ func TestGeneratorGenerateWithoutCommitsUsesFallback(t *testing.T) {
func TestClassifyCommit(t *testing.T) {
t.Parallel()

section, line := classifyCommit("fix: patch bug\n\nBREAKING CHANGE: behavior changed")
section, line := classifyCommit("fix: patch bug\n\nBREAKING CHANGE: behavior changed", DefaultGenerateOptions())
require.Equal(t, bugFixesSection, section)
require.Equal(t, "BREAKING CHANGE: behavior changed", line)

section, line = classifyCommit("notes from manual release")
section, line = classifyCommit("notes from manual release", DefaultGenerateOptions())
require.Equal(t, otherChangesSection, section)
require.Equal(t, "notes from manual release", line)
}

func TestGeneratorGenerateWithCustomSections(t *testing.T) {
t.Parallel()

opts := DefaultGenerateOptions()
opts.Sections = []SectionRule{
{Type: "feat", Section: "Highlights"},
{Type: "fix", Section: "Bugfixes"},
{Type: "docs", Hidden: true},
}

output := New().Generate(ReleaseContext{
Version: "1.3.0",
Commits: []string{
"feat: add new feature",
"fix: resolve issue with X",
"docs: update README",
"chore: tidy up",
},
}, opts)

require.Equal(t, "## v1.3.0\n\n## What's Changed\n\n### Highlights\n- feat: add new feature\n\n### Bugfixes\n- fix: resolve issue with X\n\n### Other\n- chore: tidy up", output)
}

func TestFindSectionRuleIsCaseInsensitive(t *testing.T) {
t.Parallel()

rules := []SectionRule{{Type: "Feat", Section: "Features"}}
rule, ok := findSectionRule(rules, "feat")
require.True(t, ok)
require.Equal(t, "Features", rule.Section)

_, ok = findSectionRule(rules, "fix")
require.False(t, ok)
}

func TestGeneratorGenerateWithSignature(t *testing.T) {
t.Parallel()

Expand Down
7 changes: 7 additions & 0 deletions schema/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
"examples": [
"[\"feat: add feature\", \"fix: patch issue\"]"
]
},
"SEMREL_PLUGIN_SECTIONS_JSON": {
"type": "string",
"description": "JSON array overriding the built-in feat/fix→section mapping used to group release-note entries, similar to the 'presetConfig.types' option of @semantic-release/release-notes-generator. Each object has a required 'type' (conventional-commit type, e.g. 'feat'), an optional 'section' (custom heading; commits are grouped under the first matching rule's section, in declaration order) and an optional 'hidden' (bool, drops matching commits entirely). Types with no matching rule fall back to 'Other'.",
"examples": [
"[{\"type\":\"feat\",\"section\":\"Features\"},{\"type\":\"fix\",\"section\":\"Bugfixes\"},{\"type\":\"deps\",\"section\":\"Dependencies\"},{\"type\":\"chore\",\"section\":\"Miscellaneous\"},{\"type\":\"docs\",\"hidden\":true},{\"type\":\"refactor\",\"hidden\":true}]"
]
}
},
"additionalProperties": true
Expand Down
Loading