diff --git a/README.md b/README.md index 71dcad1..0e104a8 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,70 @@ refactor!: drop support for Node 6 If no keywords are specified a **Patch** bump is applied. +### Scheme: Custom (YAML) + +A user-defined scheme can be supplied via a YAML file using the `--scheme-file` flag. This is +useful when neither the autotag nor Conventional Commits scheme fits your project's commit +conventions. + +`--scheme-file` is mutually exclusive with an explicitly non-default `--scheme`: pass one or the +other. Ready-to-copy reference schemes are available under +[`examples/schemes/`](examples/schemes/). + +#### YAML schema + +```yaml +name: my-custom-scheme # required: identifier used in logs +description: Optional summary. # optional: free-form + +# Rules are evaluated in declaration order. The first rule whose regex matches +# the commit message determines the bump. Remaining rules are not consulted. +rules: + - name: breaking-footer # optional: shown in validation errors + match: '(?m)^BREAKING CHANGE:' + bump: major + - name: feat + match: '^feat(\([^)]*\))?:' + bump: minor + - name: patch-types + match: '^(fix|chore|docs|refactor)(\([^)]*\))?:' + bump: patch + +# Required. What to do when no rule matches a commit. +# Allowed values: major | minor | patch | none +default: patch +``` + +- `match` is a Go [regexp](https://pkg.go.dev/regexp/syntax) pattern. +- `bump` and `default` must be one of `major`, `minor`, `patch`, or `none`. +- A rule with `bump: none` matches the commit but contributes no version change — useful for + explicitly ignoring commits (e.g., merge commits) without falling through to the default. +- `default: none` combined with `--strict-match` will cause unmatched commits to error out instead + of being silently ignored. + +All validation (regex compilation, allowed bump values, required fields, unknown keys) happens at +load time, so a malformed scheme fails fast with a clear error before any commits are parsed. + +#### Example + +Given a scheme file `./my-scheme.yaml`: + +```yaml +name: my-scheme +rules: + - match: '(?m)^BREAKING CHANGE:' + bump: major + - match: '^feat:' + bump: minor +default: patch +``` + +Invoke autotag with: + +```sh +autotag --scheme-file ./my-scheme.yaml +``` + ### Strict Match Option The `--strict-match` option enforces that commit messages must strictly adhere to the specified commit message scheme. @@ -164,6 +228,13 @@ When the `--strict-match` option is enabled, the behavior of the commit message - With `--strict-match`: Commit messages that do not follow the conventional commit format will result in an error, and the commit will not be processed. +- **Custom (YAML) Scheme**: + - Without `--strict-match`: Unmatched commits use the scheme's `default` value. When `default: + none`, those commits contribute no version change. + - With `--strict-match`: Unmatched commits (no rule matches **and** the scheme declares `default: + none`) result in an error. If the scheme's `default` is `major`, `minor`, or `patch`, the + fallback is always taken and strict-match never fires. + #### Usage To use the strict match option, add the `--strict-match` flag when running the autotag tool: diff --git a/autotag.go b/autotag.go index 07eecfa..c3c5f0e 100644 --- a/autotag.go +++ b/autotag.go @@ -123,6 +123,11 @@ type GitRepoConfig struct { // * https://www.conventionalcommits.org/en/v1.0.0/#summary w Scheme string + // SchemeFile is an optional path to a YAML file defining a custom scheme. + // When set, it takes precedence over Scheme. Passing a non-default Scheme + // alongside SchemeFile is an error. + SchemeFile string + // Prefix prepends literal 'v' to the tag, eg: v1.0.0. Enabled by default Prefix bool @@ -153,7 +158,7 @@ type GitRepo struct { preReleaseTimestampLayout string buildMetadata string - scheme string + scheme Scheme strictMatch bool prefix bool @@ -209,13 +214,18 @@ func NewRepo(cfg GitRepoConfig) (*GitRepo, error) { } } + scheme, err := resolveScheme(cfg) + if err != nil { + return nil, err + } + r := &GitRepo{ repo: repo, branch: cfg.Branch, preReleaseName: cfg.PreReleaseName, preReleaseTimestampLayout: cfg.PreReleaseTimestampLayout, buildMetadata: cfg.BuildMetadata, - scheme: cfg.Scheme, + scheme: scheme, prefix: cfg.Prefix, strictMatch: cfg.StrictMatch, buildNumber: cfg.BuildNumber, @@ -524,82 +534,41 @@ func (r *GitRepo) tagNewVersion() error { // parseCommit looks at HEAD commit see if we want to increment major/minor/patch func (r *GitRepo) parseCommit(commit *git.Commit) (*version.Version, error) { - var b bumper msg := commit.Message log.Printf("Parsing %s: %s\n", commit.ID, msg) - switch r.scheme { - case "conventional": - b = parseConventionalCommit(msg, r.strictMatch) - case "", "autotag": - b = parseAutotagCommit(msg) - } + b := r.scheme.ParseCommit(msg) if r.strictMatch && b == nil { return nil, fmt.Errorf("no match found for commit %s", commit.ID) } - // fallback to patch bump if no matches from the scheme parsers if b != nil { return b.bump(r.currentVersion) } - return nil, nil } -// parseAutotagCommit implements the autotag (default) commit scheme. -// A git commit message header containing: -// - [major] or #major: major version bump -// - [minor] or #minor: minor version bump -// - [patch] or #patch: patch version bump -// -// If no action is present nil is returned and the caller must decide what action to take. -func parseAutotagCommit(msg string) bumper { - if majorRex.MatchString(msg) { - log.Println("major bump") - return majorBumper - } - - if minorRex.MatchString(msg) { - log.Println("minor bump") - return minorBumper - } - - if patchRex.MatchString(msg) { - log.Println("patch bump") - return patchBumper - } - - return nil -} - -// parseConventionalCommit implements the Conventional Commit scheme. Given a commit message -// A strict match option will enforce that the commit message must match the conventional commit -// it will return the correct version bumper. In the case of non-confirming conventional commit -// it will return nil and the caller will decide what action to take. -// https://www.conventionalcommits.org/en/v1.0.0/#summary -func parseConventionalCommit(msg string, strictMatch bool) bumper { - matches := findNamedMatches(conventionalCommitRex, msg) - - // If we're in strict match and no matches are found, return nil - bumperType, authorized := conventionalCommitAuthorizedTypes[matches["type"]] - if strictMatch && !authorized { - return nil - } - - // If the commit contains a footer with 'BREAKING CHANGE:' it is always a major bump - if strings.Contains(msg, "\nBREAKING CHANGE:") { - return majorBumper +// resolveScheme maps the user-facing GitRepoConfig fields to a Scheme +// implementation. When SchemeFile is set, it is loaded and returned; the +// built-in Scheme string must then be empty or the default "autotag" (the +// CLI's default value), otherwise the two are considered mutually exclusive. +// Unknown built-in scheme names are an error. +func resolveScheme(cfg GitRepoConfig) (Scheme, error) { + if cfg.SchemeFile != "" { + if cfg.Scheme != "" && cfg.Scheme != "autotag" { + return nil, fmt.Errorf("--scheme and --scheme-file are mutually exclusive") + } + return LoadSchemeFile(cfg.SchemeFile) } - - // If the type/scope in the header includes a trailing '!' this is a breaking change - if breaking, ok := matches["breaking"]; ok && breaking == "!" { - return majorBumper + switch cfg.Scheme { + case "", "autotag": + return autotagScheme{}, nil + case "conventional": + return conventionalScheme{strictMatch: cfg.StrictMatch}, nil + default: + return nil, fmt.Errorf("unknown scheme %q", cfg.Scheme) } - - // If the type in the header match a type try to find it in the authorized list - // If it's not in the list it returns nil - return bumperType } // MajorBump will bump the version one major rev 1.0.0 -> 2.0.0 diff --git a/autotag/main.go b/autotag/main.go index a988a0f..07b6ed7 100644 --- a/autotag/main.go +++ b/autotag/main.go @@ -20,6 +20,7 @@ type Options struct { PreReleaseTimestamp string `short:"T" long:"pre-release-timestamp" description:"create a pre-release tag and append a timestamp (can be: datetime|epoch)"` BuildMetadata string `short:"m" long:"build-metadata" description:"optional SemVer build metadata to append to the version with '+' character"` Scheme string `short:"s" long:"scheme" description:"The commit message scheme to use (can be: autotag|conventional)" default:"autotag"` + SchemeFile string `long:"scheme-file" description:"Path to a YAML file defining a custom scheme (mutually exclusive with --scheme)"` NoVersionPrefix bool `short:"e" long:"empty-version-prefix" description:"Do not prepend v to version tag"` StrictMatch bool `long:"strict-match" description:"Enforce strict mode on the scheme parsers, returns error if no match is found"` BuildNumber bool `long:"build-number" description:"Enforce append build number in metadata (after '+' character), returns error if metadata is not a unsigned integer or empty"` @@ -48,6 +49,7 @@ func main() { PreReleaseTimestampLayout: opts.PreReleaseTimestamp, BuildMetadata: opts.BuildMetadata, Scheme: opts.Scheme, + SchemeFile: opts.SchemeFile, Prefix: !opts.NoVersionPrefix, StrictMatch: opts.StrictMatch, BuildNumber: opts.BuildNumber, diff --git a/autotag_test.go b/autotag_test.go index 556c61c..deccc51 100644 --- a/autotag_test.go +++ b/autotag_test.go @@ -22,6 +22,10 @@ type testRepoSetup struct { // (optional) versioning scheme to use, eg: "" or "autotag", "conventional". If not set, defaults to "" (autotag) scheme string + // (optional) path to a YAML file defining a custom scheme. When set, takes precedence over scheme + // (unless scheme is also explicitly set to a non-default value, in which case NewRepo returns an error). + schemeFile string + // (optional) branch to create. If not set, defaults to "master" branch string @@ -105,6 +109,7 @@ func newTestRepo(t *testing.T, setup testRepoSetup) (GitRepo, error) { PreReleaseTimestampLayout: setup.preReleaseTimestampLayout, BuildMetadata: setup.buildMetadata, Scheme: setup.scheme, + SchemeFile: setup.schemeFile, Prefix: !setup.disablePrefix, StrictMatch: setup.strictMatch, BuildNumber: setup.buildNumber, @@ -345,6 +350,17 @@ func TestNewRepoStrictMatch(t *testing.T) { strictMatch: true, }, }, + + // tests for custom YAML schemes + { + name: "custom scheme with default none, no match fails with strict match", + setup: testRepoSetup{ + schemeFile: "testdata/schemes/autotag_clone.yaml", + initialTag: "v1.0.0", + nextCommit: "random commit with no marker", + strictMatch: true, + }, + }, } for _, tc := range tests { @@ -355,6 +371,24 @@ func TestNewRepoStrictMatch(t *testing.T) { } } +func TestNewRepoSchemeMutuallyExclusive(t *testing.T) { + _, err := newTestRepo(t, testRepoSetup{ + scheme: "conventional", + schemeFile: "testdata/schemes/valid_minimal.yaml", + initialTag: "v1.0.0", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} + +func TestNewRepoSchemeFileLoadError(t *testing.T) { + _, err := newTestRepo(t, testRepoSetup{ + schemeFile: "testdata/schemes/does_not_exist.yaml", + initialTag: "v1.0.0", + }) + assert.Error(t, err) +} + func TestMajor(t *testing.T) { r, err := newTestRepo(t, testRepoSetup{ branch: "master", @@ -786,6 +820,53 @@ func TestAutoTag(t *testing.T) { }, expectedTag: "v0.10.0", }, + + // tests for custom YAML schemes + { + name: "custom scheme, rule match bumps minor", + setup: testRepoSetup{ + schemeFile: "testdata/schemes/valid_minimal.yaml", + nextCommit: "feat: add thing", + initialTag: "v1.0.0", + }, + expectedTag: "v1.1.0", + }, + { + name: "custom scheme, no match falls back to default patch", + setup: testRepoSetup{ + schemeFile: "testdata/schemes/valid_minimal.yaml", + nextCommit: "random commit message", + initialTag: "v1.0.0", + }, + expectedTag: "v1.0.1", + }, + { + name: "custom scheme, autotag_clone replicates built-in autotag", + setup: testRepoSetup{ + schemeFile: "testdata/schemes/autotag_clone.yaml", + commitList: []string{ + "[minor] thing 1", + "[major] break thing 1", + "[minor] thing 2", + }, + initialTag: "v1.0.0", + }, + expectedTag: "v2.0.0", + }, + { + name: "custom scheme, conventional_clone replicates built-in conventional", + setup: testRepoSetup{ + schemeFile: "testdata/schemes/conventional_clone.yaml", + commitList: []string{ + "feat: thing 1", + "feat!: break thing 1", + "feat: thing 2", + "refactor(runtime)!: drop support for Node 6", + }, + initialTag: "v1.0.0", + }, + expectedTag: "v2.0.0", + }, } for _, tc := range tests { diff --git a/examples/schemes/autotag.yaml b/examples/schemes/autotag.yaml new file mode 100644 index 0000000..071ff75 --- /dev/null +++ b/examples/schemes/autotag.yaml @@ -0,0 +1,20 @@ +# Reproduces the built-in "autotag" scheme as a --scheme-file. +# Use this as a starting point when customizing the bracket/hash keyword style. +name: autotag +description: Bump based on [major]/[minor]/[patch] or #major/#minor/#patch markers. + +rules: + - name: major + match: '(?i)\[major\]|#major' + bump: major + - name: minor + match: '(?i)\[minor\]|#minor' + bump: minor + - name: patch + match: '(?i)\[patch\]|#patch' + bump: patch + +# The built-in autotag scheme falls back to a patch bump when no marker is +# present. Use `default: none` if you'd rather unmatched commits contribute +# nothing (combine with --strict-match to turn them into errors). +default: patch diff --git a/examples/schemes/conventional.yaml b/examples/schemes/conventional.yaml new file mode 100644 index 0000000..be3d1e9 --- /dev/null +++ b/examples/schemes/conventional.yaml @@ -0,0 +1,32 @@ +# Reproduces the built-in "conventional" scheme (in non-strict mode) as a +# --scheme-file. Use this as a starting point when customizing which commit +# types bump which version segment. +name: conventional +description: Conventional Commits v1.0.0 — https://www.conventionalcommits.org/en/v1.0.0/ + +rules: + # A footer starting with "BREAKING CHANGE:" anywhere in the commit body + # forces a major bump, even when the header type is unknown. + - name: breaking-footer + match: '(?m)^BREAKING CHANGE:' + bump: major + + # A trailing '!' after the type or type(scope) also marks a breaking change. + - name: breaking-bang + match: '^\w+(\([^)]*\))?!:' + bump: major + + # A new feature — minor bump. + - name: feat + match: '^feat(\([^)]*\))?:' + bump: minor + + # The remaining well-known types all map to a patch bump. + - name: patch-types + match: '^(fix|build|chore|ci|docs|perf|refactor|revert|style|test)(\([^)]*\))?:' + bump: patch + +# Commits that don't match any rule contribute no version change. This matches +# the built-in conventional scheme's behavior: non-conforming commits are +# silently ignored unless --strict-match is enabled, in which case they error. +default: none diff --git a/go.mod b/go.mod index 0788b13..9f99608 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gogs/git-module v1.8.5 github.com/hashicorp/go-version v1.8.0 github.com/jessevdk/go-flags v1.6.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index df12409..45e258f 100644 --- a/go.sum +++ b/go.sum @@ -2,40 +2,27 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gogs/git-module v1.8.4 h1:oSt8sOL4NWOGrSo/CwbS+C4YXtk76QvxyPofem/ViTU= -github.com/gogs/git-module v1.8.4/go.mod h1:bQY0aoMK5Q5+NKgy4jXe3K1GFW+GnsSk0SJK0jh6yD0= github.com/gogs/git-module v1.8.5 h1:+H1KWQYqdQcbwso6ip5r6EaqQHjUwc9eCnyQ/0v3Q5k= github.com/gogs/git-module v1.8.5/go.mod h1:WwWjbU0HQJgQUimNcUHxT06zFEHkb/KC9Vx7Xdxa3Ag= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= -github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2 h1:YocNLcTBdEdvY3iDK6jfWXvEaM5OCKkjxPKoJRdB3Gg= github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scheme.go b/scheme.go new file mode 100644 index 0000000..a8feac3 --- /dev/null +++ b/scheme.go @@ -0,0 +1,10 @@ +package autotag + +// Scheme decides, for a given commit message, what kind of version bump the +// commit implies. A nil return value means the commit does not match any rule +// defined by the scheme; the caller decides what to do (e.g. fall back to a +// patch bump, or error when running in strict mode). +type Scheme interface { + Name() string + ParseCommit(msg string) bumper +} diff --git a/scheme_builtin.go b/scheme_builtin.go new file mode 100644 index 0000000..93f2ba3 --- /dev/null +++ b/scheme_builtin.go @@ -0,0 +1,63 @@ +package autotag + +import ( + "log" + "strings" +) + +// autotagScheme implements the default autotag commit message scheme: +// - [major] or #major -> major bump +// - [minor] or #minor -> minor bump +// - [patch] or #patch -> patch bump +// +// Anything else returns nil; callers fall back to a patch bump by default, or +// error when strict matching is enabled. +type autotagScheme struct{} + +func (autotagScheme) Name() string { return "autotag" } + +func (autotagScheme) ParseCommit(msg string) bumper { + if majorRex.MatchString(msg) { + log.Println("major bump") + return majorBumper + } + if minorRex.MatchString(msg) { + log.Println("minor bump") + return minorBumper + } + if patchRex.MatchString(msg) { + log.Println("patch bump") + return patchBumper + } + return nil +} + +// conventionalScheme implements the Conventional Commits v1.0.0 scheme. +// https://www.conventionalcommits.org/en/v1.0.0/#summary +// +// When strictMatch is true, commits whose type is not in the authorized +// list short-circuit to nil — even if they carry a breaking-change marker +// — so the caller can surface "no match found" rather than inferring a +// bump from an otherwise-unrecognised commit. +type conventionalScheme struct { + strictMatch bool +} + +func (conventionalScheme) Name() string { return "conventional" } + +func (c conventionalScheme) ParseCommit(msg string) bumper { + matches := findNamedMatches(conventionalCommitRex, msg) + + bumperType, authorized := conventionalCommitAuthorizedTypes[matches["type"]] + if c.strictMatch && !authorized { + return nil + } + + if strings.Contains(msg, "\nBREAKING CHANGE:") { + return majorBumper + } + if breaking, ok := matches["breaking"]; ok && breaking == "!" { + return majorBumper + } + return bumperType +} diff --git a/scheme_custom.go b/scheme_custom.go new file mode 100644 index 0000000..b8909bd --- /dev/null +++ b/scheme_custom.go @@ -0,0 +1,141 @@ +package autotag + +import ( + "bytes" + "fmt" + "log" + "os" + "regexp" + + "gopkg.in/yaml.v3" +) + +// customSchemeFile is the on-disk YAML shape for a user-defined scheme. +type customSchemeFile struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Rules []customRule `yaml:"rules"` + Default string `yaml:"default"` +} + +// customRule is a single match-and-bump entry inside a scheme file. +type customRule struct { + Name string `yaml:"name"` + Match string `yaml:"match"` + Bump string `yaml:"bump"` +} + +// customScheme is a loaded, validated scheme backed by regex rules. +// Rules are evaluated in declaration order; the first match wins. When no +// rule matches, the scheme returns its fallback bump (nil if "none"). +type customScheme struct { + name string + rules []compiledRule + fallback bumper + fallbackName string +} + +type compiledRule struct { + name string + re *regexp.Regexp + bump bumper + bumpName string +} + +func (c *customScheme) Name() string { return c.name } + +func (c *customScheme) ParseCommit(msg string) bumper { + for i, r := range c.rules { + if r.re.MatchString(msg) { + log.Printf("matched %s: %s bump", labelFor(r.name, i), r.bumpName) + return r.bump + } + } + if c.fallback != nil { + log.Printf("no rule matched, applying default: %s bump", c.fallbackName) + } + return c.fallback +} + +// LoadSchemeFile reads, decodes, and validates a YAML scheme definition at +// path. All validation happens here so that per-commit parsing at runtime is +// side-effect free. +func LoadSchemeFile(path string) (Scheme, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read scheme file %q: %w", path, err) + } + + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + + var f customSchemeFile + if err := dec.Decode(&f); err != nil { + return nil, fmt.Errorf("parse scheme file %q: %w", path, err) + } + + if f.Name == "" { + return nil, fmt.Errorf("scheme file %q: name is required", path) + } + if len(f.Rules) == 0 { + return nil, fmt.Errorf("scheme file %q: at least one rule is required", path) + } + if f.Default == "" { + return nil, fmt.Errorf("scheme file %q: default is required (one of major|minor|patch|none)", path) + } + + fallback, err := resolveBump(f.Default) + if err != nil { + return nil, fmt.Errorf("scheme file %q: default: %w", path, err) + } + + compiled := make([]compiledRule, 0, len(f.Rules)) + for i, r := range f.Rules { + label := labelFor(r.Name, i) + + if r.Match == "" { + return nil, fmt.Errorf("scheme file %q: %s: match is required", path, label) + } + re, err := regexp.Compile(r.Match) + if err != nil { + return nil, fmt.Errorf("scheme file %q: %s: invalid regex: %w", path, label, err) + } + if r.Bump == "" { + return nil, fmt.Errorf("scheme file %q: %s: bump is required", path, label) + } + b, err := resolveBump(r.Bump) + if err != nil { + return nil, fmt.Errorf("scheme file %q: %s: %w", path, label, err) + } + + compiled = append(compiled, compiledRule{name: r.Name, re: re, bump: b, bumpName: r.Bump}) + } + + return &customScheme{name: f.Name, rules: compiled, fallback: fallback, fallbackName: f.Default}, nil +} + +// resolveBump maps a YAML bump string to a bumper. "none" returns a nil +// bumper, meaning the matched commit contributes no version change. +func resolveBump(s string) (bumper, error) { + switch s { + case "major": + return majorBumper, nil + case "minor": + return minorBumper, nil + case "patch": + return patchBumper, nil + case "none": + return nil, nil + default: + return nil, fmt.Errorf("invalid bump %q (want major|minor|patch|none)", s) + } +} + +// labelFor returns a human-readable identifier for a rule, used in both +// validation errors at load time and match logs at parse time. +func labelFor(name string, i int) string { + if name != "" { + return fmt.Sprintf("rule %q", name) + } + return fmt.Sprintf("rule[%d]", i) +} diff --git a/scheme_custom_test.go b/scheme_custom_test.go new file mode 100644 index 0000000..62b5905 --- /dev/null +++ b/scheme_custom_test.go @@ -0,0 +1,200 @@ +package autotag + +import ( + "regexp" + "strings" + "testing" + + "github.com/alecthomas/assert/v2" +) + +func TestLoadSchemeFile_valid(t *testing.T) { + tests := []struct { + name string + path string + expectName string + expectRules int + expectFbType bumper // nil means "none" + }{ + { + name: "minimal", + path: "testdata/schemes/valid_minimal.yaml", + expectName: "minimal", + expectRules: 1, + expectFbType: patchBumper, + }, + { + name: "autotag clone", + path: "testdata/schemes/autotag_clone.yaml", + expectName: "autotag-clone", + expectRules: 3, + expectFbType: nil, + }, + { + name: "conventional clone", + path: "testdata/schemes/conventional_clone.yaml", + expectName: "conventional-clone", + expectRules: 4, + expectFbType: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s, err := LoadSchemeFile(tc.path) + assert.NoError(t, err) + assert.Equal(t, tc.expectName, s.Name()) + + cs, ok := s.(*customScheme) + assert.True(t, ok, "expected *customScheme") + assert.Equal(t, tc.expectRules, len(cs.rules)) + assert.Equal(t, tc.expectFbType, cs.fallback) + }) + } +} + +func TestLoadSchemeFile_invalid(t *testing.T) { + tests := []struct { + name string + path string + errContains string + }{ + { + name: "missing file", + path: "testdata/schemes/does_not_exist.yaml", + errContains: "does_not_exist.yaml", + }, + { + name: "malformed yaml", + path: "testdata/schemes/malformed.yaml", + errContains: "parse scheme file", + }, + { + name: "unknown top-level key", + path: "testdata/schemes/unknown_key.yaml", + errContains: "mystery", + }, + { + name: "invalid regex", + path: "testdata/schemes/bad_regex.yaml", + errContains: `rule "broken"`, + }, + { + name: "invalid bump value", + path: "testdata/schemes/bad_bump.yaml", + errContains: "major|minor|patch|none", + }, + { + name: "empty rules", + path: "testdata/schemes/empty_rules.yaml", + errContains: "at least one rule", + }, + { + name: "missing default", + path: "testdata/schemes/no_default.yaml", + errContains: "default is required", + }, + { + name: "missing name", + path: "testdata/schemes/no_name.yaml", + errContains: "name is required", + }, + { + name: "rule missing match", + path: "testdata/schemes/rule_missing_match.yaml", + errContains: "match is required", + }, + { + name: "rule missing bump", + path: "testdata/schemes/rule_missing_bump.yaml", + errContains: "bump is required", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadSchemeFile(tc.path) + assert.Error(t, err) + if !strings.Contains(err.Error(), tc.errContains) { + t.Fatalf("expected error to contain %q, got: %v", tc.errContains, err) + } + }) + } +} + +func assertBumper(t *testing.T, want, got bumper) { + t.Helper() + if want != got { + t.Fatalf("expected %T, got %T", want, got) + } +} + +func TestCustomSchemeParseCommit(t *testing.T) { + s := &customScheme{ + name: "test", + rules: []compiledRule{ + {name: "breaking", re: regexp.MustCompile(`^BREAKING:`), bump: majorBumper}, + {name: "feat", re: regexp.MustCompile(`^feat:`), bump: minorBumper}, + {name: "fix", re: regexp.MustCompile(`^fix:`), bump: patchBumper}, + {name: "skip", re: regexp.MustCompile(`^skip:`), bump: nil}, + }, + fallback: patchBumper, + } + + tests := []struct { + name string + msg string + expect bumper + }{ + {"first match wins — breaking over feat", "BREAKING: feat: a change", majorBumper}, + {"feat matches", "feat: add thing", minorBumper}, + {"fix matches", "fix: patch thing", patchBumper}, + {"explicit skip returns nil rule bump", "skip: docs", nil}, + {"no match falls back", "chore: upgrade deps", patchBumper}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertBumper(t, tc.expect, s.ParseCommit(tc.msg)) + }) + } +} + +func TestCustomSchemeParseCommit_nilFallback(t *testing.T) { + s := &customScheme{ + name: "no-fallback", + rules: []compiledRule{{name: "feat", re: regexp.MustCompile(`^feat:`), bump: minorBumper}}, + fallback: nil, + } + + assertBumper(t, minorBumper, s.ParseCommit("feat: thing")) + assertBumper(t, nil, s.ParseCommit("random message")) +} + +// Dogfood: load autotag_clone.yaml and verify it reproduces the built-in +// autotag scheme's decisions on representative commit messages. +func TestCustomSchemeDogfood_autotag(t *testing.T) { + s, err := LoadSchemeFile("testdata/schemes/autotag_clone.yaml") + assert.NoError(t, err) + + assertBumper(t, majorBumper, s.ParseCommit("[major] drop thing")) + assertBumper(t, minorBumper, s.ParseCommit("[minor] add thing")) + assertBumper(t, patchBumper, s.ParseCommit("[patch] fix thing")) + assertBumper(t, majorBumper, s.ParseCommit("#major drop thing")) + assertBumper(t, nil, s.ParseCommit("no marker here")) +} + +// Dogfood: load conventional_clone.yaml and verify it reproduces the built-in +// conventional scheme's decisions (in non-strict mode). +func TestCustomSchemeDogfood_conventional(t *testing.T) { + s, err := LoadSchemeFile("testdata/schemes/conventional_clone.yaml") + assert.NoError(t, err) + + assertBumper(t, minorBumper, s.ParseCommit("feat: allow config to extend")) + assertBumper(t, minorBumper, s.ParseCommit("feat(lang): add polish")) + assertBumper(t, majorBumper, s.ParseCommit("refactor!: drop Node 6")) + assertBumper(t, majorBumper, s.ParseCommit("refactor(runtime)!: drop Node 6")) + assertBumper(t, majorBumper, s.ParseCommit("feat: thing\n\nBREAKING CHANGE: break")) + assertBumper(t, patchBumper, s.ParseCommit("fix: typo")) + assertBumper(t, nil, s.ParseCommit("not a conventional commit")) +} diff --git a/testdata/schemes/autotag_clone.yaml b/testdata/schemes/autotag_clone.yaml new file mode 100644 index 0000000..2dce796 --- /dev/null +++ b/testdata/schemes/autotag_clone.yaml @@ -0,0 +1,13 @@ +name: autotag-clone +description: Replicates the built-in autotag scheme. +rules: + - name: major + match: '(?i)\[major\]|#major' + bump: major + - name: minor + match: '(?i)\[minor\]|#minor' + bump: minor + - name: patch + match: '(?i)\[patch\]|#patch' + bump: patch +default: none diff --git a/testdata/schemes/bad_bump.yaml b/testdata/schemes/bad_bump.yaml new file mode 100644 index 0000000..eac5c5f --- /dev/null +++ b/testdata/schemes/bad_bump.yaml @@ -0,0 +1,6 @@ +name: bad-bump +rules: + - name: weird + match: '^feat' + bump: megamajor +default: patch diff --git a/testdata/schemes/bad_regex.yaml b/testdata/schemes/bad_regex.yaml new file mode 100644 index 0000000..73105d3 --- /dev/null +++ b/testdata/schemes/bad_regex.yaml @@ -0,0 +1,6 @@ +name: bad-regex +rules: + - name: broken + match: '[unterminated' + bump: minor +default: patch diff --git a/testdata/schemes/conventional_clone.yaml b/testdata/schemes/conventional_clone.yaml new file mode 100644 index 0000000..b962546 --- /dev/null +++ b/testdata/schemes/conventional_clone.yaml @@ -0,0 +1,16 @@ +name: conventional-clone +description: Replicates the built-in conventional commits scheme in non-strict mode. +rules: + - name: breaking-footer + match: '(?m)^BREAKING CHANGE:' + bump: major + - name: breaking-bang + match: '^\w+(\([^)]*\))?!:' + bump: major + - name: feat + match: '^feat(\([^)]*\))?:' + bump: minor + - name: patch-types + match: '^(fix|build|chore|ci|docs|perf|refactor|revert|style|test)(\([^)]*\))?:' + bump: patch +default: none diff --git a/testdata/schemes/empty_rules.yaml b/testdata/schemes/empty_rules.yaml new file mode 100644 index 0000000..8e70039 --- /dev/null +++ b/testdata/schemes/empty_rules.yaml @@ -0,0 +1,3 @@ +name: empty-rules +rules: [] +default: patch diff --git a/testdata/schemes/malformed.yaml b/testdata/schemes/malformed.yaml new file mode 100644 index 0000000..1274d6d --- /dev/null +++ b/testdata/schemes/malformed.yaml @@ -0,0 +1,6 @@ +name: broken +rules: + - match: '^feat' + bump: minor + this is: not: valid yaml +default: patch diff --git a/testdata/schemes/no_default.yaml b/testdata/schemes/no_default.yaml new file mode 100644 index 0000000..2e8a8ec --- /dev/null +++ b/testdata/schemes/no_default.yaml @@ -0,0 +1,4 @@ +name: no-default +rules: + - match: '^feat' + bump: minor diff --git a/testdata/schemes/no_name.yaml b/testdata/schemes/no_name.yaml new file mode 100644 index 0000000..81c1761 --- /dev/null +++ b/testdata/schemes/no_name.yaml @@ -0,0 +1,4 @@ +rules: + - match: '^feat' + bump: minor +default: patch diff --git a/testdata/schemes/rule_missing_bump.yaml b/testdata/schemes/rule_missing_bump.yaml new file mode 100644 index 0000000..cf09f4a --- /dev/null +++ b/testdata/schemes/rule_missing_bump.yaml @@ -0,0 +1,5 @@ +name: missing-bump +rules: + - name: orphan + match: '^feat' +default: patch diff --git a/testdata/schemes/rule_missing_match.yaml b/testdata/schemes/rule_missing_match.yaml new file mode 100644 index 0000000..f8e38a6 --- /dev/null +++ b/testdata/schemes/rule_missing_match.yaml @@ -0,0 +1,5 @@ +name: missing-match +rules: + - name: orphan + bump: minor +default: patch diff --git a/testdata/schemes/unknown_key.yaml b/testdata/schemes/unknown_key.yaml new file mode 100644 index 0000000..896007b --- /dev/null +++ b/testdata/schemes/unknown_key.yaml @@ -0,0 +1,6 @@ +name: unknown-key +rules: + - match: '^feat' + bump: minor +default: patch +mystery: this key is not part of the schema diff --git a/testdata/schemes/valid_minimal.yaml b/testdata/schemes/valid_minimal.yaml new file mode 100644 index 0000000..68f2366 --- /dev/null +++ b/testdata/schemes/valid_minimal.yaml @@ -0,0 +1,5 @@ +name: minimal +rules: + - match: '^feat' + bump: minor +default: patch