From b049cfbe07baa24df32fbd9f53c969210ee8dd68 Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sat, 28 Feb 2026 15:31:27 +0530 Subject: [PATCH 1/3] refactor: move default rules, formatter to lint --- config/config.go | 2 +- config/default.go | 4 ++-- {formatter => lint/formatter}/default.go | 0 {formatter => lint/formatter}/json.go | 0 {rule => lint/rule}/breaking_change_rule.go | 0 {rule => lint/rule}/case_rules.go | 0 {rule => lint/rule}/charset_rules.go | 0 {rule => lint/rule}/empty_rules.go | 0 {rule => lint/rule}/footer_enum.go | 0 {rule => lint/rule}/footer_type_enum.go | 0 {rule => lint/rule}/fullstop_rules.go | 0 {rule => lint/rule}/header_trim_rule.go | 0 {rule => lint/rule}/leading_blank_rules.go | 0 {rule => lint/rule}/length_rules.go | 0 {rule => lint/rule}/rule.go | 0 {rule => lint/rule}/scope_enum.go | 0 {rule => lint/rule}/trailer_rules.go | 0 {rule => lint/rule}/type_enum.go | 0 registry/registry.go | 4 ++-- test/formatter_test.go | 2 +- test/rule_test.go | 2 +- 21 files changed, 7 insertions(+), 7 deletions(-) rename {formatter => lint/formatter}/default.go (100%) rename {formatter => lint/formatter}/json.go (100%) rename {rule => lint/rule}/breaking_change_rule.go (100%) rename {rule => lint/rule}/case_rules.go (100%) rename {rule => lint/rule}/charset_rules.go (100%) rename {rule => lint/rule}/empty_rules.go (100%) rename {rule => lint/rule}/footer_enum.go (100%) rename {rule => lint/rule}/footer_type_enum.go (100%) rename {rule => lint/rule}/fullstop_rules.go (100%) rename {rule => lint/rule}/header_trim_rule.go (100%) rename {rule => lint/rule}/leading_blank_rules.go (100%) rename {rule => lint/rule}/length_rules.go (100%) rename {rule => lint/rule}/rule.go (100%) rename {rule => lint/rule}/scope_enum.go (100%) rename {rule => lint/rule}/trailer_rules.go (100%) rename {rule => lint/rule}/type_enum.go (100%) diff --git a/config/config.go b/config/config.go index a1c58a2..f8f4a27 100644 --- a/config/config.go +++ b/config/config.go @@ -12,9 +12,9 @@ import ( "golang.org/x/mod/semver" yaml "gopkg.in/yaml.v2" - "github.com/conventionalcommit/commitlint/formatter" "github.com/conventionalcommit/commitlint/internal" "github.com/conventionalcommit/commitlint/lint" + "github.com/conventionalcommit/commitlint/lint/formatter" "github.com/conventionalcommit/commitlint/registry" ) diff --git a/config/default.go b/config/default.go index 9c5a63e..c8aa466 100644 --- a/config/default.go +++ b/config/default.go @@ -1,10 +1,10 @@ package config import ( - "github.com/conventionalcommit/commitlint/formatter" "github.com/conventionalcommit/commitlint/internal" "github.com/conventionalcommit/commitlint/lint" - "github.com/conventionalcommit/commitlint/rule" + "github.com/conventionalcommit/commitlint/lint/formatter" + "github.com/conventionalcommit/commitlint/lint/rule" ) const ( diff --git a/formatter/default.go b/lint/formatter/default.go similarity index 100% rename from formatter/default.go rename to lint/formatter/default.go diff --git a/formatter/json.go b/lint/formatter/json.go similarity index 100% rename from formatter/json.go rename to lint/formatter/json.go diff --git a/rule/breaking_change_rule.go b/lint/rule/breaking_change_rule.go similarity index 100% rename from rule/breaking_change_rule.go rename to lint/rule/breaking_change_rule.go diff --git a/rule/case_rules.go b/lint/rule/case_rules.go similarity index 100% rename from rule/case_rules.go rename to lint/rule/case_rules.go diff --git a/rule/charset_rules.go b/lint/rule/charset_rules.go similarity index 100% rename from rule/charset_rules.go rename to lint/rule/charset_rules.go diff --git a/rule/empty_rules.go b/lint/rule/empty_rules.go similarity index 100% rename from rule/empty_rules.go rename to lint/rule/empty_rules.go diff --git a/rule/footer_enum.go b/lint/rule/footer_enum.go similarity index 100% rename from rule/footer_enum.go rename to lint/rule/footer_enum.go diff --git a/rule/footer_type_enum.go b/lint/rule/footer_type_enum.go similarity index 100% rename from rule/footer_type_enum.go rename to lint/rule/footer_type_enum.go diff --git a/rule/fullstop_rules.go b/lint/rule/fullstop_rules.go similarity index 100% rename from rule/fullstop_rules.go rename to lint/rule/fullstop_rules.go diff --git a/rule/header_trim_rule.go b/lint/rule/header_trim_rule.go similarity index 100% rename from rule/header_trim_rule.go rename to lint/rule/header_trim_rule.go diff --git a/rule/leading_blank_rules.go b/lint/rule/leading_blank_rules.go similarity index 100% rename from rule/leading_blank_rules.go rename to lint/rule/leading_blank_rules.go diff --git a/rule/length_rules.go b/lint/rule/length_rules.go similarity index 100% rename from rule/length_rules.go rename to lint/rule/length_rules.go diff --git a/rule/rule.go b/lint/rule/rule.go similarity index 100% rename from rule/rule.go rename to lint/rule/rule.go diff --git a/rule/scope_enum.go b/lint/rule/scope_enum.go similarity index 100% rename from rule/scope_enum.go rename to lint/rule/scope_enum.go diff --git a/rule/trailer_rules.go b/lint/rule/trailer_rules.go similarity index 100% rename from rule/trailer_rules.go rename to lint/rule/trailer_rules.go diff --git a/rule/type_enum.go b/lint/rule/type_enum.go similarity index 100% rename from rule/type_enum.go rename to lint/rule/type_enum.go diff --git a/registry/registry.go b/registry/registry.go index 7b854ec..593c51d 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -7,9 +7,9 @@ import ( "fmt" "sync" - "github.com/conventionalcommit/commitlint/formatter" "github.com/conventionalcommit/commitlint/lint" - "github.com/conventionalcommit/commitlint/rule" + "github.com/conventionalcommit/commitlint/lint/formatter" + "github.com/conventionalcommit/commitlint/lint/rule" ) var globalRegistry = newRegistry() diff --git a/test/formatter_test.go b/test/formatter_test.go index ffa04d4..00fbc00 100644 --- a/test/formatter_test.go +++ b/test/formatter_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/conventionalcommit/commitlint/formatter" + "github.com/conventionalcommit/commitlint/lint/formatter" ) func TestDefaultFormatter_Name(t *testing.T) { diff --git a/test/rule_test.go b/test/rule_test.go index 53b3e17..cab62c3 100644 --- a/test/rule_test.go +++ b/test/rule_test.go @@ -5,7 +5,7 @@ import ( "github.com/conventionalcommit/commitlint/internal/casing" "github.com/conventionalcommit/commitlint/lint" - "github.com/conventionalcommit/commitlint/rule" + "github.com/conventionalcommit/commitlint/lint/rule" ) // --- Header length rules --- From d8290bfee5f2f6956b5444de0c6557855ba799dc Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sat, 28 Feb 2026 16:19:10 +0530 Subject: [PATCH 2/3] refactor: move Commit, Note, Parser interfaces --- commit/commit.go | 28 ++++++++++++++++++++++++++++ {lint => commit}/parser.go | 7 ++++--- lint/lint.go | 35 +++++++++++------------------------ lint/linter.go | 4 +++- 4 files changed, 46 insertions(+), 28 deletions(-) create mode 100644 commit/commit.go rename {lint => commit}/parser.go (74%) diff --git a/commit/commit.go b/commit/commit.go new file mode 100644 index 0000000..9512262 --- /dev/null +++ b/commit/commit.go @@ -0,0 +1,28 @@ +// Package commit provides core interfaces for parsed conventional commits. +// It wraps the external parser package and is the single import point for it. +// Both the lint and changelog packages depend on these interfaces. +package commit + +// Note represent a footer note +type Note interface { + Token() string + Value() string +} + +// Commit represent a parsed conventional commit message +type Commit interface { + Message() string + Header() string + Body() string + Footer() string + Type() string + Scope() string + Description() string + Notes() []Note + IsBreakingChange() bool +} + +// Parser parses a commit message into a Commit +type Parser interface { + Parse(msg string) (Commit, error) +} diff --git a/lint/parser.go b/commit/parser.go similarity index 74% rename from lint/parser.go rename to commit/parser.go index bc8a46b..453a455 100644 --- a/lint/parser.go +++ b/commit/parser.go @@ -1,4 +1,4 @@ -package lint +package commit import "github.com/conventionalcommit/parser" @@ -6,13 +6,14 @@ type defaultParser struct { p *parser.Parser } -func newParser() *defaultParser { +// NewParser returns a new Parser that wraps the conventional commit parser +func NewParser() Parser { return &defaultParser{ p: parser.New(), } } -func (p defaultParser) Parse(input string) (Commit, error) { +func (p *defaultParser) Parse(input string) (Commit, error) { c, err := p.p.Parse(input) if err != nil { return nil, err diff --git a/lint/lint.go b/lint/lint.go index 225629b..bd46af4 100644 --- a/lint/lint.go +++ b/lint/lint.go @@ -1,5 +1,16 @@ package lint +import "github.com/conventionalcommit/commitlint/commit" + +// Type aliases for backward compatibility. +// External packages that depend on lint.Commit, lint.Note, or lint.Parser +// continue to work without changes. +type ( + Commit = commit.Commit + Note = commit.Note + Parser = commit.Parser +) + // Rule Severity Constants const ( SeverityWarn Severity = "warn" @@ -20,30 +31,6 @@ func (s Severity) String() string { } } -// Note represent a footer note -type Note interface { - Token() string - Value() string -} - -// Commit represent a commit message -type Commit interface { - Message() string - Header() string - Body() string - Footer() string - Type() string - Scope() string - Description() string - Notes() []Note - IsBreakingChange() bool -} - -// Parser parses given commit message -type Parser interface { - Parse(msg string) (Commit, error) -} - // Formatter represent a lint result formatter type Formatter interface { // Name is a unique identifier for formatter diff --git a/lint/linter.go b/lint/linter.go index 5e9a8d6..ae4c2a0 100644 --- a/lint/linter.go +++ b/lint/linter.go @@ -5,6 +5,8 @@ import ( "fmt" "regexp" "strings" + + "github.com/conventionalcommit/commitlint/commit" ) // Linter is linter for commit message @@ -26,7 +28,7 @@ func New(conf *Config, rules []Rule) (*Linter, error) { l := &Linter{ conf: conf, rules: rules, - parser: newParser(), + parser: commit.NewParser(), ignorePatterns: compiled, } return l, nil From 985f743f4955992d37e1dd544872206dcddff103 Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sat, 28 Feb 2026 18:13:30 +0530 Subject: [PATCH 3/3] feat: support changelog generation --- changelog/changelog.go | 91 +++++ changelog/config.go | 58 +++ changelog/formatter/json.go | 108 ++++++ changelog/formatter/json_test.go | 104 +++++ changelog/formatter/markdown.go | 132 +++++++ changelog/formatter/markdown_test.go | 205 ++++++++++ changelog/generator.go | 389 +++++++++++++++++++ changelog/generator_test.go | 68 ++++ config/api.go | 19 +- config/changelog.go | 63 +++ config/config.go | 250 ++++++------ config/default.go | 361 +++++++++--------- config/default_test.go | 31 +- config/lint.go | 103 +++++ internal/cmd/changelog.go | 197 ++++++++++ internal/cmd/cmd.go | 1 + internal/cmd/debug.go | 6 +- internal/cmd/lint.go | 8 +- internal/git/git.go | 420 ++++++++++++++++++++ internal/git/git_test.go | 210 ++++++++++ registry/registry.go | 80 +++- test/config_test.go | 547 ++++++++++++++++++++++----- test/helpers_test.go | 2 +- test/ignore_test.go | 16 +- test/lint_test.go | 4 +- 25 files changed, 3045 insertions(+), 428 deletions(-) create mode 100644 changelog/changelog.go create mode 100644 changelog/config.go create mode 100644 changelog/formatter/json.go create mode 100644 changelog/formatter/json_test.go create mode 100644 changelog/formatter/markdown.go create mode 100644 changelog/formatter/markdown_test.go create mode 100644 changelog/generator.go create mode 100644 changelog/generator_test.go create mode 100644 config/changelog.go create mode 100644 internal/cmd/changelog.go create mode 100644 internal/git/git.go create mode 100644 internal/git/git_test.go diff --git a/changelog/changelog.go b/changelog/changelog.go new file mode 100644 index 0000000..e0b9a41 --- /dev/null +++ b/changelog/changelog.go @@ -0,0 +1,91 @@ +package changelog + +import ( + "time" + + "github.com/conventionalcommit/commitlint/commit" +) + +// CommitInfo holds a parsed commit with git metadata +type CommitInfo struct { + // Commit is the parsed conventional commit + Commit commit.Commit + + // Hash is the full commit SHA + Hash string + + // ShortHash is the abbreviated commit SHA (7 chars) + ShortHash string + + // CommitURL is the full URL to the commit + CommitURL string + + // Author is the commit author name + Author string + + // Date is the commit date + Date time.Time + + // References holds extracted issue references + References []string +} + +// TypeGroup holds commits of one type +type TypeGroup struct { + // Type is the conventional commit type + Type string + + // Header is the display header for this group + Header string + + // Commits holds all commits of this type + Commits []CommitInfo +} + +// VersionChangelog holds all commits for one version/tag range +type VersionChangelog struct { + // Version is the tag name (e.g., "v1.0.0") or "Unreleased" + Version string + + // Date is the release date formatted as YYYY-MM-DD + Date string + + // CompareURL is the URL to compare with previous version + CompareURL string + + // FromRef is the start reference (exclusive) + FromRef string + + // ToRef is the end reference (inclusive) + ToRef string + + // Groups holds commits grouped by type + Groups []TypeGroup + + // Breaking holds breaking change commits + Breaking []CommitInfo + + // Other holds non-conventional commits + Other []CommitInfo +} + +// Changelog is the full changelog document +type Changelog struct { + // Header is the top-level changelog header + Header string + + // Versions holds all version changelogs, newest first + Versions []VersionChangelog +} + +// Formatter formats a Changelog into string output +type Formatter interface { + // Name returns the name of the formatter + Name() string + + // Format formats the full changelog + Format(changelog *Changelog) (string, error) + + // FormatVersion formats a single version changelog + FormatVersion(version *VersionChangelog, cfg *Config) (string, error) +} diff --git a/changelog/config.go b/changelog/config.go new file mode 100644 index 0000000..a9f4940 --- /dev/null +++ b/changelog/config.go @@ -0,0 +1,58 @@ +// Package changelog contains core types for changelog generation +package changelog + +// Config holds the changelog generation configuration +type Config struct { + // Formatter is the name of the formatter to use + Formatter string `yaml:"formatter"` + + // Output is the output file path, "-" for stdout + Output string `yaml:"output"` + + // Header is the top-level changelog header + Header string `yaml:"header"` + + // Repository holds repository URL configuration + Repository RepositoryConfig `yaml:"repository"` + + // IssuePrefixes defines prefixes for issue references + IssuePrefixes []string `yaml:"issue-prefixes"` + + // IncludeOther includes non-conventional commits + IncludeOther bool `yaml:"include-other"` + + // IncludeBreaking adds a separate breaking changes section + IncludeBreaking bool `yaml:"include-breaking"` + + // SkipMergeCommits skips merge commits from changelog + SkipMergeCommits bool `yaml:"skip-merge-commits"` + + // Types defines commit type grouping and display + Types []TypeConfig `yaml:"types"` +} + +// RepositoryConfig holds repository URL configuration +type RepositoryConfig struct { + // URL is the base repository URL (auto-inferred from git remote) + URL string `yaml:"url"` + + // CommitURL is the template for commit links + // Placeholders: {{hash}} + CommitURL string `yaml:"commit-url"` + + // CompareURL is the template for version compare links + // Placeholders: {{from}}, {{to}} + CompareURL string `yaml:"compare-url"` +} + +// TypeConfig defines how a commit type is displayed in the changelog +type TypeConfig struct { + // Type is the conventional commit type (e.g., "feat", "fix") + Type string `yaml:"type"` + + // Header is the display header (e.g., "Features", "Bug Fixes") + Header string `yaml:"header"` + + // Hidden if true, commits of this type are excluded from output + Hidden bool `yaml:"hidden"` +} diff --git a/changelog/formatter/json.go b/changelog/formatter/json.go new file mode 100644 index 0000000..cefca9a --- /dev/null +++ b/changelog/formatter/json.go @@ -0,0 +1,108 @@ +package formatter + +import ( + "encoding/json" + "fmt" + + "github.com/conventionalcommit/commitlint/changelog" +) + +// JSONFormatter formats changelog as JSON +type JSONFormatter struct{} + +// Name returns the name of the formatter +func (f *JSONFormatter) Name() string { return "json" } + +// Format formats the full changelog as JSON +func (f *JSONFormatter) Format(cl *changelog.Changelog) (string, error) { + output := map[string]interface{}{ + "header": cl.Header, + "versions": f.formatVersions(cl.Versions), + } + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return "", fmt.Errorf("json formatting failed: %w", err) + } + return string(data), nil +} + +// FormatVersion formats a single version changelog as JSON +func (f *JSONFormatter) FormatVersion(v *changelog.VersionChangelog, _ *changelog.Config) (string, error) { + output := f.formatSingleVersion(v) + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return "", fmt.Errorf("json formatting failed: %w", err) + } + return string(data), nil +} + +func (f *JSONFormatter) formatVersions(versions []changelog.VersionChangelog) []interface{} { + result := make([]interface{}, 0, len(versions)) + for i := range versions { + result = append(result, f.formatSingleVersion(&versions[i])) + } + return result +} + +func (f *JSONFormatter) formatSingleVersion(v *changelog.VersionChangelog) map[string]interface{} { + output := map[string]interface{}{ + "version": v.Version, + "date": v.Date, + } + + if v.CompareURL != "" { + output["compareUrl"] = v.CompareURL + } + + if len(v.Breaking) > 0 { + output["breakingChanges"] = f.formatCommits(v.Breaking) + } + + groups := make([]interface{}, 0, len(v.Groups)) + for _, g := range v.Groups { + groups = append(groups, map[string]interface{}{ + "type": g.Type, + "header": g.Header, + "commits": f.formatCommits(g.Commits), + }) + } + output["groups"] = groups + + if len(v.Other) > 0 { + output["other"] = f.formatCommits(v.Other) + } + + return output +} + +func (f *JSONFormatter) formatCommits(commits []changelog.CommitInfo) []interface{} { + result := make([]interface{}, 0, len(commits)) + for _, c := range commits { + entry := map[string]interface{}{ + "hash": c.Hash, + "shortHash": c.ShortHash, + "author": c.Author, + "date": c.Date.Format("2006-01-02"), + } + + if c.CommitURL != "" { + entry["commitUrl"] = c.CommitURL + } + + if c.Commit != nil { + entry["type"] = c.Commit.Type() + entry["scope"] = c.Commit.Scope() + entry["description"] = c.Commit.Description() + entry["isBreakingChange"] = c.Commit.IsBreakingChange() + } + + if len(c.References) > 0 { + entry["references"] = c.References + } + + result = append(result, entry) + } + return result +} diff --git a/changelog/formatter/json_test.go b/changelog/formatter/json_test.go new file mode 100644 index 0000000..f3c28b8 --- /dev/null +++ b/changelog/formatter/json_test.go @@ -0,0 +1,104 @@ +package formatter + +import ( + "encoding/json" + "testing" + "time" + + "github.com/conventionalcommit/commitlint/changelog" +) + +func TestJSONFormatterName(t *testing.T) { + f := &JSONFormatter{} + if f.Name() != "json" { + t.Errorf("expected name 'json', got '%s'", f.Name()) + } +} + +func TestJSONFormatVersion(t *testing.T) { + f := &JSONFormatter{} + + v := &changelog.VersionChangelog{ + Version: "v1.0.0", + Date: "2025-01-15", + CompareURL: "https://github.com/u/r/compare/v0.9.0...v1.0.0", + Groups: []changelog.TypeGroup{ + { + Type: "feat", + Header: "Features", + Commits: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat: add something"), + Hash: "abc123def456", + ShortHash: "abc123d", + CommitURL: "https://github.com/u/r/commit/abc123def456", + Author: "John", + Date: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, + } + + result, err := f.FormatVersion(v, nil) + if err != nil { + t.Fatal(err) + } + + // verify it's valid JSON + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + + if parsed["version"] != "v1.0.0" { + t.Errorf("expected version 'v1.0.0', got %v", parsed["version"]) + } +} + +func TestJSONFullFormat(t *testing.T) { + f := &JSONFormatter{} + + cl := &changelog.Changelog{ + Header: "# Changelog", + Versions: []changelog.VersionChangelog{ + { + Version: "v1.0.0", + Date: "2025-01-15", + Groups: []changelog.TypeGroup{ + { + Type: "feat", + Header: "Features", + Commits: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat: first feature"), + ShortHash: "aaa1111", + Author: "Jane", + Date: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, + }, + }, + } + + result, err := f.Format(cl) + if err != nil { + t.Fatal(err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + + if parsed["header"] != "# Changelog" { + t.Error("expected header in JSON") + } + + versions, ok := parsed["versions"].([]interface{}) + if !ok || len(versions) != 1 { + t.Error("expected 1 version in JSON") + } +} diff --git a/changelog/formatter/markdown.go b/changelog/formatter/markdown.go new file mode 100644 index 0000000..0c2ce32 --- /dev/null +++ b/changelog/formatter/markdown.go @@ -0,0 +1,132 @@ +// Package formatter contains changelog output formatters +package formatter + +import ( + "fmt" + "strings" + + "github.com/conventionalcommit/commitlint/changelog" +) + +// MarkdownFormatter formats changelog as Markdown +type MarkdownFormatter struct{} + +// Name returns the name of the formatter +func (f *MarkdownFormatter) Name() string { return "markdown" } + +// Format formats the full changelog as Markdown +func (f *MarkdownFormatter) Format(cl *changelog.Changelog) (string, error) { + var sb strings.Builder + + if cl.Header != "" { + sb.WriteString(cl.Header) + sb.WriteString("\n") + } + + for i, v := range cl.Versions { + if i > 0 || cl.Header != "" { + sb.WriteString("\n") + } + + out, err := f.FormatVersion(&v, nil) + if err != nil { + return "", err + } + sb.WriteString(out) + } + + return sb.String(), nil +} + +// FormatVersion formats a single version changelog as Markdown +func (f *MarkdownFormatter) FormatVersion(v *changelog.VersionChangelog, _ *changelog.Config) (string, error) { + var sb strings.Builder + + // version header + f.writeVersionHeader(&sb, v) + + // breaking changes + if len(v.Breaking) > 0 { + sb.WriteString("\n### ⚠ BREAKING CHANGES\n\n") + for _, c := range v.Breaking { + f.writeCommitLine(&sb, c) + } + } + + // type groups + for _, g := range v.Groups { + sb.WriteString("\n### ") + sb.WriteString(g.Header) + sb.WriteString("\n\n") + + for _, c := range g.Commits { + f.writeCommitLine(&sb, c) + } + } + + // other (non-conventional) commits + if len(v.Other) > 0 { + sb.WriteString("\n### Other Changes\n\n") + for _, c := range v.Other { + f.writeOtherCommitLine(&sb, c) + } + } + + return sb.String(), nil +} + +// writeVersionHeader writes the version header line +func (f *MarkdownFormatter) writeVersionHeader(sb *strings.Builder, v *changelog.VersionChangelog) { + sb.WriteString("## ") + + if v.CompareURL != "" { + fmt.Fprintf(sb, "[%s](%s)", v.Version, v.CompareURL) + } else { + sb.WriteString(v.Version) + } + + if v.Date != "" { + fmt.Fprintf(sb, " (%s)", v.Date) + } + + sb.WriteString("\n") +} + +// writeCommitLine writes a single commit line +func (f *MarkdownFormatter) writeCommitLine(sb *strings.Builder, c changelog.CommitInfo) { + sb.WriteString("* ") + + if c.Commit != nil && c.Commit.Scope() != "" { + fmt.Fprintf(sb, "**%s:** ", c.Commit.Scope()) + } + + if c.Commit != nil { + sb.WriteString(c.Commit.Description()) + } + + f.writeHashLink(sb, c) + sb.WriteString("\n") +} + +// writeOtherCommitLine writes a non-conventional commit line +func (f *MarkdownFormatter) writeOtherCommitLine(sb *strings.Builder, c changelog.CommitInfo) { + sb.WriteString("* ") + + if c.Commit != nil { + sb.WriteString(c.Commit.Description()) + } else { + sb.WriteString(c.ShortHash) + } + + f.writeHashLink(sb, c) + sb.WriteString("\n") +} + +// writeHashLink writes the commit hash with optional link +func (f *MarkdownFormatter) writeHashLink(sb *strings.Builder, c changelog.CommitInfo) { + if c.CommitURL != "" { + fmt.Fprintf(sb, " ([%s](%s))", c.ShortHash, c.CommitURL) + } else if c.ShortHash != "" { + fmt.Fprintf(sb, " (%s)", c.ShortHash) + } +} diff --git a/changelog/formatter/markdown_test.go b/changelog/formatter/markdown_test.go new file mode 100644 index 0000000..3160955 --- /dev/null +++ b/changelog/formatter/markdown_test.go @@ -0,0 +1,205 @@ +package formatter + +import ( + "strings" + "testing" + "time" + + "github.com/conventionalcommit/commitlint/changelog" + "github.com/conventionalcommit/commitlint/commit" +) + +func parseCommit(t *testing.T, msg string) commit.Commit { + t.Helper() + p := commit.NewParser() + c, err := p.Parse(msg) + if err != nil { + t.Fatalf("failed to parse %q: %v", msg, err) + } + return c +} + +func TestMarkdownFormatterName(t *testing.T) { + f := &MarkdownFormatter{} + if f.Name() != "markdown" { + t.Errorf("expected name 'markdown', got '%s'", f.Name()) + } +} + +func TestMarkdownFormatVersion(t *testing.T) { + f := &MarkdownFormatter{} + v := &changelog.VersionChangelog{ + Version: "v1.0.0", + Date: "2025-01-15", + CompareURL: "https://github.com/u/r/compare/v0.9.0...v1.0.0", + Groups: []changelog.TypeGroup{ + { + Type: "feat", + Header: "Features", + Commits: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat: add something"), + Hash: "abc123def456", + ShortHash: "abc123d", + CommitURL: "https://github.com/u/r/commit/abc123def456", + Date: time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC), + }, + }, + }, + }, + } + + result, err := f.FormatVersion(v, nil) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(result, "## [v1.0.0]") { + t.Error("expected version header with link") + } + if !strings.Contains(result, "(2025-01-15)") { + t.Error("expected date in header") + } + if !strings.Contains(result, "### Features") { + t.Error("expected Features header") + } + if !strings.Contains(result, "add something") { + t.Error("expected commit description") + } + if !strings.Contains(result, "[abc123d]") { + t.Error("expected short hash link") + } +} + +func TestMarkdownFormatVersionWithScope(t *testing.T) { + f := &MarkdownFormatter{} + v := &changelog.VersionChangelog{ + Version: "v1.0.0", + Date: "2025-01-15", + Groups: []changelog.TypeGroup{ + { + Type: "feat", + Header: "Features", + Commits: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat(api): add endpoint"), + ShortHash: "abc1234", + CommitURL: "https://example.com/commit/abc", + }, + }, + }, + }, + } + + result, err := f.FormatVersion(v, nil) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(result, "**api:**") { + t.Error("expected scope in bold") + } +} + +func TestMarkdownFormatBreakingChanges(t *testing.T) { + f := &MarkdownFormatter{} + v := &changelog.VersionChangelog{ + Version: "v2.0.0", + Date: "2025-06-01", + Breaking: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat!: breaking api change"), + ShortHash: "def456", + }, + }, + Groups: []changelog.TypeGroup{ + { + Type: "feat", + Header: "Features", + Commits: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat!: breaking api change"), + ShortHash: "def456", + }, + }, + }, + }, + } + + result, err := f.FormatVersion(v, nil) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(result, "⚠ BREAKING CHANGES") { + t.Error("expected breaking changes section") + } +} + +func TestMarkdownFullFormat(t *testing.T) { + f := &MarkdownFormatter{} + cl := &changelog.Changelog{ + Header: "# Changelog", + Versions: []changelog.VersionChangelog{ + { + Version: "v1.0.0", + Date: "2025-01-15", + Groups: []changelog.TypeGroup{ + { + Type: "feat", + Header: "Features", + Commits: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat: first feature"), + ShortHash: "aaa1111", + }, + }, + }, + }, + }, + }, + } + + result, err := f.Format(cl) + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(result, "# Changelog") { + t.Error("expected changelog header at start") + } + if !strings.Contains(result, "## v1.0.0") { + t.Error("expected version header") + } +} + +func TestMarkdownNoCommitURL(t *testing.T) { + f := &MarkdownFormatter{} + v := &changelog.VersionChangelog{ + Version: "v1.0.0", + Date: "2025-01-15", + Groups: []changelog.TypeGroup{ + { + Type: "feat", + Header: "Features", + Commits: []changelog.CommitInfo{ + { + Commit: parseCommit(t, "feat: something"), + ShortHash: "abc1234", + // no CommitURL + }, + }, + }, + }, + } + + result, err := f.FormatVersion(v, nil) + if err != nil { + t.Fatal(err) + } + + // should have (abc1234) without link + if !strings.Contains(result, "(abc1234)") { + t.Error("expected short hash without link") + } +} diff --git a/changelog/generator.go b/changelog/generator.go new file mode 100644 index 0000000..82f2163 --- /dev/null +++ b/changelog/generator.go @@ -0,0 +1,389 @@ +package changelog + +import ( + "fmt" + "strings" + "time" + + "github.com/conventionalcommit/commitlint/commit" + "github.com/conventionalcommit/commitlint/internal/git" +) + +// Generator orchestrates changelog generation +type Generator struct { + git *git.Client + conf *Config + + parser commit.Parser + commitURL string + compareURL string +} + +// New creates a new Generator for the given repository directory +func New(repoDir string, conf *Config) (*Generator, error) { + git, err := git.NewClient(repoDir) + if err != nil { + return nil, err + } + + g := &Generator{ + git: git, + conf: conf, + parser: commit.NewParser(), + } + + // infer repository URLs if not configured + if err := g.inferURLs(); err != nil { + return nil, err + } + + return g, nil +} + +// inferURLs infers commit and compare URLs from git remote +func (g *Generator) inferURLs() error { + repoURL := g.conf.Repository.URL + + if repoURL == "" { + var err error + repoURL, err = g.git.GetRemoteURL() + if err != nil { + return err + } + } + + if repoURL == "" { + return nil // no remote, links will be empty + } + + hostType := git.DetectHostType(repoURL) + + g.commitURL = g.conf.Repository.CommitURL + if g.commitURL == "" { + g.commitURL = git.InferCommitURL(repoURL, hostType) + } + + g.compareURL = g.conf.Repository.CompareURL + if g.compareURL == "" { + g.compareURL = git.InferCompareURL(repoURL, hostType) + } + + return nil +} + +// GenerateAll generates the full changelog for all versions +func (g *Generator) GenerateAll() (*Changelog, error) { + tags, err := g.git.GetSemverTags() + if err != nil { + return nil, fmt.Errorf("failed to get tags: %w", err) + } + + cl := &Changelog{ + Header: g.conf.Header, + } + + head, err := g.git.GetHead() + if err != nil { + return nil, fmt.Errorf("failed to get HEAD: %w", err) + } + + if len(tags) == 0 { + // no tags — generate single "Unreleased" version from all commits + vc, err := g.generateVersion("Unreleased", "", "", head, time.Now()) + if err != nil { + return nil, err + } + if hasCommits(vc) { + cl.Versions = append(cl.Versions, *vc) + } + return cl, nil + } + + // check for unreleased commits (after latest tag) + latestTag := tags[0] + vc, err := g.generateVersion("Unreleased", latestTag.Name, latestTag.Name, "HEAD", time.Now()) + if err != nil { + return nil, err + } + if hasCommits(vc) { + cl.Versions = append(cl.Versions, *vc) + } + + // generate each version + for i := 0; i < len(tags); i++ { + tag := tags[i] + + var fromRef string + if i+1 < len(tags) { + fromRef = tags[i+1].Name + } + + date := tag.Date + if date.IsZero() { + date, _ = g.git.GetTagDate(tag.Name) + } + + var prevTag string + if i+1 < len(tags) { + prevTag = tags[i+1].Name + } + + vc, err := g.generateVersion(tag.Name, prevTag, fromRef, tag.Name, date) + if err != nil { + return nil, err + } + if hasCommits(vc) { + cl.Versions = append(cl.Versions, *vc) + } + } + + return cl, nil +} + +// GenerateRange generates the changelog between two refs +func (g *Generator) GenerateRange(from, to string) (*Changelog, error) { + if to == "" { + to = "HEAD" + } + + cl := &Changelog{ + Header: g.conf.Header, + } + + // try to determine version name + versionName := to + if to == "HEAD" { + // check if there's a tag at HEAD + head, err := g.git.GetHead() + if err == nil { + tags, _ := g.git.GetSemverTags() + for _, t := range tags { + if t.Hash == head[:len(t.Hash)] || head[:len(t.Hash)] == t.Hash { + versionName = t.Name + break + } + } + if versionName == "HEAD" { + versionName = "Unreleased" + } + } + } + + date := time.Now() + if to != "HEAD" { + d, err := g.git.GetTagDate(to) + if err == nil && !d.IsZero() { + date = d + } + } + + vc, err := g.generateVersion(versionName, from, from, to, date) + if err != nil { + return nil, err + } + if hasCommits(vc) { + cl.Versions = append(cl.Versions, *vc) + } + + return cl, nil +} + +// GenerateSmart implements the smart generation logic: +// - If explicit from/to given, use them +// - Otherwise generate full changelog for all versions +func (g *Generator) GenerateSmart(from, to string) (*Changelog, error) { + // if explicit range given, use it + if from != "" || to != "" { + return g.GenerateRange(from, to) + } + + // generate full changelog for all versions + return g.GenerateAll() +} + +// generateVersion generates a VersionChangelog for a single version +func (g *Generator) generateVersion(version, prevVersion, fromRef, toRef string, date time.Time) (*VersionChangelog, error) { + commits, err := g.git.GetCommits(fromRef, toRef) + if err != nil { + return nil, fmt.Errorf("failed to get commits for %s: %w", version, err) + } + + vc := &VersionChangelog{ + Version: version, + Date: date.Format("2006-01-02"), + CompareURL: git.BuildCompareURL(g.compareURL, prevVersion, version), + FromRef: fromRef, + ToRef: toRef, + } + + // if no previous version for compare, and it's the first version + if prevVersion == "" && len(commits) > 0 { + firstCommit := commits[len(commits)-1] + vc.CompareURL = git.BuildCompareURL(g.compareURL, firstCommit.Hash, version) + } + + // build type lookup + typeIndex := g.buildTypeIndex() + + // group map: type -> []CommitInfo + groups := make(map[string][]CommitInfo) + var breaking []CommitInfo + var other []CommitInfo + + for _, raw := range commits { + ci := g.processCommit(raw) + + if ci.Commit == nil { + // non-conventional commit + if g.conf.IncludeOther { + other = append(other, ci) + } + continue + } + + // collect breaking changes + if g.conf.IncludeBreaking && ci.Commit.IsBreakingChange() { + breaking = append(breaking, ci) + } + + commitType := ci.Commit.Type() + groups[commitType] = append(groups[commitType], ci) + } + + // build ordered type groups following config order + for _, tc := range g.conf.Types { + if tc.Hidden { + continue + } + commits, ok := groups[tc.Type] + if !ok || len(commits) == 0 { + continue + } + vc.Groups = append(vc.Groups, TypeGroup{ + Type: tc.Type, + Header: tc.Header, + Commits: commits, + }) + delete(groups, tc.Type) + } + + // append any remaining unknown types + for typ, commits := range groups { + if len(commits) == 0 { + continue + } + // check if it's hidden + if tc, ok := typeIndex[typ]; ok && tc.Hidden { + continue + } + vc.Groups = append(vc.Groups, TypeGroup{ + Type: typ, + Header: capitalizeFirst(typ), + Commits: commits, + }) + } + + vc.Breaking = breaking + vc.Other = other + + return vc, nil +} + +// processCommit converts a raw git commit into a CommitInfo +func (g *Generator) processCommit(raw git.CommitRaw) CommitInfo { + ci := CommitInfo{ + Hash: raw.Hash, + ShortHash: raw.ShortHash, + CommitURL: git.BuildCommitURL(g.commitURL, raw.Hash), + Author: raw.Author, + Date: raw.Date, + } + + // reconstruct full message for parser + msg := raw.Subject + if raw.Body != "" { + msg = raw.Subject + "\n\n" + raw.Body + } + + commit, err := g.parser.Parse(msg) + if err == nil { + ci.Commit = commit + } + + // extract issue references + ci.References = git.ExtractReferences(msg, g.conf.IssuePrefixes) + + return ci +} + +// buildTypeIndex creates a map from type name to TypeConfig +func (g *Generator) buildTypeIndex() map[string]TypeConfig { + index := make(map[string]TypeConfig, len(g.conf.Types)) + for _, tc := range g.conf.Types { + index[tc.Type] = tc + } + return index +} + +// hasCommits checks if a VersionChangelog has any commits +func hasCommits(vc *VersionChangelog) bool { + if vc == nil { + return false + } + for _, g := range vc.Groups { + if len(g.Commits) > 0 { + return true + } + } + return len(vc.Breaking) > 0 || len(vc.Other) > 0 +} + +// capitalizeFirst capitalizes the first letter of a string +func capitalizeFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// DebugInfo holds debug information about the repository +type DebugInfo struct { + Version string + Build string + GitDir string + RemoteURL string + HostType git.HostType + CommitURL string + CompareURL string + LatestTag string + TotalTags int + Head string + ConfigPath string + ConfigType string + Formatter string + Types []TypeConfig +} + +// Debug returns debug information about the generator setup +func (g *Generator) Debug() (*DebugInfo, error) { + head, _ := g.git.GetHead() + remoteURL, _ := g.git.GetRemoteURL() + tags, _ := g.git.GetSemverTags() + latestTag, _ := g.git.GetLatestTag() + + hostType := git.DetectHostType(remoteURL) + + info := &DebugInfo{ + RemoteURL: remoteURL, + HostType: hostType, + CommitURL: g.commitURL, + CompareURL: g.compareURL, + LatestTag: latestTag.Name, + TotalTags: len(tags), + Head: head, + Formatter: g.conf.Formatter, + Types: g.conf.Types, + } + + return info, nil +} diff --git a/changelog/generator_test.go b/changelog/generator_test.go new file mode 100644 index 0000000..7a103bd --- /dev/null +++ b/changelog/generator_test.go @@ -0,0 +1,68 @@ +package changelog + +import ( + "testing" + + "github.com/conventionalcommit/commitlint/internal/git" +) + +func TestExtractReferencesIntegration(t *testing.T) { + conf := &Config{ + IssuePrefixes: []string{"#", "GH-"}, + } + + msg := "fix: resolve #42 and GH-100" + refs := git.ExtractReferences(msg, conf.IssuePrefixes) + + if len(refs) != 2 { + t.Fatalf("expected 2 refs, got %d: %v", len(refs), refs) + } +} + +func TestCapitalizeFirst(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"feat", "Feat"}, + {"", ""}, + {"Fix", "Fix"}, + {"ci", "Ci"}, + } + + for _, tt := range tests { + result := capitalizeFirst(tt.input) + if result != tt.expected { + t.Errorf("capitalizeFirst(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestHasCommits(t *testing.T) { + // nil + if hasCommits(nil) { + t.Error("hasCommits(nil) should be false") + } + + // empty + vc := &VersionChangelog{} + if hasCommits(vc) { + t.Error("hasCommits(empty) should be false") + } + + // with groups + vc.Groups = []TypeGroup{ + {Commits: []CommitInfo{{Hash: "abc"}}}, + } + if !hasCommits(vc) { + t.Error("hasCommits(with groups) should be true") + } + + // with breaking + vc2 := &VersionChangelog{ + Breaking: []CommitInfo{{Hash: "def"}}, + } + if !hasCommits(vc2) { + t.Error("hasCommits(with breaking) should be true") + } +} diff --git a/config/api.go b/config/api.go index ae9a54e..8adf9df 100644 --- a/config/api.go +++ b/config/api.go @@ -1,15 +1,30 @@ package config -import "github.com/conventionalcommit/commitlint/lint" +import ( + "github.com/conventionalcommit/commitlint/changelog" + "github.com/conventionalcommit/commitlint/lint" +) // LintMessage lints commitMsg using the default configuration. // It is the simplest entry point for programmatic use: no config file is needed. // // For custom configuration use Parse or NewDefault, then NewLinter. func LintMessage(commitMsg string) (*lint.Result, error) { - linter, err := NewLinter(NewDefault()) + conf := NewDefaultLint() + linter, err := NewLinter(conf) if err != nil { return nil, err } return linter.ParseAndLint(commitMsg) } + +// GenerateChangelog generates changelog using the default configuration. +// It is the simplest entry point for programmatic use. +func GenerateChangelog(repoDir string) (*changelog.Changelog, error) { + clConf := NewDefaultChangelog() + gen, err := changelog.New(repoDir, clConf) + if err != nil { + return nil, err + } + return gen.GenerateAll() +} diff --git a/config/changelog.go b/config/changelog.go new file mode 100644 index 0000000..c78d2ba --- /dev/null +++ b/config/changelog.go @@ -0,0 +1,63 @@ +package config + +import ( + "fmt" + + "github.com/conventionalcommit/commitlint/changelog" + "github.com/conventionalcommit/commitlint/registry" +) + +// NewGenerator creates a changelog generator for the given repository directory and config. +func NewGenerator(repoDir string, conf *changelog.Config) (*changelog.Generator, error) { + return changelog.New(repoDir, conf) +} + +// GetChangelogFormatter returns the changelog formatter as defined in conf. +func GetChangelogFormatter(conf *changelog.Config) (changelog.Formatter, error) { + if conf.Formatter == "" { + return nil, fmt.Errorf("config error: changelog formatter is empty") + } + f, ok := registry.GetChangelogFormatter(conf.Formatter) + if !ok { + return nil, fmt.Errorf("config error: '%s' changelog formatter not found", conf.Formatter) + } + return f, nil +} + +// ValidateChangelog validates the given changelog config. +// It checks that the formatter is registered and types are defined. +func ValidateChangelog(conf *changelog.Config) []error { + var errs []error + + if conf.Formatter == "" { + errs = append(errs, fmt.Errorf("changelog formatter is empty")) + } else { + _, ok := registry.GetChangelogFormatter(conf.Formatter) + if !ok { + errs = append(errs, fmt.Errorf("unknown changelog formatter '%s'", conf.Formatter)) + } + } + + if len(conf.Types) == 0 { + errs = append(errs, fmt.Errorf("changelog types are empty")) + } + + // Check for duplicate types + seen := make(map[string]struct{}, len(conf.Types)) + for _, tc := range conf.Types { + if tc.Type == "" { + errs = append(errs, fmt.Errorf("changelog type entry has empty type field")) + continue + } + if tc.Header == "" { + errs = append(errs, fmt.Errorf("changelog type '%s' has empty header", tc.Type)) + } + if _, exists := seen[tc.Type]; exists { + errs = append(errs, fmt.Errorf("duplicate changelog type '%s'", tc.Type)) + } else { + seen[tc.Type] = struct{}{} + } + } + + return errs +} diff --git a/config/config.go b/config/config.go index f8f4a27..a55fc3b 100644 --- a/config/config.go +++ b/config/config.go @@ -1,4 +1,4 @@ -// Package config contains helpers, defaults for linter +// Package config contains helpers, defaults for linter and changelog package config import ( @@ -7,142 +7,126 @@ import ( "io" "os" "path/filepath" - "regexp" - "golang.org/x/mod/semver" yaml "gopkg.in/yaml.v2" + "github.com/conventionalcommit/commitlint/changelog" "github.com/conventionalcommit/commitlint/internal" "github.com/conventionalcommit/commitlint/lint" - "github.com/conventionalcommit/commitlint/lint/formatter" - "github.com/conventionalcommit/commitlint/registry" ) -// Parse parse given file in confPath, and return Config instance, error if any -func Parse(confPath string) (*lint.Config, error) { +// Config is the top-level configuration that bundles lint and changelog config. +type Config struct { + Lint *lint.Config `yaml:"lint"` + Changelog *changelog.Config `yaml:"changelog"` +} + +// Parse parses the given config file and returns a Config instance. +func Parse(confPath string) (*Config, error) { confPath = filepath.Clean(confPath) confBytes, err := os.ReadFile(confPath) if err != nil { return nil, fmt.Errorf("config file error: %w", err) } - conf := &lint.Config{ - Formatter: (&formatter.DefaultFormatter{}).Name(), - Severity: lint.SeverityConfig{ - Default: lint.SeverityError, + conf := &Config{ + Lint: &lint.Config{ + Formatter: defaultLintFormatter, + Severity: lint.SeverityConfig{ + Default: lint.SeverityError, + }, }, + Changelog: NewDefaultChangelog(), } err = yaml.UnmarshalStrict(confBytes, conf) if err != nil { + // Detect old flat config format (pre-v0.12.0) that lacks the top-level "lint:" key. + if isOldConfigFormat(confBytes) { + return nil, fmt.Errorf( + "config file error: this looks like a pre-v0.12.0 config (flat format without 'lint:' key).\n"+ + "Please migrate to the new format. See: https://github.com/conventionalcommit/commitlint/blob/main/docs/migration.md\n"+ + "Original error: %w", err, + ) + } return nil, fmt.Errorf("config file error: %w", err) } + // --- Apply lint defaults --- + // Backward compatibility: accept old "version" key - if conf.MinVersion == "" && conf.DeprecatedVersion != "" { - conf.MinVersion = conf.DeprecatedVersion + if conf.Lint.MinVersion == "" && conf.Lint.DeprecatedVersion != "" { + conf.Lint.MinVersion = conf.Lint.DeprecatedVersion } - conf.DeprecatedVersion = "" + conf.Lint.DeprecatedVersion = "" // Default to current version if neither key was provided - if conf.MinVersion == "" { - conf.MinVersion = internal.Version() + if conf.Lint.MinVersion == "" { + conf.Lint.MinVersion = internal.Version() } // Always set the built-in default patterns - conf.DefaultIgnorePatterns = DefaultIgnorePatterns() - - if conf.Formatter == "" { - return nil, errors.New("config error: formatter is empty") - } - - err = isValidVersion(conf.MinVersion) - if err != nil { - return nil, err - } - return conf, nil -} + conf.Lint.DefaultIgnorePatterns = DefaultIgnorePatterns() -// Validate validates given config instance, it checks the following -// If formatters, rules are registered/known -// If arguments to rules are valid -// If version is valid and at least minimum than commitlint version used -func Validate(conf *lint.Config) []error { - var errs []error - - err := isValidVersion(conf.MinVersion) - if err != nil { - errs = append(errs, err) - } - - if conf.Formatter == "" { - errs = append(errs, errors.New("formatter is empty")) + // --- Apply changelog defaults --- + if conf.Changelog == nil { + conf.Changelog = NewDefaultChangelog() } else { - _, ok := registry.GetFormatter(conf.Formatter) - if !ok { - errs = append(errs, fmt.Errorf("unknown formatter '%s'", conf.Formatter)) - } - } - - // Check Severity Level - if !isSeverityValid(conf.Severity.Default) { - errs = append(errs, fmt.Errorf("unknown default severity level '%s'", conf.Severity.Default)) - } + defaults := NewDefaultChangelog() + cl := conf.Changelog - for ruleName, sev := range conf.Severity.Rules { - // Check Severity Level of rule config - if !isSeverityValid(sev) { - errs = append(errs, fmt.Errorf("unknown severity level '%s' for rule '%s'", sev, ruleName)) + if cl.Formatter == "" { + cl.Formatter = defaults.Formatter } - } - - for _, ruleName := range conf.Rules { - // Check if rule is registered - _, ok := registry.GetRule(ruleName) - if !ok { - errs = append(errs, fmt.Errorf("unknown rule '%s'", ruleName)) - continue + if cl.Header == "" { + cl.Header = defaults.Header } - } - - // Check for duplicate rules - ruleSeen := make(map[string]struct{}, len(conf.Rules)) - for _, ruleName := range conf.Rules { - if _, exists := ruleSeen[ruleName]; exists { - errs = append(errs, fmt.Errorf("duplicate rule '%s' in rules list", ruleName)) - } else { - ruleSeen[ruleName] = struct{}{} + if len(cl.IssuePrefixes) == 0 { + cl.IssuePrefixes = defaults.IssuePrefixes + } + if len(cl.Types) == 0 { + cl.Types = defaults.Types } } - for ruleName, ruleSetting := range conf.Settings { - // Check if rule is registered - ruleData, ok := registry.GetRule(ruleName) - if !ok { - errs = append(errs, fmt.Errorf("unknown rule '%s'", ruleName)) - continue - } + // --- Validate essentials --- - err := ruleData.Apply(ruleSetting) - if err != nil { - errs = append(errs, err) - } + if conf.Lint.Formatter == "" { + return nil, errors.New("config error: lint formatter is empty") } - // Validate ignore patterns (both default and user-defined) - for _, pattern := range conf.EffectiveIgnorePatterns() { - _, err := regexp.Compile(pattern) - if err != nil { - errs = append(errs, fmt.Errorf("invalid ignore pattern %q: %w", pattern, err)) - } + err = isValidVersion(conf.Lint.MinVersion) + if err != nil { + return nil, err } + return conf, nil +} - return errs +// isOldConfigFormat checks if the YAML bytes look like the old flat config +// (pre-v0.12.0) that had top-level keys like "formatter:", "rules:", "settings:" +// instead of being nested under "lint:". +func isOldConfigFormat(data []byte) bool { + // Quick heuristic: try to unmarshal into a map and check for old top-level keys + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return false + } + // Old format had these at the top level + oldKeys := []string{"formatter", "rules", "settings", "severity"} + matches := 0 + for _, k := range oldKeys { + if _, ok := raw[k]; ok { + matches++ + } + } + // If the file has "lint:" key, it's the new format (even if malformed) + _, hasLint := raw["lint"] + return !hasLint && matches >= 2 } -// LookupAndParse gets the config path according to the precedence -// if exists, parses the config file and returns config instance -func LookupAndParse() (*lint.Config, error) { +// LookupAndParse gets the config path according to the precedence, +// parses the config file if found, and returns a Config instance. +func LookupAndParse() (*Config, error) { confFilePath, confType, err := internal.LookupConfigPath() if err != nil { return nil, err @@ -161,7 +145,8 @@ func LookupAndParse() (*lint.Config, error) { // WriteTo writes config in yaml format to given io.Writer, including all // settings and every field even if empty or zero-valued. -func WriteTo(w io.Writer, conf *lint.Config) (retErr error) { +func WriteTo(w io.Writer, conf *Config) (retErr error) { + out := prepareForWrite(conf) enc := yaml.NewEncoder(w) defer func() { err := enc.Close() @@ -169,51 +154,72 @@ func WriteTo(w io.Writer, conf *lint.Config) (retErr error) { retErr = err } }() - return enc.Encode(conf) + return enc.Encode(out) } // WriteCompactTo writes config in yaml format to given io.Writer. -// Only settings for enabled rules are written, keeping the output compact. -func WriteCompactTo(w io.Writer, conf *lint.Config) error { - // Build a compact copy: only settings for enabled rules - compact := *conf - if len(compact.Rules) > 0 && len(compact.Settings) > 0 { - enabled := make(map[string]struct{}, len(compact.Rules)) - for _, r := range compact.Rules { +// Only settings for enabled rules and non-hidden changelog types are written, +// keeping the output compact. +func WriteCompactTo(w io.Writer, conf *Config) error { + out := prepareForWrite(conf) + + // Only settings for enabled lint rules + if out.Lint != nil && len(out.Lint.Rules) > 0 && len(out.Lint.Settings) > 0 { + enabled := make(map[string]struct{}, len(out.Lint.Rules)) + for _, r := range out.Lint.Rules { enabled[r] = struct{}{} } - filtered := make(map[string]lint.RuleSetting, len(compact.Rules)) - for name, setting := range compact.Settings { + filtered := make(map[string]lint.RuleSetting, len(out.Lint.Rules)) + for name, setting := range out.Lint.Settings { if _, ok := enabled[name]; ok { filtered[name] = setting } } - compact.Settings = filtered + out.Lint.Settings = filtered + } + + // Only non-hidden changelog types + if out.Changelog != nil && len(out.Changelog.Types) > 0 { + visible := make([]changelog.TypeConfig, 0, len(out.Changelog.Types)) + for _, tc := range out.Changelog.Types { + if !tc.Hidden { + visible = append(visible, tc) + } + } + // Shallow-copy to avoid mutating the caller's config + clCopy := *out.Changelog + clCopy.Types = visible + out.Changelog = &clCopy } enc := yaml.NewEncoder(w) defer enc.Close() - return enc.Encode(&compact) + return enc.Encode(out) } -func isValidVersion(versionNo string) error { - if versionNo == "" { - return errors.New("version is empty") +// prepareForWrite returns a copy with nil pointers filled with defaults. +func prepareForWrite(conf *Config) *Config { + out := *conf + if out.Lint == nil { + out.Lint = NewDefaultLint() } - if !semver.IsValid(versionNo) { - return errors.New("invalid version should be in semver format") + if out.Changelog == nil { + out.Changelog = NewDefaultChangelog() } - return nil + return &out } -func checkIfMinVersion(versionNo string) error { - cmp := semver.Compare(internal.Version(), versionNo) - if cmp != -1 { - return nil +func Validate(conf *Config) []error { + var errs []error + if conf.Lint != nil { + errs = append(errs, ValidateLint(conf.Lint)...) + } else { + errs = append(errs, errors.New("lint config is nil")) } - return fmt.Errorf("min version required is %s. you have %s.\nupgrade commitlint", versionNo, internal.Version()) -} - -func isSeverityValid(s lint.Severity) bool { - return s == lint.SeverityError || s == lint.SeverityWarn + if conf.Changelog != nil { + errs = append(errs, ValidateChangelog(conf.Changelog)...) + } else { + errs = append(errs, errors.New("changelog config is nil")) + } + return errs } diff --git a/config/default.go b/config/default.go index c8aa466..80116c3 100644 --- a/config/default.go +++ b/config/default.go @@ -1,7 +1,9 @@ package config import ( + "github.com/conventionalcommit/commitlint/changelog" "github.com/conventionalcommit/commitlint/internal" + "github.com/conventionalcommit/commitlint/internal/casing" "github.com/conventionalcommit/commitlint/lint" "github.com/conventionalcommit/commitlint/lint/formatter" "github.com/conventionalcommit/commitlint/lint/rule" @@ -10,8 +12,167 @@ import ( const ( DefaultTypeCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" DefaultScopeCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/," + DefaultTrailer = "Signed-off-by" ) +// Rule name variables — avoids repeated (&rule.XxxRule{}).Name() allocations. +var ( + ruleHeadMinLen = (&rule.HeadMinLenRule{}).Name() + ruleHeadMaxLen = (&rule.HeadMaxLenRule{}).Name() + ruleBodyMinLen = (&rule.BodyMinLenRule{}).Name() + ruleBodyMaxLen = (&rule.BodyMaxLenRule{}).Name() + ruleBodyMaxLineLen = (&rule.BodyMaxLineLenRule{}).Name() + ruleFooterMinLen = (&rule.FooterMinLenRule{}).Name() + ruleFooterMaxLen = (&rule.FooterMaxLenRule{}).Name() + ruleFooterMaxLine = (&rule.FooterMaxLineLenRule{}).Name() + ruleTypeMinLen = (&rule.TypeMinLenRule{}).Name() + ruleTypeMaxLen = (&rule.TypeMaxLenRule{}).Name() + ruleScopeMinLen = (&rule.ScopeMinLenRule{}).Name() + ruleScopeMaxLen = (&rule.ScopeMaxLenRule{}).Name() + ruleDescMinLen = (&rule.DescriptionMinLenRule{}).Name() + ruleDescMaxLen = (&rule.DescriptionMaxLenRule{}).Name() + + ruleTypeEnum = (&rule.TypeEnumRule{}).Name() + ruleScopeEnum = (&rule.ScopeEnumRule{}).Name() + ruleFooterEnum = (&rule.FooterEnumRule{}).Name() + ruleFooterTypeEnum = (&rule.FooterTypeEnumRule{}).Name() + + ruleTypeCharset = (&rule.TypeCharsetRule{}).Name() + ruleScopeCharset = (&rule.ScopeCharsetRule{}).Name() + + ruleTypeCase = (&rule.TypeCaseRule{}).Name() + ruleScopeCase = (&rule.ScopeCaseRule{}).Name() + ruleDescCase = (&rule.DescriptionCaseRule{}).Name() + ruleBodyCase = (&rule.BodyCaseRule{}).Name() + ruleHeadCase = (&rule.HeaderCaseRule{}).Name() + + ruleTypeEmpty = (&rule.TypeEmptyRule{}).Name() + ruleScopeEmpty = (&rule.ScopeEmptyRule{}).Name() + ruleBodyEmpty = (&rule.BodyEmptyRule{}).Name() + ruleFooterEmpty = (&rule.FooterEmptyRule{}).Name() + ruleDescEmpty = (&rule.DescriptionEmptyRule{}).Name() + + ruleHeadFullStop = (&rule.HeaderFullStopRule{}).Name() + ruleBodyFullStop = (&rule.BodyFullStopRule{}).Name() + ruleDescFullStop = (&rule.DescriptionFullStopRule{}).Name() + + ruleBodyLeadingBlank = (&rule.BodyLeadingBlankRule{}).Name() + ruleFooterLeadingBlank = (&rule.FooterLeadingBlankRule{}).Name() + + ruleHeaderTrim = (&rule.HeaderTrimRule{}).Name() + + ruleSignedOffBy = (&rule.SignedOffByRule{}).Name() + ruleTrailerExists = (&rule.TrailerExistsRule{}).Name() + + ruleBreakingExcl = (&rule.BreakingChangeExclamationMarkRule{}).Name() + + defaultLintFormatter = (&formatter.DefaultFormatter{}).Name() +) + +// NewDefault returns the default Config with lint and changelog defaults. +func NewDefault() *Config { + return &Config{ + Lint: NewDefaultLint(), + Changelog: NewDefaultChangelog(), + } +} + +// NewDefaultLint returns the default lint configuration. +func NewDefaultLint() *lint.Config { + return &lint.Config{ + MinVersion: internal.Version(), + Formatter: defaultLintFormatter, + Rules: []string{ + ruleHeadMinLen, + ruleHeadMaxLen, + ruleBodyMaxLineLen, + ruleFooterMaxLine, + ruleTypeEnum, + }, + Severity: lint.SeverityConfig{ + Default: lint.SeverityError, + }, + Settings: map[string]lint.RuleSetting{ + // Length rules + ruleHeadMinLen: {Argument: 10}, + ruleHeadMaxLen: {Argument: 72}, + ruleBodyMinLen: {Argument: 0}, + ruleBodyMaxLen: {Argument: -1}, + ruleBodyMaxLineLen: {Argument: 100}, + ruleFooterMinLen: {Argument: 0}, + ruleFooterMaxLen: {Argument: -1}, + ruleFooterMaxLine: {Argument: 100}, + ruleTypeMinLen: {Argument: 0}, + ruleTypeMaxLen: {Argument: -1}, + ruleScopeMinLen: {Argument: 0}, + ruleScopeMaxLen: {Argument: -1}, + ruleDescMinLen: {Argument: 0}, + ruleDescMaxLen: {Argument: -1}, + + // Enum rules + ruleTypeEnum: {Argument: DefaultTypeEnums()}, + ruleScopeEnum: { + Argument: []interface{}{}, + Flags: map[string]interface{}{"allow-empty": true}, + }, + ruleFooterEnum: {Argument: []interface{}{}}, + ruleFooterTypeEnum: {Argument: []interface{}{}}, + + // Charset rules + ruleTypeCharset: {Argument: DefaultTypeCharset}, + ruleScopeCharset: {Argument: DefaultScopeCharset}, + + // Case rules + ruleTypeCase: {Argument: casing.Lower}, + ruleScopeCase: {Argument: casing.Lower}, + ruleDescCase: {Argument: casing.Lower}, + ruleBodyCase: {Argument: casing.Lower}, + ruleHeadCase: {Argument: casing.Lower}, + + // Full-stop rules + ruleHeadFullStop: {Argument: "."}, + ruleBodyFullStop: {Argument: "."}, + ruleDescFullStop: {Argument: "."}, + + // Trailer / Signed-off-by + ruleSignedOffBy: {Argument: DefaultTrailer}, + ruleTrailerExists: {Argument: DefaultTrailer}, + + // Empty rules + ruleTypeEmpty: {}, + ruleScopeEmpty: {}, + ruleBodyEmpty: {}, + ruleFooterEmpty: {}, + ruleDescEmpty: {}, + + // Leading-blank rules + ruleBodyLeadingBlank: {}, + ruleFooterLeadingBlank: {}, + + // Header trim + ruleHeaderTrim: {}, + + // Breaking change + ruleBreakingExcl: {}, + }, + DefaultIgnorePatterns: DefaultIgnorePatterns(), + } +} + +// NewDefaultChangelog returns the default changelog configuration +func NewDefaultChangelog() *changelog.Config { + return &changelog.Config{ + Formatter: "markdown", + Output: "", + Header: "# Changelog", + IssuePrefixes: DefaultIssuePrefixes(), + IncludeOther: false, + IncludeBreaking: true, + SkipMergeCommits: true, + Types: DefaultChangelogTypes(), + } +} + // DefaultIgnorePatterns returns the default list of ignore patterns // These patterns match commit messages auto-generated by git commands // like merge, revert, fixup, squash, etc. @@ -52,188 +213,24 @@ func DefaultTypeEnums() []interface{} { } } -// NewDefault returns default config -func NewDefault() *lint.Config { - // Enabled Rules - rules := []string{ - (&rule.HeadMinLenRule{}).Name(), - (&rule.HeadMaxLenRule{}).Name(), - (&rule.BodyMaxLineLenRule{}).Name(), - (&rule.FooterMaxLineLenRule{}).Name(), - (&rule.TypeEnumRule{}).Name(), - } - - // Severity Levels - severity := lint.SeverityConfig{ - Default: lint.SeverityError, - } - - // Default Rule Settings - settings := map[string]lint.RuleSetting{ - // Header Min Len Rule - (&rule.HeadMinLenRule{}).Name(): { - Argument: 10, - }, - - // Header Max Len Rule - (&rule.HeadMaxLenRule{}).Name(): { - Argument: 72, - }, - - // Body Max Line Rule - (&rule.BodyMaxLineLenRule{}).Name(): { - Argument: 100, - }, - - // Footer Max Line Rule - (&rule.FooterMaxLineLenRule{}).Name(): { - Argument: 100, - }, - - // Types Enum Rule - (&rule.TypeEnumRule{}).Name(): { - Argument: DefaultTypeEnums(), - }, - - // Scope Enum Rule - (&rule.ScopeEnumRule{}).Name(): { - Argument: []interface{}{}, - Flags: map[string]interface{}{ - "allow-empty": true, - }, - }, - - // Body Min Len Rule - (&rule.BodyMinLenRule{}).Name(): { - Argument: 0, - }, - - // Body Max Len Rule - (&rule.BodyMaxLenRule{}).Name(): { - Argument: -1, - }, - - // Footer Min Len Rule - (&rule.FooterMinLenRule{}).Name(): { - Argument: 0, - }, - - // Footer Max Len Rule - (&rule.FooterMaxLenRule{}).Name(): { - Argument: -1, - }, - - // Type Min Len Rule - (&rule.TypeMinLenRule{}).Name(): { - Argument: 0, - }, - - // Type Max Len Rule - (&rule.TypeMaxLenRule{}).Name(): { - Argument: -1, - }, - - // Scope Min Len Rule - (&rule.ScopeMinLenRule{}).Name(): { - Argument: 0, - }, - - // Scope Max Len Rule - (&rule.ScopeMaxLenRule{}).Name(): { - Argument: -1, - }, - - // Description Min Len Rule - (&rule.DescriptionMinLenRule{}).Name(): { - Argument: 0, - }, - - // Description Max Len Rule - (&rule.DescriptionMaxLenRule{}).Name(): { - Argument: -1, - }, - - // Type Charset Rule - (&rule.TypeCharsetRule{}).Name(): { - Argument: DefaultTypeCharset, - }, - - // Scope Charset Rule - (&rule.ScopeCharsetRule{}).Name(): { - Argument: DefaultScopeCharset, - }, - - // Footer Enum Rule - (&rule.FooterEnumRule{}).Name(): { - Argument: []interface{}{}, - }, - - // Footer Type Enum Rule - (&rule.FooterTypeEnumRule{}).Name(): { - Argument: []interface{}{}, - }, - - // Case Rules - (&rule.TypeCaseRule{}).Name(): { - Argument: "lower-case", - }, - (&rule.ScopeCaseRule{}).Name(): { - Argument: "lower-case", - }, - (&rule.DescriptionCaseRule{}).Name(): { - Argument: "lower-case", - }, - (&rule.BodyCaseRule{}).Name(): { - Argument: "lower-case", - }, - (&rule.HeaderCaseRule{}).Name(): { - Argument: "lower-case", - }, - - // Full-stop Rules - (&rule.HeaderFullStopRule{}).Name(): { - Argument: ".", - }, - (&rule.BodyFullStopRule{}).Name(): { - Argument: ".", - }, - (&rule.DescriptionFullStopRule{}).Name(): { - Argument: ".", - }, - - // Trailer / Signed-off-by - (&rule.SignedOffByRule{}).Name(): { - Argument: "Signed-off-by", - }, - (&rule.TrailerExistsRule{}).Name(): { - Argument: "Signed-off-by", - }, - - // Empty rules (no argument needed) - (&rule.TypeEmptyRule{}).Name(): {}, - (&rule.ScopeEmptyRule{}).Name(): {}, - (&rule.BodyEmptyRule{}).Name(): {}, - (&rule.FooterEmptyRule{}).Name(): {}, - (&rule.DescriptionEmptyRule{}).Name(): {}, - - // Leading-blank rules (no argument needed) - (&rule.BodyLeadingBlankRule{}).Name(): {}, - (&rule.FooterLeadingBlankRule{}).Name(): {}, - - // Header trim (no argument needed) - (&rule.HeaderTrimRule{}).Name(): {}, - - // Breaking change (no argument needed) - (&rule.BreakingChangeExclamationMarkRule{}).Name(): {}, +// DefaultChangelogTypes returns the default conventional commit type configuration for changelog +func DefaultChangelogTypes() []changelog.TypeConfig { + return []changelog.TypeConfig{ + {Type: "feat", Header: "Features"}, + {Type: "fix", Header: "Bug Fixes"}, + {Type: "docs", Header: "Documentation"}, + {Type: "style", Header: "Styles", Hidden: true}, + {Type: "refactor", Header: "Refactor", Hidden: true}, + {Type: "perf", Header: "Performance"}, + {Type: "test", Header: "Tests", Hidden: true}, + {Type: "build", Header: "Build", Hidden: true}, + {Type: "ci", Header: "CI", Hidden: true}, + {Type: "chore", Header: "Chores", Hidden: true}, + {Type: "revert", Header: "Reverts", Hidden: true}, } +} - def := &lint.Config{ - MinVersion: internal.Version(), - Formatter: (&formatter.DefaultFormatter{}).Name(), - Rules: rules, - Severity: severity, - Settings: settings, - DefaultIgnorePatterns: DefaultIgnorePatterns(), - } - return def +// DefaultIssuePrefixes returns the default issue prefixes +func DefaultIssuePrefixes() []string { + return []string{"#"} } diff --git a/config/default_test.go b/config/default_test.go index c8b68cd..40501bd 100644 --- a/config/default_test.go +++ b/config/default_test.go @@ -7,7 +7,7 @@ import ( ) func TestDefaultLint(t *testing.T) { - defConf := NewDefault() + defConf := NewDefault().Lint _, err := NewLinter(defConf) if err != nil { t.Error("default lint creation failed", err) @@ -18,9 +18,36 @@ func TestDefaultLint(t *testing.T) { func TestDefaultSettings(t *testing.T) { defConf := NewDefault() rules := registry.Rules() - settingSize := len(defConf.Settings) + settingSize := len(defConf.Lint.Settings) if len(rules) != settingSize { t.Error("default config does not have all rule settings", len(rules), settingSize) return } } + +func TestNewLintDefault(t *testing.T) { + conf := NewDefaultLint() + if conf.MinVersion == "" { + t.Error("expected non-empty MinVersion") + } + if conf.Formatter == "" { + t.Error("expected non-empty Formatter") + } + if len(conf.Rules) == 0 { + t.Error("expected non-empty rules") + } + if len(conf.Settings) == 0 { + t.Error("expected non-empty settings") + } +} + +func TestNewDefaultChangelog_Valid(t *testing.T) { + conf := NewDefaultChangelog() + errs := ValidateChangelog(conf) + if len(errs) != 0 { + t.Errorf("expected no validation errors for default changelog, got %d:", len(errs)) + for _, e := range errs { + t.Errorf(" - %v", e) + } + } +} diff --git a/config/lint.go b/config/lint.go index cfb9e40..28ab512 100644 --- a/config/lint.go +++ b/config/lint.go @@ -1,10 +1,14 @@ package config import ( + "errors" "fmt" + "regexp" + "github.com/conventionalcommit/commitlint/internal" "github.com/conventionalcommit/commitlint/lint" "github.com/conventionalcommit/commitlint/registry" + "golang.org/x/mod/semver" ) // NewLinter returns Linter for given confFilePath @@ -70,3 +74,102 @@ func GetEnabledRules(conf *lint.Config) ([]lint.Rule, error) { return enabledRules, nil } + +// ValidateLint validates given lint config instance, it checks the following +// If formatters, rules are registered/known +// If arguments to rules are valid +// If version is valid and at least minimum than commitlint version used +func ValidateLint(conf *lint.Config) []error { + var errs []error + + err := isValidVersion(conf.MinVersion) + if err != nil { + errs = append(errs, err) + } + + if conf.Formatter == "" { + errs = append(errs, errors.New("formatter is empty")) + } else { + _, ok := registry.GetFormatter(conf.Formatter) + if !ok { + errs = append(errs, fmt.Errorf("unknown formatter '%s'", conf.Formatter)) + } + } + + // Check Severity Level + if !isSeverityValid(conf.Severity.Default) { + errs = append(errs, fmt.Errorf("unknown default severity level '%s'", conf.Severity.Default)) + } + + for ruleName, sev := range conf.Severity.Rules { + // Check Severity Level of rule config + if !isSeverityValid(sev) { + errs = append(errs, fmt.Errorf("unknown severity level '%s' for rule '%s'", sev, ruleName)) + } + } + + for _, ruleName := range conf.Rules { + // Check if rule is registered + _, ok := registry.GetRule(ruleName) + if !ok { + errs = append(errs, fmt.Errorf("unknown rule '%s'", ruleName)) + continue + } + } + + // Check for duplicate rules + ruleSeen := make(map[string]struct{}, len(conf.Rules)) + for _, ruleName := range conf.Rules { + if _, exists := ruleSeen[ruleName]; exists { + errs = append(errs, fmt.Errorf("duplicate rule '%s' in rules list", ruleName)) + } else { + ruleSeen[ruleName] = struct{}{} + } + } + + for ruleName, ruleSetting := range conf.Settings { + // Check if rule is registered + ruleData, ok := registry.GetRule(ruleName) + if !ok { + errs = append(errs, fmt.Errorf("unknown rule '%s'", ruleName)) + continue + } + + err := ruleData.Apply(ruleSetting) + if err != nil { + errs = append(errs, err) + } + } + + // Validate ignore patterns (both default and user-defined) + for _, pattern := range conf.EffectiveIgnorePatterns() { + _, err := regexp.Compile(pattern) + if err != nil { + errs = append(errs, fmt.Errorf("invalid ignore pattern %q: %w", pattern, err)) + } + } + + return errs +} + +func isValidVersion(versionNo string) error { + if versionNo == "" { + return errors.New("version is empty") + } + if !semver.IsValid(versionNo) { + return errors.New("invalid version should be in semver format") + } + return nil +} + +func checkIfMinVersion(versionNo string) error { + cmp := semver.Compare(internal.Version(), versionNo) + if cmp != -1 { + return nil + } + return fmt.Errorf("min version required is %s. you have %s.\nupgrade commitlint", versionNo, internal.Version()) +} + +func isSeverityValid(s lint.Severity) bool { + return s == lint.SeverityError || s == lint.SeverityWarn +} diff --git a/internal/cmd/changelog.go b/internal/cmd/changelog.go new file mode 100644 index 0000000..557fc21 --- /dev/null +++ b/internal/cmd/changelog.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/conventionalcommit/commitlint/changelog" + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/registry" + cli "github.com/urfave/cli/v2" +) + +func newChangelogCmd() *cli.Command { + return &cli.Command{ + Name: "changelog", + Usage: "Generate changelog from conventional commits", + Description: "Generates a changelog grouped by commit type.\nSmart mode: if no flags, generates full changelog for all versions.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Config `FILE` path (default: auto-detect)", + }, + &cli.StringFlag{ + Name: "from", + Usage: "Start reference (tag/commit/branch, exclusive)", + }, + &cli.StringFlag{ + Name: "to", + Usage: "End reference (default: HEAD)", + }, + &cli.StringFlag{ + Name: "output", + Aliases: []string{"o"}, + Usage: "Output `FILE` (default: stdout)", + }, + &cli.StringFlag{ + Name: "format", + Usage: "Output format: markdown, json (default: from config)", + }, + }, + Action: func(ctx *cli.Context) error { + confPath := ctx.String("config") + from := ctx.String("from") + to := ctx.String("to") + output := ctx.String("output") + format := ctx.String("format") + + return runChangelog(confPath, from, to, output, format) + }, + } +} + +func runChangelog(confPath, from, to, output, format string) error { + // load changelog config + clConf, err := loadChangelogConfig(confPath) + if err != nil { + return err + } + + // override format if specified via CLI + if format != "" { + clConf.Formatter = format + } + + // get current directory as repo dir + repoDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // create generator + gen, err := changelog.New(repoDir, clConf) + if err != nil { + return fmt.Errorf("failed to initialize: %w", err) + } + + // generate changelog + cl, err := gen.GenerateSmart(from, to) + if err != nil { + return fmt.Errorf("failed to generate changelog: %w", err) + } + + // get formatter + f, ok := registry.GetChangelogFormatter(clConf.Formatter) + if !ok { + return fmt.Errorf("unknown changelog formatter '%s'", clConf.Formatter) + } + + // format output + result, err := f.Format(cl) + if err != nil { + return fmt.Errorf("failed to format changelog: %w", err) + } + + // write output + if output != "" && output != "-" { + output = filepath.Clean(output) + err = os.WriteFile(output, []byte(result), 0o644) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + fmt.Printf("changelog written to %s\n", output) + return nil + } + + fmt.Print(result) + return nil +} + +func loadChangelogConfig(confPath string) (*changelog.Config, error) { + if confPath != "" { + confPath = filepath.Clean(confPath) + conf, err := config.Parse(confPath) + if err != nil { + return nil, err + } + return conf.Changelog, nil + } + + // Try to get config from the normal lookup + conf, err := config.LookupAndParse() + if err != nil { + return nil, err + } + + return conf.Changelog, nil +} + +func printChangelogDebug() error { + fmt.Println() + fmt.Println("Changelog:") + + // load config + conf, err := config.LookupAndParse() + if err != nil { + fmt.Printf(" Config Error: %s\n", err) + return nil + } + + clConf := conf.Changelog + + fmt.Printf(" Formatter: %s\n", clConf.Formatter) + + // git info + repoDir, err := os.Getwd() + if err != nil { + fmt.Printf(" Git: error: %s\n", err) + return nil + } + + gen, err := changelog.New(repoDir, clConf) + if err != nil { + fmt.Printf(" Git: error: %s\n", err) + return nil + } + + info, err := gen.Debug() + if err != nil { + fmt.Printf(" Git: error: %s\n", err) + return nil + } + + fmt.Printf(" Remote URL: %s\n", valueOrNA(info.RemoteURL)) + fmt.Printf(" Host Type: %s\n", string(info.HostType)) + fmt.Printf(" Commit URL: %s\n", valueOrNA(info.CommitURL)) + fmt.Printf(" Compare URL: %s\n", valueOrNA(info.CompareURL)) + fmt.Printf(" HEAD: %s\n", valueOrNA(info.Head)) + fmt.Printf(" Latest Tag: %s\n", valueOrNA(info.LatestTag)) + fmt.Printf(" Total Tags: %d\n", info.TotalTags) + + // types + fmt.Println(" Types:") + for _, t := range info.Types { + hidden := "" + if t.Hidden { + hidden = " (hidden)" + } + fmt.Printf(" %s → %s%s\n", t.Type, t.Header, hidden) + } + + // issue prefixes + if len(clConf.IssuePrefixes) > 0 { + fmt.Printf(" Issue Prefixes: %s\n", strings.Join(clConf.IssuePrefixes, ", ")) + } + + return nil +} + +func valueOrNA(s string) string { + if s == "" { + return "N/A" + } + return s +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index ffdb4eb..c64f87c 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -19,6 +19,7 @@ func newCliApp() *cli.App { newInitCmd(), newRemoveCmd(), newLintCmd(), + newChangelogCmd(), newConfigCmd(), newHookCmd(), newDebugCmd(), diff --git a/internal/cmd/debug.go b/internal/cmd/debug.go index 9923281..7a5b0fc 100644 --- a/internal/cmd/debug.go +++ b/internal/cmd/debug.go @@ -57,6 +57,10 @@ func printDebug() error { } fmt.Println(w.String()) + + // Print changelog debug info + _ = printChangelogDebug() + return nil } @@ -79,7 +83,7 @@ func getGitVersion() (string, error) { func getGitHookConfig(isGlobal bool) (string, error) { b := &bytes.Buffer{} - var args = []string{"config"} + args := []string{"config"} if isGlobal { args = append(args, "--global") } diff --git a/internal/cmd/lint.go b/internal/cmd/lint.go index 46a3197..a245190 100644 --- a/internal/cmd/lint.go +++ b/internal/cmd/lint.go @@ -83,7 +83,11 @@ func getLinter(confParam string) (*lint.Linter, lint.Formatter, error) { func getConfig(confParam string) (*lint.Config, error) { if confParam != "" { confParam = filepath.Clean(confParam) - return config.Parse(confParam) + conf, err := config.Parse(confParam) + if err != nil { + return nil, err + } + return conf.Lint, nil } // If config param is empty, lookup for defaults @@ -92,7 +96,7 @@ func getConfig(confParam string) (*lint.Config, error) { return nil, err } - return conf, nil + return conf.Lint, nil } func getCommitMsg(fileInput string) (string, error) { diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..fccc0d3 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,420 @@ +package git + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "os/exec" + "regexp" + "sort" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +var ( + errGitNotFound = errors.New("git is required and must be in PATH") + errNotGitRepo = errors.New("not a git repository (or any of the parent directories)") +) + +// CommitRaw holds raw commit data from git log +type CommitRaw struct { + Hash string + ShortHash string + Author string + Date time.Time + Subject string + Body string +} + +// TagInfo holds tag data +type TagInfo struct { + Name string + Hash string + Date time.Time +} + +// Client wraps git operations via os/exec +type Client struct { + repoDir string +} + +// NewClient creates a new git client for the given repository directory +func NewClient(repoDir string) (*Client, error) { + _, err := exec.LookPath("git") + if err != nil { + return nil, errGitNotFound + } + + g := &Client{repoDir: repoDir} + + // verify it's a git repo + _, err = g.run("rev-parse", "--git-dir") + if err != nil { + return nil, errNotGitRepo + } + + return g, nil +} + +// run executes a git command and returns trimmed stdout +func (g *Client) run(args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = g.repoDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg != "" { + return "", fmt.Errorf("git %s: %s", args[0], errMsg) + } + return "", fmt.Errorf("git %s: %w", args[0], err) + } + + return strings.TrimSpace(stdout.String()), nil +} + +// GetHead returns the HEAD commit hash +func (g *Client) GetHead() (string, error) { + return g.run("rev-parse", "HEAD") +} + +// GetRemoteURL returns the origin remote URL +func (g *Client) GetRemoteURL() (string, error) { + out, err := g.run("remote", "get-url", "origin") + if err != nil { + return "", nil // no remote is not an error + } + return normalizeRemoteURL(out), nil +} + +// GetSemverTags returns all semver tags sorted descending (newest first) +func (g *Client) GetSemverTags() ([]TagInfo, error) { + out, err := g.run("tag", "--list", "--format=%(refname:short)\t%(objectname:short)\t%(*objectname:short)\t%(creatordate:iso-strict)") + if err != nil { + return nil, err + } + + if out == "" { + return nil, nil + } + + var tags []TagInfo + lines := strings.Split(out, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.SplitN(line, "\t", 4) + if len(parts) < 4 { + continue + } + + name := parts[0] + + // ensure it's a valid semver tag + ver := name + if !strings.HasPrefix(ver, "v") { + ver = "v" + ver + } + if !semver.IsValid(ver) { + continue + } + + // for annotated tags, use the dereferenced commit hash + hash := parts[1] + if parts[2] != "" { + hash = parts[2] + } + + var t time.Time + if parts[3] != "" { + t, _ = time.Parse(time.RFC3339, parts[3]) + } + + tags = append(tags, TagInfo{ + Name: name, + Hash: hash, + Date: t, + }) + } + + // sort descending by semver + sort.Slice(tags, func(i, j int) bool { + vi := tags[i].Name + vj := tags[j].Name + if !strings.HasPrefix(vi, "v") { + vi = "v" + vi + } + if !strings.HasPrefix(vj, "v") { + vj = "v" + vj + } + return semver.Compare(vi, vj) > 0 + }) + + return tags, nil +} + +// GetLatestTag returns the latest semver tag, or empty if none +func (g *Client) GetLatestTag() (TagInfo, error) { + tags, err := g.GetSemverTags() + if err != nil { + return TagInfo{}, err + } + if len(tags) == 0 { + return TagInfo{}, nil + } + return tags[0], nil +} + +// GetFirstCommit returns the first commit hash in the repository +func (g *Client) GetFirstCommit() (string, error) { + return g.run("rev-list", "--max-parents=0", "HEAD") +} + +const ( + commitSep = "---COMMIT_SEP---" + fieldSep = "---FIELD_SEP---" +) + +// commitLogFormat is the git log format string +var commitLogFormat = strings.Join([]string{ + commitSep, + "%H" + fieldSep + "%h" + fieldSep + "%an" + fieldSep + "%aI" + fieldSep + "%s" + fieldSep + "%b", +}, "") + +// GetCommits returns commits between two refs +// fromRef is exclusive, toRef is inclusive +// if fromRef is empty, returns all commits up to toRef +// if toRef is empty, defaults to HEAD +func (g *Client) GetCommits(fromRef, toRef string) ([]CommitRaw, error) { + if toRef == "" { + toRef = "HEAD" + } + + var refRange string + if fromRef == "" { + refRange = toRef + } else { + refRange = fromRef + ".." + toRef + } + + out, err := g.run("log", "--format="+commitLogFormat, "--no-merges", refRange) + if err != nil { + return nil, err + } + + if out == "" { + return nil, nil + } + + return parseGitLog(out), nil +} + +// GetTagDate returns the date of a tag +func (g *Client) GetTagDate(tag string) (time.Time, error) { + out, err := g.run("log", "-1", "--format=%aI", tag) + if err != nil { + return time.Time{}, err + } + + t, err := time.Parse(time.RFC3339, strings.TrimSpace(out)) + if err != nil { + return time.Time{}, nil + } + return t, nil +} + +// HasChangelog checks if CHANGELOG.md exists +func (g *Client) HasChangelog() bool { + _, err := g.run("ls-files", "CHANGELOG.md") + return err == nil +} + +// parseGitLog parses the output of git log +func parseGitLog(output string) []CommitRaw { + chunks := strings.Split(output, commitSep) + var commits []CommitRaw + + for _, chunk := range chunks { + chunk = strings.TrimSpace(chunk) + if chunk == "" { + continue + } + + parts := strings.SplitN(chunk, fieldSep, 6) + if len(parts) < 5 { + continue + } + + hash := strings.TrimSpace(parts[0]) + shortHash := strings.TrimSpace(parts[1]) + author := strings.TrimSpace(parts[2]) + dateStr := strings.TrimSpace(parts[3]) + subject := strings.TrimSpace(parts[4]) + + var body string + if len(parts) > 5 { + body = strings.TrimSpace(parts[5]) + } + + var date time.Time + if dateStr != "" { + date, _ = time.Parse(time.RFC3339, dateStr) + } + + commits = append(commits, CommitRaw{ + Hash: hash, + ShortHash: shortHash, + Author: author, + Date: date, + Subject: subject, + Body: body, + }) + } + + return commits +} + +// ssh pattern: git@host:user/repo.git +var sshURLRegexp = regexp.MustCompile(`^[\w-]+@([\w.-]+):([\w./-]+?)(?:\.git)?$`) + +// normalizeRemoteURL converts git remote URL to HTTPS URL +func normalizeRemoteURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + + // handle SSH URLs: git@github.com:user/repo.git + if matches := sshURLRegexp.FindStringSubmatch(rawURL); matches != nil { + return "https://" + matches[1] + "/" + matches[2] + } + + // handle HTTPS URLs: remove .git suffix + parsed, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + parsed.Path = strings.TrimSuffix(parsed.Path, ".git") + + // ensure https + if parsed.Scheme == "http" { + parsed.Scheme = "https" + } + + // remove any userinfo + parsed.User = nil + + return parsed.String() +} + +// HostType represents the type of git hosting service +type HostType string + +const ( + HostGitHub HostType = "github" + HostGitLab HostType = "gitlab" + HostBitbucket HostType = "bitbucket" + HostAzure HostType = "azure" + HostGeneric HostType = "generic" +) + +// DetectHostType detects the hosting service from a URL +func DetectHostType(repoURL string) HostType { + lower := strings.ToLower(repoURL) + + switch { + case strings.Contains(lower, "github.com"): + return HostGitHub + case strings.Contains(lower, "gitlab.com") || strings.Contains(lower, "gitlab"): + return HostGitLab + case strings.Contains(lower, "bitbucket.org") || strings.Contains(lower, "bitbucket"): + return HostBitbucket + case strings.Contains(lower, "dev.azure.com") || strings.Contains(lower, "visualstudio.com"): + return HostAzure + default: + return HostGeneric + } +} + +// InferCommitURL returns the commit URL template for the given host +func InferCommitURL(repoURL string, hostType HostType) string { + switch hostType { + case HostGitLab: + return repoURL + "/-/commit/{{hash}}" + case HostBitbucket: + return repoURL + "/commits/{{hash}}" + case HostAzure: + return repoURL + "#/commit/{{hash}}" + default: // GitHub, generic + return repoURL + "/commit/{{hash}}" + } +} + +// InferCompareURL returns the compare URL template for the given host +func InferCompareURL(repoURL string, hostType HostType) string { + switch hostType { + case HostGitLab: + return repoURL + "/-/compare/{{from}}...{{to}}" + case HostBitbucket: + return repoURL + "/compare/{{from}}..{{to}}" + case HostAzure: + return repoURL + "#/compare?head=true&sourceBranch={{to}}&targetBranch={{from}}" + default: // GitHub, generic + return repoURL + "/compare/{{from}}...{{to}}" + } +} + +// BuildCommitURL replaces templates in commit URL +func BuildCommitURL(tmpl, hash string) string { + if tmpl == "" { + return "" + } + return strings.ReplaceAll(tmpl, "{{hash}}", hash) +} + +// BuildCompareURL replaces templates in compare URL +func BuildCompareURL(tmpl, from, to string) string { + if tmpl == "" { + return "" + } + r := strings.ReplaceAll(tmpl, "{{from}}", from) + r = strings.ReplaceAll(r, "{{to}}", to) + return r +} + +// ExtractReferences extracts issue references from a commit message +func ExtractReferences(msg string, prefixes []string) []string { + if len(prefixes) == 0 { + return nil + } + + var refs []string + seen := make(map[string]struct{}) + + for _, prefix := range prefixes { + pattern := regexp.QuoteMeta(prefix) + `(\d+)` + re, err := regexp.Compile(pattern) + if err != nil { + continue + } + + matches := re.FindAllStringSubmatch(msg, -1) + for _, m := range matches { + ref := prefix + m[1] + if _, ok := seen[ref]; !ok { + refs = append(refs, ref) + seen[ref] = struct{}{} + } + } + } + + return refs +} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..495d08a --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,210 @@ +package git + +import ( + "testing" +) + +func TestNormalizeRemoteURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "github ssh", + input: "git@github.com:user/repo.git", + expected: "https://github.com/user/repo", + }, + { + name: "github https with .git", + input: "https://github.com/user/repo.git", + expected: "https://github.com/user/repo", + }, + { + name: "github https without .git", + input: "https://github.com/user/repo", + expected: "https://github.com/user/repo", + }, + { + name: "gitlab ssh", + input: "git@gitlab.com:group/project.git", + expected: "https://gitlab.com/group/project", + }, + { + name: "bitbucket ssh", + input: "git@bitbucket.org:team/repo.git", + expected: "https://bitbucket.org/team/repo", + }, + { + name: "http to https", + input: "http://github.com/user/repo.git", + expected: "https://github.com/user/repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := normalizeRemoteURL(tt.input) + if result != tt.expected { + t.Errorf("normalizeRemoteURL(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestDetectHostType(t *testing.T) { + tests := []struct { + url string + expected HostType + }{ + {"https://github.com/user/repo", HostGitHub}, + {"https://gitlab.com/group/project", HostGitLab}, + {"https://bitbucket.org/team/repo", HostBitbucket}, + {"https://dev.azure.com/org/project", HostAzure}, + {"https://visualstudio.com/org/project", HostAzure}, + {"https://custom.example.com/repo", HostGeneric}, + } + + for _, tt := range tests { + t.Run(string(tt.expected), func(t *testing.T) { + result := DetectHostType(tt.url) + if result != tt.expected { + t.Errorf("detectHostType(%q) = %q, want %q", tt.url, result, tt.expected) + } + }) + } +} + +func TestInferCommitURL(t *testing.T) { + base := "https://github.com/user/repo" + result := InferCommitURL(base, HostGitHub) + expected := "https://github.com/user/repo/commit/{{hash}}" + if result != expected { + t.Errorf("inferCommitURL() = %q, want %q", result, expected) + } + + result = InferCommitURL("https://gitlab.com/g/p", HostGitLab) + expected = "https://gitlab.com/g/p/-/commit/{{hash}}" + if result != expected { + t.Errorf("inferCommitURL() = %q, want %q", result, expected) + } +} + +func TestInferCompareURL(t *testing.T) { + result := InferCompareURL("https://github.com/u/r", HostGitHub) + expected := "https://github.com/u/r/compare/{{from}}...{{to}}" + if result != expected { + t.Errorf("inferCompareURL() = %q, want %q", result, expected) + } + + result = InferCompareURL("https://dev.azure.com/o/p", HostAzure) + expected = "https://dev.azure.com/o/p#/compare?head=true&sourceBranch={{to}}&targetBranch={{from}}" + if result != expected { + t.Errorf("inferCompareURL() = %q, want %q", result, expected) + } +} + +func TestBuildCommitURL(t *testing.T) { + tmpl := "https://github.com/user/repo/commit/{{hash}}" + result := BuildCommitURL(tmpl, "abc123") + expected := "https://github.com/user/repo/commit/abc123" + if result != expected { + t.Errorf("buildCommitURL() = %q, want %q", result, expected) + } + + // empty template + result = BuildCommitURL("", "abc123") + if result != "" { + t.Errorf("buildCommitURL empty template = %q, want empty", result) + } +} + +func TestBuildCompareURL(t *testing.T) { + tmpl := "https://github.com/user/repo/compare/{{from}}...{{to}}" + result := BuildCompareURL(tmpl, "v1.0.0", "v2.0.0") + expected := "https://github.com/user/repo/compare/v1.0.0...v2.0.0" + if result != expected { + t.Errorf("buildCompareURL() = %q, want %q", result, expected) + } +} + +func TestParseGitLog(t *testing.T) { + output := commitSep + "\nabc123full" + fieldSep + "abc1234" + fieldSep + "John Doe" + fieldSep + "2025-01-15T10:00:00+00:00" + fieldSep + "feat: add feature" + fieldSep + "some body" + + commits := parseGitLog(output) + if len(commits) != 1 { + t.Fatalf("expected 1 commit, got %d", len(commits)) + } + + c := commits[0] + if c.Hash != "abc123full" { + t.Errorf("hash = %q, want %q", c.Hash, "abc123full") + } + if c.ShortHash != "abc1234" { + t.Errorf("shortHash = %q, want %q", c.ShortHash, "abc1234") + } + if c.Author != "John Doe" { + t.Errorf("author = %q, want %q", c.Author, "John Doe") + } + if c.Subject != "feat: add feature" { + t.Errorf("subject = %q, want %q", c.Subject, "feat: add feature") + } + if c.Body != "some body" { + t.Errorf("body = %q, want %q", c.Body, "some body") + } +} + +func TestExtractReferences(t *testing.T) { + tests := []struct { + name string + msg string + prefixes []string + expected []string + }{ + { + name: "single reference", + msg: "fix: resolve issue #42", + prefixes: []string{"#"}, + expected: []string{"#42"}, + }, + { + name: "multiple references", + msg: "fix: resolve #42 and #100", + prefixes: []string{"#"}, + expected: []string{"#42", "#100"}, + }, + { + name: "no references", + msg: "fix: resolve issue", + prefixes: []string{"#"}, + expected: nil, + }, + { + name: "custom prefix", + msg: "fix: resolve JIRA-123", + prefixes: []string{"JIRA-"}, + expected: []string{"JIRA-123"}, + }, + { + name: "no duplicates", + msg: "fix: resolve #42 and #42", + prefixes: []string{"#"}, + expected: []string{"#42"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + refs := ExtractReferences(tt.msg, tt.prefixes) + if len(refs) != len(tt.expected) { + t.Errorf("extractReferences() returned %d refs, want %d", len(refs), len(tt.expected)) + return + } + for i, ref := range refs { + if ref != tt.expected[i] { + t.Errorf("ref[%d] = %q, want %q", i, ref, tt.expected[i]) + } + } + }) + } +} diff --git a/registry/registry.go b/registry/registry.go index 593c51d..8ac3437 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -7,8 +7,10 @@ import ( "fmt" "sync" + "github.com/conventionalcommit/commitlint/changelog" + chgfmt "github.com/conventionalcommit/commitlint/changelog/formatter" "github.com/conventionalcommit/commitlint/lint" - "github.com/conventionalcommit/commitlint/lint/formatter" + lintfmt "github.com/conventionalcommit/commitlint/lint/formatter" "github.com/conventionalcommit/commitlint/lint/rule" ) @@ -46,11 +48,28 @@ func Formatters() []lint.Formatter { return globalRegistry.Formatters() } +// RegisterChangelogFormatter registers a custom changelog formatter. +// Returns an error if a formatter with the same name is already registered. +func RegisterChangelogFormatter(format changelog.Formatter) error { + return globalRegistry.RegisterChangelogFormatter(format) +} + +// GetChangelogFormatter returns the changelog Formatter registered under name, and whether it was found. +func GetChangelogFormatter(name string) (changelog.Formatter, bool) { + return globalRegistry.GetChangelogFormatter(name) +} + +// ChangelogFormatters returns all registered changelog formatters. +func ChangelogFormatters() []changelog.Formatter { + return globalRegistry.ChangelogFormatters() +} + type registry struct { mut *sync.Mutex - allRules map[string]lint.Rule - allFormatters map[string]lint.Formatter + allRules map[string]lint.Rule + allFormatters map[string]lint.Formatter + allChangelogFormatters map[string]changelog.Formatter } func newRegistry() *registry { @@ -95,15 +114,21 @@ func newRegistry() *registry { } defaultFormatters := []lint.Formatter{ - &formatter.DefaultFormatter{}, - &formatter.JSONFormatter{}, + &lintfmt.DefaultFormatter{}, + &lintfmt.JSONFormatter{}, + } + + defaultChangelogFormatters := []changelog.Formatter{ + &chgfmt.MarkdownFormatter{}, + &chgfmt.JSONFormatter{}, } reg := ®istry{ mut: &sync.Mutex{}, - allRules: make(map[string]lint.Rule), - allFormatters: make(map[string]lint.Formatter), + allRules: make(map[string]lint.Rule), + allFormatters: make(map[string]lint.Formatter), + allChangelogFormatters: make(map[string]changelog.Formatter), } // Register Default Rules @@ -124,6 +149,14 @@ func newRegistry() *registry { } } + // Register Default Changelog Formatters + for _, format := range defaultChangelogFormatters { + err := reg.RegisterChangelogFormatter(format) + if err != nil { + panic(err) + } + } + return reg } @@ -182,6 +215,39 @@ func (reg *registry) Formatters() []lint.Formatter { return allFormats } +func (reg *registry) RegisterChangelogFormatter(format changelog.Formatter) error { + reg.mut.Lock() + defer reg.mut.Unlock() + + _, ok := reg.allChangelogFormatters[format.Name()] + if ok { + return fmt.Errorf("'%s' changelog formatter already registered", format.Name()) + } + + reg.allChangelogFormatters[format.Name()] = format + + return nil +} + +func (reg *registry) GetChangelogFormatter(name string) (changelog.Formatter, bool) { + reg.mut.Lock() + defer reg.mut.Unlock() + + format, ok := reg.allChangelogFormatters[name] + return format, ok +} + +func (reg *registry) ChangelogFormatters() []changelog.Formatter { + reg.mut.Lock() + defer reg.mut.Unlock() + + allFormats := make([]changelog.Formatter, 0, len(reg.allChangelogFormatters)) + for _, f := range reg.allChangelogFormatters { + allFormats = append(allFormats, f) + } + return allFormats +} + func (reg *registry) Rules() []lint.Rule { reg.mut.Lock() defer reg.mut.Unlock() diff --git a/test/config_test.go b/test/config_test.go index 7ce2f16..2331172 100644 --- a/test/config_test.go +++ b/test/config_test.go @@ -4,14 +4,16 @@ import ( "bytes" "os" "path/filepath" + "strings" "testing" + "github.com/conventionalcommit/commitlint/changelog" "github.com/conventionalcommit/commitlint/config" "github.com/conventionalcommit/commitlint/lint" ) func TestConfig_NewDefault(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint if conf.MinVersion == "" { t.Error("expected non-empty MinVersion") @@ -41,7 +43,7 @@ func TestConfig_NewDefault(t *testing.T) { func TestConfig_NewLinter(t *testing.T) { conf := config.NewDefault() - linter, err := config.NewLinter(conf) + linter, err := config.NewLinter(conf.Lint) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -52,7 +54,7 @@ func TestConfig_NewLinter(t *testing.T) { func TestConfig_GetFormatter(t *testing.T) { conf := config.NewDefault() - f, err := config.GetFormatter(conf) + f, err := config.GetFormatter(conf.Lint) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -66,8 +68,8 @@ func TestConfig_GetFormatter(t *testing.T) { func TestConfig_GetFormatterJSON(t *testing.T) { conf := config.NewDefault() - conf.Formatter = "json" - f, err := config.GetFormatter(conf) + conf.Lint.Formatter = "json" + f, err := config.GetFormatter(conf.Lint) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -78,8 +80,8 @@ func TestConfig_GetFormatterJSON(t *testing.T) { func TestConfig_GetFormatterUnknown(t *testing.T) { conf := config.NewDefault() - conf.Formatter = "unknown" - _, err := config.GetFormatter(conf) + conf.Lint.Formatter = "unknown" + _, err := config.GetFormatter(conf.Lint) if err == nil { t.Error("expected error for unknown formatter") } @@ -87,7 +89,7 @@ func TestConfig_GetFormatterUnknown(t *testing.T) { func TestConfig_Validate_Valid(t *testing.T) { conf := config.NewDefault() - errs := config.Validate(conf) + errs := config.ValidateLint(conf.Lint) if len(errs) != 0 { t.Errorf("expected no validation errors, got %d:", len(errs)) for _, e := range errs { @@ -98,8 +100,8 @@ func TestConfig_Validate_Valid(t *testing.T) { func TestConfig_Validate_InvalidFormatter(t *testing.T) { conf := config.NewDefault() - conf.Formatter = "nonexistent" - errs := config.Validate(conf) + conf.Lint.Formatter = "nonexistent" + errs := config.ValidateLint(conf.Lint) if len(errs) == 0 { t.Error("expected validation errors for unknown formatter") } @@ -107,8 +109,8 @@ func TestConfig_Validate_InvalidFormatter(t *testing.T) { func TestConfig_Validate_EmptyFormatter(t *testing.T) { conf := config.NewDefault() - conf.Formatter = "" - errs := config.Validate(conf) + conf.Lint.Formatter = "" + errs := config.ValidateLint(conf.Lint) if len(errs) == 0 { t.Error("expected validation errors for empty formatter") } @@ -116,8 +118,8 @@ func TestConfig_Validate_EmptyFormatter(t *testing.T) { func TestConfig_Validate_InvalidSeverity(t *testing.T) { conf := config.NewDefault() - conf.Severity.Default = "invalid" - errs := config.Validate(conf) + conf.Lint.Severity.Default = "invalid" + errs := config.ValidateLint(conf.Lint) if len(errs) == 0 { t.Error("expected validation errors for invalid severity") } @@ -125,10 +127,10 @@ func TestConfig_Validate_InvalidSeverity(t *testing.T) { func TestConfig_Validate_InvalidRuleSeverity(t *testing.T) { conf := config.NewDefault() - conf.Severity.Rules = map[string]lint.Severity{ + conf.Lint.Severity.Rules = map[string]lint.Severity{ "type-enum": "invalid-severity", } - errs := config.Validate(conf) + errs := config.ValidateLint(conf.Lint) if len(errs) == 0 { t.Error("expected validation errors for invalid rule severity") } @@ -136,8 +138,8 @@ func TestConfig_Validate_InvalidRuleSeverity(t *testing.T) { func TestConfig_Validate_UnknownRule(t *testing.T) { conf := config.NewDefault() - conf.Rules = append(conf.Rules, "nonexistent-rule") - errs := config.Validate(conf) + conf.Lint.Rules = append(conf.Lint.Rules, "nonexistent-rule") + errs := config.ValidateLint(conf.Lint) if len(errs) == 0 { t.Error("expected validation errors for unknown rule") } @@ -145,8 +147,8 @@ func TestConfig_Validate_UnknownRule(t *testing.T) { func TestConfig_Validate_InvalidIgnorePattern(t *testing.T) { conf := config.NewDefault() - conf.IgnorePatterns = []string{`[invalid`} - errs := config.Validate(conf) + conf.Lint.IgnorePatterns = []string{`[invalid`} + errs := config.ValidateLint(conf.Lint) if len(errs) == 0 { t.Error("expected validation errors for invalid ignore pattern") } @@ -154,8 +156,8 @@ func TestConfig_Validate_InvalidIgnorePattern(t *testing.T) { func TestConfig_Validate_ValidIgnorePattern(t *testing.T) { conf := config.NewDefault() - conf.IgnorePatterns = []string{`^Merge .*`} - errs := config.Validate(conf) + conf.Lint.IgnorePatterns = []string{`^Merge .*`} + errs := config.ValidateLint(conf.Lint) for _, e := range errs { t.Errorf("unexpected validation error: %v", e) } @@ -184,11 +186,24 @@ func TestConfig_WriteCompactTo(t *testing.T) { t.Errorf("should contain enabled rule setting %q", enabled) } } + + // Should not contain hidden changelog types (e.g. chore, style are hidden by default) + for _, hidden := range []string{"chore", "style", "refactor", "test", "build", "ci", "revert"} { + if bytes.Contains(buf.Bytes(), []byte("type: "+hidden)) { + t.Errorf("compact output should not contain hidden type %q", hidden) + } + } + // Visible types should still be present + for _, visible := range []string{"feat", "fix", "docs", "perf"} { + if !bytes.Contains(buf.Bytes(), []byte("type: "+visible)) { + t.Errorf("compact output should contain visible type %q", visible) + } + } } func TestConfig_WriteCompactTo_WithUserIgnores(t *testing.T) { conf := config.NewDefault() - conf.IgnorePatterns = []string{`^WIP `} + conf.Lint.IgnorePatterns = []string{`^WIP `} var buf bytes.Buffer err := config.WriteCompactTo(&buf, conf) if err != nil { @@ -203,23 +218,24 @@ func TestConfig_Parse_ValidFile(t *testing.T) { tmpDir := t.TempDir() confPath := filepath.Join(tmpDir, "commitlint.yaml") - confContent := `min-version: v0.9.0 -formatter: default -rules: - - header-min-length - - header-max-length - - type-enum -severity: - default: error -settings: - header-min-length: - argument: 10 - header-max-length: - argument: 50 - type-enum: - argument: - - feat - - fix + confContent := `lint: + min-version: v0.9.0 + formatter: default + rules: + - header-min-length + - header-max-length + - type-enum + severity: + default: error + settings: + header-min-length: + argument: 10 + header-max-length: + argument: 50 + type-enum: + argument: + - feat + - fix ` err := os.WriteFile(confPath, []byte(confContent), 0o644) if err != nil { @@ -231,11 +247,11 @@ settings: t.Fatalf("unexpected error: %v", err) } - if conf.Formatter != "default" { - t.Errorf("expected formatter 'default', got %q", conf.Formatter) + if conf.Lint.Formatter != "default" { + t.Errorf("expected formatter 'default', got %q", conf.Lint.Formatter) } - if len(conf.Rules) != 3 { - t.Errorf("expected 3 rules, got %d", len(conf.Rules)) + if len(conf.Lint.Rules) != 3 { + t.Errorf("expected 3 rules, got %d", len(conf.Lint.Rules)) } } @@ -244,15 +260,16 @@ func TestConfig_Parse_OldVersionKey(t *testing.T) { confPath := filepath.Join(tmpDir, "commitlint.yaml") // Uses the old "version:" key for backward compatibility - confContent := `version: v0.9.0 -formatter: default -rules: - - header-min-length -severity: - default: error -settings: - header-min-length: - argument: 10 + confContent := `lint: + version: v0.9.0 + formatter: default + rules: + - header-min-length + severity: + default: error + settings: + header-min-length: + argument: 10 ` err := os.WriteFile(confPath, []byte(confContent), 0o644) if err != nil { @@ -264,8 +281,8 @@ settings: t.Fatalf("unexpected error parsing old 'version' key: %v", err) } - if conf.MinVersion != "v0.9.0" { - t.Errorf("expected MinVersion 'v0.9.0', got %q", conf.MinVersion) + if conf.Lint.MinVersion != "v0.9.0" { + t.Errorf("expected MinVersion 'v0.9.0', got %q", conf.Lint.MinVersion) } } @@ -273,18 +290,19 @@ func TestConfig_Parse_WithIgnores(t *testing.T) { tmpDir := t.TempDir() confPath := filepath.Join(tmpDir, "commitlint.yaml") - confContent := `min-version: v0.9.0 -formatter: default -rules: - - header-min-length -severity: - default: error -settings: - header-min-length: - argument: 10 -ignores: - - "^WIP " - - "^TICKET-\\d+" + confContent := `lint: + min-version: v0.9.0 + formatter: default + rules: + - header-min-length + severity: + default: error + settings: + header-min-length: + argument: 10 + ignores: + - "^WIP " + - "^TICKET-\\d+" ` err := os.WriteFile(confPath, []byte(confContent), 0o644) if err != nil { @@ -296,8 +314,8 @@ ignores: t.Fatalf("unexpected error: %v", err) } - if len(conf.IgnorePatterns) != 2 { - t.Errorf("expected 2 ignore patterns, got %d", len(conf.IgnorePatterns)) + if len(conf.Lint.IgnorePatterns) != 2 { + t.Errorf("expected 2 ignore patterns, got %d", len(conf.Lint.IgnorePatterns)) } } @@ -305,15 +323,16 @@ func TestConfig_Parse_WithoutIgnores_UsesDefaults(t *testing.T) { tmpDir := t.TempDir() confPath := filepath.Join(tmpDir, "commitlint.yaml") - confContent := `min-version: v0.9.0 -formatter: default -rules: - - header-min-length -severity: - default: error -settings: - header-min-length: - argument: 10 + confContent := `lint: + min-version: v0.9.0 + formatter: default + rules: + - header-min-length + severity: + default: error + settings: + header-min-length: + argument: 10 ` err := os.WriteFile(confPath, []byte(confContent), 0o644) if err != nil { @@ -325,10 +344,10 @@ settings: t.Fatalf("unexpected error: %v", err) } - if len(conf.DefaultIgnorePatterns) == 0 { + if len(conf.Lint.DefaultIgnorePatterns) == 0 { t.Error("expected default ignore patterns to be populated by Parse") } - if len(conf.IgnorePatterns) != 0 { + if len(conf.Lint.IgnorePatterns) != 0 { t.Error("expected empty user ignore patterns when not specified in config") } } @@ -337,18 +356,19 @@ func TestConfig_Parse_DisableDefaultIgnores(t *testing.T) { tmpDir := t.TempDir() confPath := filepath.Join(tmpDir, "commitlint.yaml") - confContent := `min-version: v0.9.0 -formatter: default -rules: - - header-min-length -severity: - default: error -settings: - header-min-length: - argument: 10 -disable-default-ignores: true -ignores: - - "^WIP " + confContent := `lint: + min-version: v0.9.0 + formatter: default + rules: + - header-min-length + severity: + default: error + settings: + header-min-length: + argument: 10 + disable-default-ignores: true + ignores: + - "^WIP " ` err := os.WriteFile(confPath, []byte(confContent), 0o644) if err != nil { @@ -360,13 +380,13 @@ ignores: t.Fatalf("unexpected error: %v", err) } - if !conf.DisableDefaultIgnores { + if !conf.Lint.DisableDefaultIgnores { t.Error("expected DisableDefaultIgnores to be true") } - if len(conf.IgnorePatterns) != 1 { - t.Errorf("expected 1 user ignore pattern, got %d", len(conf.IgnorePatterns)) + if len(conf.Lint.IgnorePatterns) != 1 { + t.Errorf("expected 1 user ignore pattern, got %d", len(conf.Lint.IgnorePatterns)) } - effective := conf.EffectiveIgnorePatterns() + effective := conf.Lint.EffectiveIgnorePatterns() if len(effective) != 1 { t.Errorf("expected 1 effective pattern (defaults disabled), got %d", len(effective)) } @@ -395,7 +415,7 @@ func TestConfig_Parse_InvalidYAML(t *testing.T) { } func TestConfig_GetEnabledRules(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint rules, err := config.GetEnabledRules(conf) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -406,7 +426,7 @@ func TestConfig_GetEnabledRules(t *testing.T) { } func TestConfig_GetEnabledRules_DuplicateRules(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.Rules = append(conf.Rules, conf.Rules[0]) rules, err := config.GetEnabledRules(conf) @@ -419,7 +439,7 @@ func TestConfig_GetEnabledRules_DuplicateRules(t *testing.T) { } func TestConfig_GetEnabledRules_UnknownRule(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.Rules = []string{"nonexistent-rule"} _, err := config.GetEnabledRules(conf) @@ -445,3 +465,332 @@ func TestConfig_SeverityString(t *testing.T) { } } } + +// --- Old config format detection --- + +func TestConfig_Parse_OldFlatConfig(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + // Old pre-v0.12.0 flat config (no "lint:" wrapper) + confContent := `formatter: default +rules: + - header-min-length + - type-enum +severity: + default: error +settings: + header-min-length: + argument: 10 + type-enum: + argument: + - feat + - fix +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + _, err = config.Parse(confPath) + if err == nil { + t.Fatal("expected error for old flat config format") + } + if !strings.Contains(err.Error(), "pre-v0.12.0") { + t.Errorf("expected migration hint in error, got: %v", err) + } + if !strings.Contains(err.Error(), "migration.md") { + t.Errorf("expected migration.md link in error, got: %v", err) + } +} + +// --- Changelog defaults in Parse --- + +func TestConfig_Parse_ChangelogDefaultsApplied(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + // Config with only lint section, no changelog + confContent := `lint: + min-version: v0.9.0 + formatter: default + rules: + - header-min-length + severity: + default: error + settings: + header-min-length: + argument: 10 +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + conf, err := config.Parse(confPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if conf.Changelog == nil { + t.Fatal("expected non-nil changelog config") + } + if conf.Changelog.Formatter != "markdown" { + t.Errorf("expected default changelog formatter 'markdown', got %q", conf.Changelog.Formatter) + } + if conf.Changelog.Header != "# Changelog" { + t.Errorf("expected default changelog header, got %q", conf.Changelog.Header) + } + if len(conf.Changelog.Types) == 0 { + t.Error("expected default changelog types") + } + if len(conf.Changelog.IssuePrefixes) == 0 { + t.Error("expected default issue prefixes") + } +} + +func TestConfig_Parse_PartialChangelog(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + // Config with partial changelog (only formatter) + confContent := `lint: + min-version: v0.9.0 + formatter: default + rules: + - header-min-length + severity: + default: error + settings: + header-min-length: + argument: 10 +changelog: + formatter: json +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + conf, err := config.Parse(confPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Explicit value should be kept + if conf.Changelog.Formatter != "json" { + t.Errorf("expected changelog formatter 'json', got %q", conf.Changelog.Formatter) + } + // Missing fields should get defaults + if conf.Changelog.Header != "# Changelog" { + t.Errorf("expected default changelog header, got %q", conf.Changelog.Header) + } + if len(conf.Changelog.Types) == 0 { + t.Error("expected default changelog types when not specified") + } +} + +// --- WriteTo / WriteCompactTo with nil --- + +func TestConfig_WriteTo_NilChangelog(t *testing.T) { + conf := &config.Config{ + Lint: config.NewDefaultLint(), + Changelog: nil, + } + var buf bytes.Buffer + err := config.WriteTo(&buf, conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if buf.Len() == 0 { + t.Error("expected non-empty output") + } + // Should contain changelog section from defaults + if !bytes.Contains(buf.Bytes(), []byte("changelog:")) { + t.Error("expected 'changelog:' section in output") + } +} + +func TestConfig_WriteCompactTo_NilChangelog(t *testing.T) { + conf := &config.Config{ + Lint: config.NewDefaultLint(), + Changelog: nil, + } + var buf bytes.Buffer + err := config.WriteCompactTo(&buf, conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if buf.Len() == 0 { + t.Error("expected non-empty output") + } +} + +func TestConfig_WriteTo_NilLint(t *testing.T) { + conf := &config.Config{ + Lint: nil, + Changelog: config.NewDefaultChangelog(), + } + var buf bytes.Buffer + err := config.WriteTo(&buf, conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte("lint:")) { + t.Error("expected 'lint:' section in output") + } +} + +// --- Validate with nil --- + +func TestConfig_Validate_NilLint(t *testing.T) { + conf := &config.Config{ + Lint: nil, + Changelog: config.NewDefaultChangelog(), + } + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation error for nil lint config") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "lint config is nil") { + found = true + } + } + if !found { + t.Error("expected 'lint config is nil' error") + } +} + +func TestConfig_Validate_NilChangelog(t *testing.T) { + conf := &config.Config{ + Lint: config.NewDefaultLint(), + Changelog: nil, + } + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation error for nil changelog config") + } + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "changelog config is nil") { + found = true + } + } + if !found { + t.Error("expected 'changelog config is nil' error") + } +} + +func TestConfig_Validate_Full(t *testing.T) { + conf := config.NewDefault() + errs := config.Validate(conf) + if len(errs) != 0 { + t.Errorf("expected no validation errors for default config, got %d:", len(errs)) + for _, e := range errs { + t.Errorf(" - %v", e) + } + } +} + +// --- ValidateChangelog --- + +func TestConfig_ValidateChangelog_Valid(t *testing.T) { + conf := config.NewDefaultChangelog() + errs := config.ValidateChangelog(conf) + if len(errs) != 0 { + t.Errorf("expected no errors, got %d", len(errs)) + } +} + +func TestConfig_ValidateChangelog_EmptyFormatter(t *testing.T) { + conf := config.NewDefaultChangelog() + conf.Formatter = "" + errs := config.ValidateChangelog(conf) + if len(errs) == 0 { + t.Error("expected error for empty formatter") + } +} + +func TestConfig_ValidateChangelog_UnknownFormatter(t *testing.T) { + conf := config.NewDefaultChangelog() + conf.Formatter = "nonexistent" + errs := config.ValidateChangelog(conf) + if len(errs) == 0 { + t.Error("expected error for unknown formatter") + } +} + +func TestConfig_ValidateChangelog_EmptyTypes(t *testing.T) { + conf := config.NewDefaultChangelog() + conf.Types = nil + errs := config.ValidateChangelog(conf) + if len(errs) == 0 { + t.Error("expected error for empty types") + } +} + +func TestConfig_ValidateChangelog_DuplicateType(t *testing.T) { + conf := config.NewDefaultChangelog() + conf.Types = append(conf.Types, conf.Types[0]) // duplicate first type + errs := config.ValidateChangelog(conf) + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "duplicate") { + found = true + } + } + if !found { + t.Error("expected duplicate type error") + } +} + +func TestConfig_ValidateChangelog_EmptyTypeField(t *testing.T) { + conf := config.NewDefaultChangelog() + conf.Types = append(conf.Types, changelog.TypeConfig{Type: "", Header: "Empty"}) + errs := config.ValidateChangelog(conf) + found := false + for _, e := range errs { + if strings.Contains(e.Error(), "empty type field") { + found = true + } + } + if !found { + t.Error("expected 'empty type field' error") + } +} + +// --- GetChangelogFormatter --- + +func TestConfig_GetChangelogFormatter(t *testing.T) { + conf := config.NewDefaultChangelog() + f, err := config.GetChangelogFormatter(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f == nil { + t.Fatal("expected non-nil formatter") + } + if f.Name() != "markdown" { + t.Errorf("expected 'markdown', got %q", f.Name()) + } +} + +func TestConfig_GetChangelogFormatter_Unknown(t *testing.T) { + conf := config.NewDefaultChangelog() + conf.Formatter = "nonexistent" + _, err := config.GetChangelogFormatter(conf) + if err == nil { + t.Error("expected error for unknown formatter") + } +} + +func TestConfig_GetChangelogFormatter_Empty(t *testing.T) { + conf := config.NewDefaultChangelog() + conf.Formatter = "" + _, err := config.GetChangelogFormatter(conf) + if err == nil { + t.Error("expected error for empty formatter") + } +} diff --git a/test/helpers_test.go b/test/helpers_test.go index d9380b7..af2313d 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -42,7 +42,7 @@ func (n *mockNote) Value() string { return n.value } // newDefaultLinter creates a linter with default config for testing func newDefaultLinter(t *testing.T) *lint.Linter { t.Helper() - conf := config.NewDefault() + conf := config.NewDefault().Lint rules, err := config.GetEnabledRules(conf) if err != nil { t.Fatalf("failed to get enabled rules: %v", err) diff --git a/test/ignore_test.go b/test/ignore_test.go index 99a5b99..55b5b99 100644 --- a/test/ignore_test.go +++ b/test/ignore_test.go @@ -165,7 +165,7 @@ func TestIgnore_NotIgnored(t *testing.T) { } func TestIgnore_EmptyPatterns_NoSkip(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.DisableDefaultIgnores = true conf.IgnorePatterns = []string{} @@ -188,7 +188,7 @@ func TestIgnore_EmptyPatterns_NoSkip(t *testing.T) { } func TestIgnore_CustomPatternsAdditive(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.IgnorePatterns = []string{`^CUSTOM-\d+`, `^WIP `} rules, err := config.GetEnabledRules(conf) @@ -229,7 +229,7 @@ func TestIgnore_CustomPatternsAdditive(t *testing.T) { } func TestIgnore_CustomPatternsOnlyWhenDefaultsDisabled(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.DisableDefaultIgnores = true conf.IgnorePatterns = []string{`^CUSTOM-\d+`} @@ -262,7 +262,7 @@ func TestIgnore_CustomPatternsOnlyWhenDefaultsDisabled(t *testing.T) { } func TestIgnore_InvalidPattern_LinterCreationFails(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.IgnorePatterns = []string{`^valid`, `[invalid`} rules, err := config.GetEnabledRules(conf) @@ -277,10 +277,10 @@ func TestIgnore_InvalidPattern_LinterCreationFails(t *testing.T) { } func TestIgnore_ValidationCatchesInvalidPattern(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.IgnorePatterns = []string{`[invalid`} - errs := config.Validate(conf) + errs := config.ValidateLint(conf) found := false for _, e := range errs { if e != nil { @@ -301,13 +301,13 @@ func TestIgnore_DefaultPatternsExist(t *testing.T) { func TestIgnore_DefaultConfigHasPatterns(t *testing.T) { conf := config.NewDefault() - if len(conf.DefaultIgnorePatterns) == 0 { + if len(conf.Lint.DefaultIgnorePatterns) == 0 { t.Fatal("expected default config to have default ignore patterns") } } func TestIgnore_EffectiveIgnorePatterns(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint // Default: no user patterns, defaults enabled effective := conf.EffectiveIgnorePatterns() diff --git a/test/lint_test.go b/test/lint_test.go index a084b48..167e8d8 100644 --- a/test/lint_test.go +++ b/test/lint_test.go @@ -207,7 +207,7 @@ func TestLint_DefaultSeverityIsError(t *testing.T) { } func TestLint_CustomWarningSeverity(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint conf.Severity.Rules = map[string]lint.Severity{ "type-enum": lint.SeverityWarn, } @@ -232,7 +232,7 @@ func TestLint_CustomWarningSeverity(t *testing.T) { } func TestLint_ParserErrorAlwaysError(t *testing.T) { - conf := config.NewDefault() + conf := config.NewDefault().Lint // Even with all rules set to warn, parser errors should be SeverityError conf.Severity.Default = lint.SeverityWarn rules, err := config.GetEnabledRules(conf)