diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index 943b29e..f467582 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -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), §ions); 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 diff --git a/cmd/plugin/main_test.go b/cmd/plugin/main_test.go index 85fcbc7..877612c 100644 --- a/cmd/plugin/main_test.go +++ b/cmd/plugin/main_test.go @@ -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() diff --git a/internal/plugin/generator.go b/internal/plugin/generator.go index 7516a54..c68376a 100644 --- a/internal/plugin/generator.go +++ b/internal/plugin/generator.go @@ -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 @@ -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{} @@ -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 } @@ -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 @@ -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 "", "" @@ -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": @@ -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) diff --git a/internal/plugin/generator_test.go b/internal/plugin/generator_test.go index e78590e..b23a5f8 100644 --- a/internal/plugin/generator_test.go +++ b/internal/plugin/generator_test.go @@ -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() diff --git a/schema/v1.json b/schema/v1.json index 92c50f9..dbd3075 100644 --- a/schema/v1.json +++ b/schema/v1.json @@ -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