Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customizable commit type and scope #5

Merged
merged 7 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,40 @@ 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: `<type>(<scope>)<modifier>`

- `<type>`: The commit type, e.g. `feat`, `fix`, `refactor`.
- `<scope>`: The commit scope, e.g. `lang`, `config`. If left empty, the rule matches all scopes (`*`).
- `<modifier>`: 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)

## Licence

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)
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 25 additions & 0 deletions pkg/analyzer/commit.go
Original file line number Diff line number Diff line change
@@ -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],
}
}
81 changes: 40 additions & 41 deletions pkg/analyzer/commit_analyzer.go
Original file line number Diff line number Diff line change
@@ -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
}

Expand All @@ -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,
Expand All @@ -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
}

Expand Down
121 changes: 106 additions & 15 deletions pkg/analyzer/commit_analyzer_test.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
package analyzer

import (
"fmt"
"strings"
"testing"

"github.com/go-semantic-release/semantic-release/v2/pkg/semrel"
"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,
Expand All @@ -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)
Expand Down Expand Up @@ -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")
})
}

}
}
Loading
Loading