diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07a81ae..15ec132 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: - go-version: '1.21' + go-version: '1.22' - uses: golangci/golangci-lint-action@v3 build: runs-on: ${{ matrix.os }} @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: - go-version: '1.21' + go-version: '1.22' - run: go build ./cmd/commit-analyzer-cz/ - run: go test -v ./... release: @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: - go-version: '1.21' + go-version: '1.22' - uses: go-semantic-release/action@v1 with: hooks: goreleaser,plugin-registry-update diff --git a/LICENSE b/LICENSE index cd92284..9d0524d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2020 Christoph Witzko +Copyright (c) 2024 Christoph Witzko Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 70468bf..4f1679e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,35 @@ A [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) analyze Refs #133 ``` +## Customizable Release Rules +It is possible to customize the release rules by providing options to the analyzer. The following options are available: + +| Option | Default | +|-----------------------|---------| +| `major_release_rules` | `*!` | +| `minor_release_rules` | `feat` | +| `patch_release_rules` | `fix` | + +⚠️ Commits that contain `BREAKING CHANGE(S)` in their body will always result in a major release. This behavior cannot be customized yet. + +### Rule Syntax +A rule may match a specific commit type, scope or both. The following syntax is supported: `()` + +- ``: The commit type, e.g. `feat`, `fix`, `refactor`. +- ``: The commit scope, e.g. `lang`, `config`. If left empty, the rule matches all scopes (`*`). +- ``: The modifier, e.g. `!` for breaking changes. If left empty, the rule matches only commits without a modifier. +- A `*` may be used as a wildcard for a type, scope or modifier. + +### Example Rules +| Commit | `feat` (or `feat(*)` | `*!` (or `*(*)!`) | `chore(deps)` | `*🚀` | +|------------------------------------|----------------------|-------------------|---------------|-------| +| `feat(ui): add button component` | ✅ | ❌ | ❌ | ❌ | +| `feat!: drop support for Go 1.17` | ❌ | ✅ | ❌ | ❌ | +| `chore(deps): update dependencies` | ❌ | ❌ | ✅ | ❌ | +| `refactor: remove unused code` | ❌ | ❌ | ❌ | ❌ | +| `fix🚀: correct minor typos` | ❌ | ❌ | ❌ | ✅ | + + ## References - [Conventional Commit v1.0.0 - Examples](https://www.conventionalcommits.org/en/v1.0.0/#examples) @@ -45,4 +74,4 @@ A [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) analyze The [MIT License (MIT)](http://opensource.org/licenses/MIT) -Copyright © 2020 [Christoph Witzko](https://twitter.com/christophwitzko) +Copyright © 2024 [Christoph Witzko](https://twitter.com/christophwitzko) diff --git a/go.mod b/go.mod index 62d51d8..6659948 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/go-semantic-release/commit-analyzer-cz -go 1.21 +go 1.22 require ( github.com/go-semantic-release/semantic-release/v2 v2.28.0 diff --git a/pkg/analyzer/commit.go b/pkg/analyzer/commit.go new file mode 100644 index 0000000..7a070ce --- /dev/null +++ b/pkg/analyzer/commit.go @@ -0,0 +1,25 @@ +package analyzer + +import "strings" + +type parsedCommit struct { + Type string + Scope string + Modifier string + Message string +} + +func parseCommit(msg string) *parsedCommit { + found := commitPattern.FindAllStringSubmatch(msg, -1) + if len(found) < 1 { + // commit message does not match pattern + return nil + } + + return &parsedCommit{ + Type: strings.ToLower(found[0][1]), + Scope: found[0][2], + Modifier: found[0][3], + Message: found[0][4], + } +} diff --git a/pkg/analyzer/commit_analyzer.go b/pkg/analyzer/commit_analyzer.go index 04a294b..7667f19 100644 --- a/pkg/analyzer/commit_analyzer.go +++ b/pkg/analyzer/commit_analyzer.go @@ -1,31 +1,35 @@ package analyzer import ( - "regexp" + "cmp" + "fmt" "strings" "github.com/go-semantic-release/semantic-release/v2/pkg/semrel" ) -var ( - CAVERSION = "dev" - commitPattern = regexp.MustCompile(`^(\w*)(?:\((.*)\))?(\!)?\: (.*)$`) - breakingPattern = regexp.MustCompile("BREAKING CHANGES?") - mentionedIssuesPattern = regexp.MustCompile(`#(\d+)`) - mentionedUsersPattern = regexp.MustCompile(`(?i)@([a-z\d]([a-z\d]|-[a-z\d])+)`) -) +var CAVERSION = "dev" -func extractMentions(re *regexp.Regexp, s string) string { - ret := make([]string, 0) - for _, m := range re.FindAllStringSubmatch(s, -1) { - ret = append(ret, m[1]) - } - return strings.Join(ret, ",") +type DefaultCommitAnalyzer struct { + majorReleaseRules releaseRules + minorReleaseRules releaseRules + patchReleaseRules releaseRules } -type DefaultCommitAnalyzer struct{} - -func (da *DefaultCommitAnalyzer) Init(_ map[string]string) error { +func (da *DefaultCommitAnalyzer) Init(m map[string]string) error { + var err error + da.majorReleaseRules, err = parseRules(cmp.Or(m["major_release_rules"], defaultMajorReleaseRules)) + if err != nil { + return fmt.Errorf("failed to parse major release rules: %w", err) + } + da.minorReleaseRules, err = parseRules(cmp.Or(m["minor_release_rules"], defaultMinorReleaseRules)) + if err != nil { + return fmt.Errorf("failed to parse minor release rules: %w", err) + } + da.patchReleaseRules, err = parseRules(cmp.Or(m["patch_release_rules"], defaultPatchReleaseRules)) + if err != nil { + return fmt.Errorf("failed to parse patch release rules: %w", err) + } return nil } @@ -37,6 +41,24 @@ func (da *DefaultCommitAnalyzer) Version() string { return CAVERSION } +func (da *DefaultCommitAnalyzer) setTypeAndChange(c *semrel.Commit) { + pc := parseCommit(c.Raw[0]) + if pc == nil { + return + } + + c.Type = pc.Type + c.Scope = pc.Scope + c.Message = pc.Message + + c.Change = &semrel.Change{ + // either matches the major release rule or has a breaking change section + Major: da.majorReleaseRules.Matches(pc) || matchesBreakingPattern(c), + Minor: da.minorReleaseRules.Matches(pc), + Patch: da.patchReleaseRules.Matches(pc), + } +} + func (da *DefaultCommitAnalyzer) analyzeSingleCommit(rawCommit *semrel.RawCommit) *semrel.Commit { c := &semrel.Commit{ SHA: rawCommit.SHA, @@ -47,30 +69,7 @@ func (da *DefaultCommitAnalyzer) analyzeSingleCommit(rawCommit *semrel.RawCommit c.Annotations["mentioned_issues"] = extractMentions(mentionedIssuesPattern, rawCommit.RawMessage) c.Annotations["mentioned_users"] = extractMentions(mentionedUsersPattern, rawCommit.RawMessage) - found := commitPattern.FindAllStringSubmatch(c.Raw[0], -1) - if len(found) < 1 { - return c - } - c.Type = strings.ToLower(found[0][1]) - c.Scope = found[0][2] - breakingChange := found[0][3] - c.Message = found[0][4] - - isMajorChange := breakingPattern.MatchString(rawCommit.RawMessage) - isMinorChange := c.Type == "feat" - isPatchChange := c.Type == "fix" - - if len(breakingChange) > 0 { - isMajorChange = true - isMinorChange = false - isPatchChange = false - } - - c.Change = &semrel.Change{ - Major: isMajorChange, - Minor: isMinorChange, - Patch: isPatchChange, - } + da.setTypeAndChange(c) return c } diff --git a/pkg/analyzer/commit_analyzer_test.go b/pkg/analyzer/commit_analyzer_test.go index d08276f..6cb92a6 100644 --- a/pkg/analyzer/commit_analyzer_test.go +++ b/pkg/analyzer/commit_analyzer_test.go @@ -1,7 +1,6 @@ package analyzer import ( - "fmt" "strings" "testing" @@ -9,18 +8,6 @@ import ( "github.com/stretchr/testify/require" ) -func compareCommit(c *semrel.Commit, t, s string, change *semrel.Change) bool { - if c.Type != t || c.Scope != s { - return false - } - if c.Change.Major != change.Major || - c.Change.Minor != change.Minor || - c.Change.Patch != change.Patch { - return false - } - return true -} - func createRawCommit(sha, message string) *semrel.RawCommit { return &semrel.RawCommit{ SHA: sha, @@ -33,6 +20,7 @@ func createRawCommit(sha, message string) *semrel.RawCommit { func TestAnnotations(t *testing.T) { defaultAnalyzer := &DefaultCommitAnalyzer{} + require.NoError(t, defaultAnalyzer.Init(map[string]string{})) rawCommit := createRawCommit("a", "fix: bug #123 and #243\nthanks @Test-user for providing this fix\n\nCloses #22") commit := defaultAnalyzer.analyzeSingleCommit(rawCommit) require.Equal(t, rawCommit.SHA, commit.SHA) @@ -103,12 +91,115 @@ func TestDefaultAnalyzer(t *testing.T) { "", &semrel.Change{Major: false, Minor: false, Patch: false}, }, + { + createRawCommit("i", "feat(deps): update deps\n\nBREAKING CHANGE: update to new version of dep"), + "feat", + "deps", + &semrel.Change{Major: true, Minor: true, Patch: false}, + }, } defaultAnalyzer := &DefaultCommitAnalyzer{} + require.NoError(t, defaultAnalyzer.Init(map[string]string{})) for _, tc := range testCases { - t.Run(fmt.Sprintf("AnalyzeCommitMessage: %s", tc.RawCommit.RawMessage), func(t *testing.T) { - require.True(t, compareCommit(defaultAnalyzer.analyzeSingleCommit(tc.RawCommit), tc.Type, tc.Scope, tc.Change)) + t.Run(tc.RawCommit.RawMessage, func(t *testing.T) { + analyzedCommit := defaultAnalyzer.analyzeSingleCommit(tc.RawCommit) + require.Equal(t, tc.Type, analyzedCommit.Type, "Type") + require.Equal(t, tc.Scope, analyzedCommit.Scope, "Scope") + require.Equal(t, tc.Change.Major, analyzedCommit.Change.Major, "Major") + require.Equal(t, tc.Change.Minor, analyzedCommit.Change.Minor, "Minor") + require.Equal(t, tc.Change.Patch, analyzedCommit.Change.Patch, "Patch") }) } } + +func TestReleaseRules(t *testing.T) { + type commits []struct { + RawCommit *semrel.RawCommit + Change *semrel.Change + } + testCases := []struct { + Config map[string]string + Commits commits + }{ + { + Config: map[string]string{ + "major_release_rules": "feat", + "minor_release_rules": "feat", + "patch_release_rules": "feat", + }, + Commits: commits{ + { + RawCommit: createRawCommit("a", "feat: new feature"), + Change: &semrel.Change{Major: true, Minor: true, Patch: true}, + }, + { + RawCommit: createRawCommit("a", "docs: new feature"), + Change: &semrel.Change{Major: false, Minor: false, Patch: false}, + }, + { + RawCommit: createRawCommit("a", "feat!: new feature"), + Change: &semrel.Change{Major: false, Minor: false, Patch: false}, + }, + { + RawCommit: createRawCommit("a", "feat(api): new feature"), + Change: &semrel.Change{Major: true, Minor: true, Patch: true}, + }, + { + RawCommit: createRawCommit("a", "feat(api)!: new feature"), + Change: &semrel.Change{Major: false, Minor: false, Patch: false}, + }, + }, + }, + { + Config: map[string]string{ + "major_release_rules": "*!", + "minor_release_rules": "feat,chore(deps)", + "patch_release_rules": "fix", + }, + Commits: commits{ + { + RawCommit: createRawCommit("a", "feat: new feature"), + Change: &semrel.Change{Major: false, Minor: true, Patch: false}, + }, + { + RawCommit: createRawCommit("a", "docs: new feature"), + Change: &semrel.Change{Major: false, Minor: false, Patch: false}, + }, + { + RawCommit: createRawCommit("a", "docs!: new feature"), + Change: &semrel.Change{Major: true, Minor: false, Patch: false}, + }, + { + RawCommit: createRawCommit("a", "chore(deps): update dependencies"), + Change: &semrel.Change{Major: false, Minor: true, Patch: false}, + }, + { + RawCommit: createRawCommit("a", "chore: cleanup"), + Change: &semrel.Change{Major: false, Minor: false, Patch: false}, + }, + { + RawCommit: createRawCommit("a", "fix: bug #123"), + Change: &semrel.Change{Major: false, Minor: false, Patch: true}, + }, + { + RawCommit: createRawCommit("a", "fix!: bug #123"), + Change: &semrel.Change{Major: true, Minor: false, Patch: false}, + }, + }, + }, + } + for _, tc := range testCases { + defaultAnalyzer := &DefaultCommitAnalyzer{} + require.NoError(t, defaultAnalyzer.Init(tc.Config)) + for _, commit := range tc.Commits { + t.Run(commit.RawCommit.RawMessage, func(t *testing.T) { + analyzedCommit := defaultAnalyzer.analyzeSingleCommit(commit.RawCommit) + require.Equal(t, commit.Change.Major, analyzedCommit.Change.Major, "Major") + require.Equal(t, commit.Change.Minor, analyzedCommit.Change.Minor, "Minor") + require.Equal(t, commit.Change.Patch, analyzedCommit.Change.Patch, "Patch") + }) + } + + } +} diff --git a/pkg/analyzer/commit_test.go b/pkg/analyzer/commit_test.go new file mode 100644 index 0000000..bb9814d --- /dev/null +++ b/pkg/analyzer/commit_test.go @@ -0,0 +1,82 @@ +package analyzer + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCommit(t *testing.T) { + testCases := []struct { + message string + wanted *parsedCommit + }{ + { + message: "feat: new feature", + wanted: &parsedCommit{"feat", "", "", "new feature"}, + }, + { + message: "feat!: new feature", + wanted: &parsedCommit{"feat", "", "!", "new feature"}, + }, + { + message: "feat(api): new feature", + wanted: &parsedCommit{"feat", "api", "", "new feature"}, + }, + { + message: "feat(api): a(b): c:", + wanted: &parsedCommit{"feat", "api", "", "a(b): c:"}, + }, + { + message: "feat(new cool-api): feature", + wanted: &parsedCommit{"feat", "new cool-api", "", "feature"}, + }, + { + message: "feat(😅): cool", + wanted: &parsedCommit{"feat", "😅", "", "cool"}, + }, + { + message: "this-is-also(valid): cool", + wanted: &parsedCommit{"this-is-also", "valid", "", "cool"}, + }, + { + message: "feat((x)): test", + wanted: &parsedCommit{"feat", "(x", ")", "test"}, + }, + { + message: "feat(x)?!: test", + wanted: &parsedCommit{"feat", "x", "?!", "test"}, + }, + { + message: "feat(x): test", + wanted: &parsedCommit{"feat", "x", "", "test"}, + }, + { + message: "feat(x): : test", + wanted: &parsedCommit{"feat", "x", "", ": test"}, + }, + { + message: "feat!: test", + wanted: &parsedCommit{"feat", "", "!", "test"}, + }, + // invalid messages + { + message: "feat (new api): feature", + wanted: nil, + }, + { + message: "feat:test", + wanted: nil, + }, + { + message: "🚀(🦄): emojis!", + wanted: nil, + }, + } + for _, tc := range testCases { + t.Run(tc.message, func(t *testing.T) { + c := parseCommit(tc.message) + require.Equal(t, tc.wanted, c) + }) + } +} diff --git a/pkg/analyzer/patterns.go b/pkg/analyzer/patterns.go new file mode 100644 index 0000000..60174f2 --- /dev/null +++ b/pkg/analyzer/patterns.go @@ -0,0 +1,28 @@ +package analyzer + +import ( + "regexp" + "strings" + + "github.com/go-semantic-release/semantic-release/v2/pkg/semrel" +) + +var ( + releaseRulePattern = regexp.MustCompile(`^([\w-\*]+)(?:\(([^\)]*)\))?(\S*)$`) + commitPattern = regexp.MustCompile(`^([\w-]+)(?:\(([^\)]*)\))?(\S*)\: (.*)$`) + breakingPattern = regexp.MustCompile("BREAKING CHANGES?") + mentionedIssuesPattern = regexp.MustCompile(`#(\d+)`) + mentionedUsersPattern = regexp.MustCompile(`(?i)@([a-z\d]([a-z\d]|-[a-z\d])+)`) +) + +func extractMentions(re *regexp.Regexp, s string) string { + ret := make([]string, 0) + for _, m := range re.FindAllStringSubmatch(s, -1) { + ret = append(ret, m[1]) + } + return strings.Join(ret, ",") +} + +func matchesBreakingPattern(c *semrel.Commit) bool { + return breakingPattern.MatchString(strings.Join(c.Raw, "\n")) +} diff --git a/pkg/analyzer/patterns_test.go b/pkg/analyzer/patterns_test.go new file mode 100644 index 0000000..bcfe23c --- /dev/null +++ b/pkg/analyzer/patterns_test.go @@ -0,0 +1,51 @@ +package analyzer + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractIssues(t *testing.T) { + testCases := []struct { + message string + wanted string + }{ + { + message: "feat: new feature #123", + wanted: "123", + }, + { + message: "feat!: new feature closes #123 and #456", + wanted: "123,456", + }, + } + for _, testCase := range testCases { + t.Run(testCase.message, func(t *testing.T) { + issues := extractMentions(mentionedIssuesPattern, testCase.message) + require.Equal(t, testCase.wanted, issues) + }) + } +} + +func TestExtractMentions(t *testing.T) { + testCases := []struct { + message string + wanted string + }{ + { + message: "feat: new feature by @user", + wanted: "user", + }, + { + message: "feat!: new feature by @user and @user-2", + wanted: "user,user-2", + }, + } + for _, testCase := range testCases { + t.Run(testCase.message, func(t *testing.T) { + issues := extractMentions(mentionedUsersPattern, testCase.message) + require.Equal(t, testCase.wanted, issues) + }) + } +} diff --git a/pkg/analyzer/rules.go b/pkg/analyzer/rules.go new file mode 100644 index 0000000..aa94d9b --- /dev/null +++ b/pkg/analyzer/rules.go @@ -0,0 +1,77 @@ +package analyzer + +import ( + "cmp" + "fmt" + "strings" +) + +var ( + defaultMajorReleaseRules = "*!" + defaultMinorReleaseRules = "feat" + defaultPatchReleaseRules = "fix" +) + +type releaseRule struct { + Type string + Scope string + Modifier string +} + +func (r *releaseRule) String() string { + return fmt.Sprintf("%s(%s)%s", r.Type, r.Scope, r.Modifier) +} + +func (r *releaseRule) Matches(commit *parsedCommit) bool { + return (r.Type == "*" || r.Type == commit.Type) && + (r.Scope == "*" || r.Scope == commit.Scope) && + (r.Modifier == "*" || r.Modifier == commit.Modifier) +} + +func parseRule(rule string) (*releaseRule, error) { + foundRule := releaseRulePattern.FindAllStringSubmatch(rule, -1) + if len(foundRule) < 1 { + return nil, fmt.Errorf("cannot parse rule: %s", rule) + } + return &releaseRule{ + Type: strings.ToLower(foundRule[0][1]), + // undefined scope defaults to * + Scope: cmp.Or(foundRule[0][2], "*"), + Modifier: foundRule[0][3], + }, nil +} + +type releaseRules []*releaseRule + +func (r releaseRules) String() string { + ret := make([]string, len(r)) + for i, rule := range r { + ret[i] = rule.String() + } + return strings.Join(ret, ",") +} + +func (r releaseRules) Matches(commit *parsedCommit) bool { + for _, rule := range r { + if rule.Matches(commit) { + return true + } + } + return false +} + +func parseRules(rules string) (releaseRules, error) { + if rules == "" { + return nil, fmt.Errorf("no rules provided") + } + ruleStrings := strings.Split(rules, ",") + ret := make(releaseRules, len(ruleStrings)) + for i, r := range ruleStrings { + parsed, err := parseRule(r) + if err != nil { + return nil, err + } + ret[i] = parsed + } + return ret, nil +} diff --git a/pkg/analyzer/rules_test.go b/pkg/analyzer/rules_test.go new file mode 100644 index 0000000..a6ab98e --- /dev/null +++ b/pkg/analyzer/rules_test.go @@ -0,0 +1,62 @@ +package analyzer + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseRule(t *testing.T) { + testCases := []struct { + rule string + wanted *releaseRule + }{ + { + rule: "feat", + wanted: &releaseRule{Type: "feat", Scope: "*", Modifier: ""}, + }, + { + rule: "feat(api)", + wanted: &releaseRule{Type: "feat", Scope: "api", Modifier: ""}, + }, + { + rule: "feat(*)!", + wanted: &releaseRule{Type: "feat", Scope: "*", Modifier: "!"}, + }, + { + rule: "feat(api)!", + wanted: &releaseRule{Type: "feat", Scope: "api", Modifier: "!"}, + }, + { + rule: "*(*)!", + wanted: &releaseRule{Type: "*", Scope: "*", Modifier: "!"}, + }, + { + rule: "*(*)*", + wanted: &releaseRule{Type: "*", Scope: "*", Modifier: "*"}, + }, + { + rule: "*", + wanted: &releaseRule{Type: "*", Scope: "*", Modifier: ""}, + }, + { + rule: "*!", + wanted: &releaseRule{Type: "*", Scope: "*", Modifier: "!"}, + }, + { + rule: "x!", + wanted: &releaseRule{Type: "x", Scope: "*", Modifier: "!"}, + }, + { + rule: "x🦄", + wanted: &releaseRule{Type: "x", Scope: "*", Modifier: "🦄"}, + }, + } + for _, tc := range testCases { + t.Run(tc.rule, func(t *testing.T) { + r, err := parseRule(tc.rule) + require.NoError(t, err) + require.Equal(t, tc.wanted, r) + }) + } +}