Skip to content

Commit

Permalink
feat: add strict match option to avoid patch bump fallback
Browse files Browse the repository at this point in the history
  • Loading branch information
defgenx committed Jan 10, 2025
1 parent 49348db commit eeee33b
Show file tree
Hide file tree
Showing 3 changed files with 148 additions and 25 deletions.
63 changes: 48 additions & 15 deletions autotag.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ var (
// conventional commit message scheme:
// https://regex101.com/r/XciTmT/2
conventionalCommitRex = regexp.MustCompile(`^\s*(?P<type>\w+)(?P<scope>(?:\([^()\r\n]*\)|\()?(?P<breaking>!)?)(?P<subject>:.*)?`)
// conventional commit authorized types:
conventionalCommitAuthorizedTypes = map[string]bumper{
"feat": minorBumper,
"build": patchBumper,
"chore": patchBumper,
"ci": patchBumper,
"docs": patchBumper,
"fix": patchBumper,
"perf": patchBumper,
"refactor": patchBumper,
"revert": patchBumper,
"style": patchBumper,
"test": patchBumper,
}

// versionRex matches semVer style versions, eg: `v1.0.0`
versionRex = regexp.MustCompile(`^v?([\d]+\.?.*)`)
Expand Down Expand Up @@ -111,6 +125,9 @@ type GitRepoConfig struct {

// Prefix prepends literal 'v' to the tag, eg: v1.0.0. Enabled by default
Prefix bool

// Prefix prepends literal 'v' to the tag, eg: v1.0.0. Enabled by default
StrictMatch bool
}

// GitRepo represents a repository we want to run actions against
Expand All @@ -127,7 +144,8 @@ type GitRepo struct {
preReleaseTimestampLayout string
buildMetadata string

scheme string
scheme string
strictMatch bool

prefix bool
}
Expand Down Expand Up @@ -188,14 +206,15 @@ func NewRepo(cfg GitRepoConfig) (*GitRepo, error) {
buildMetadata: cfg.BuildMetadata,
scheme: cfg.Scheme,
prefix: cfg.Prefix,
strictMatch: cfg.StrictMatch,
}

err = r.parseTags()
if err != nil {
return nil, err
}

if err := r.calcVersion(); err != nil {
if err = r.calcVersion(); err != nil {
return nil, err
}

Expand Down Expand Up @@ -386,23 +405,26 @@ func (r *GitRepo) calcVersion() error {
revList := []string{fmt.Sprintf("%s..%s", r.currentTag.ID, startCommit.ID)}

l, err := r.repo.RevList(revList)
if len(l) == 0 && r.strictMatch {
return fmt.Errorf("no version to bump for the same commit")
}
if err != nil {
log.Printf("Error loading history for tag '%s': %s ", r.currentVersion, err.Error())
}

// r.branchID is newest commit; r.currentTag.ID is oldest
// r.branchID is the newest commit; r.currentTag.ID is oldest
log.Printf("Checking commits from %s to %s ", r.branchID, r.currentTag.ID)

// Revlist returns in reverse Crhonological We want chonological. Then check each commit for bump messages
// Revlist returns in reverse Chronological We want chronological. Then check each commit for bump messages
for i := len(l) - 1; i >= 0; i-- {
commit := l[i] // getting the reverse order element
if commit == nil {
return fmt.Errorf("commit pointed to nil object. This should not happen.")
return fmt.Errorf("commit pointed to nil object. This should not happen")
}

v, nerr := r.parseCommit(commit)
if nerr != nil {
log.Fatal(nerr)
return nerr
}

if v != nil && v.GreaterThan(r.newVersion) {
Expand All @@ -412,6 +434,9 @@ func (r *GitRepo) calcVersion() error {

// if there is no movement on the version from commits, bump patch
if r.newVersion.Equal(r.currentVersion) {
if r.strictMatch {
return fmt.Errorf("no version to bump found in commit message")
}
if r.newVersion, err = patchBumper.bump(r.currentVersion); err != nil {
return err
}
Expand Down Expand Up @@ -462,11 +487,15 @@ func (r *GitRepo) parseCommit(commit *git.Commit) (*version.Version, error) {

switch r.scheme {
case "conventional":
b = parseConventionalCommit(msg)
b = parseConventionalCommit(msg, r.strictMatch)
case "", "autotag":
b = parseAutotagCommit(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)
Expand Down Expand Up @@ -502,28 +531,32 @@ func parseAutotagCommit(msg string) bumper {
}

// 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) bumper {
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
}

// if the type/scope in the header includes a trailing '!' this is a breaking change
// If the type/scope in the header includes a trailing '!' this is a breaking change
if breaking, ok := matches["breaking"]; ok && breaking == "!" {
return majorBumper
}

// if the type in the header is 'feat' it is a minor change
if typ, ok := matches["type"]; ok && typ == "feat" {
return minorBumper
}

return nil
// 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
Expand Down
2 changes: 2 additions & 0 deletions autotag/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Options struct {
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"`
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"`
}

var opts Options
Expand All @@ -47,6 +48,7 @@ func main() {
BuildMetadata: opts.BuildMetadata,
Scheme: opts.Scheme,
Prefix: !opts.NoVersionPrefix,
StrictMatch: opts.StrictMatch,
})
if err != nil {
log.SetOutput(os.Stderr)
Expand Down
108 changes: 98 additions & 10 deletions autotag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,17 @@ type testRepoSetup struct {
// commit message parsing logic. eg: "#major this is a major commit"
nextCommit string

// (optional) Supply a list of commits to apply so you can test the logic between to possible tags wheere they may be more complex multiple bumps
// (optional) Supply a list of commits to apply so you can test the logic between to possible tags where they may be more complex multiple bumps
commitList []string

// (optional) will enforce conventions and return an error if parsers don't find anything (default: false)
strictMatch bool
}

// newTestRepo creates a new git repo in a temporary directory and returns an autotag.GitRepo struct for
// testing the autotag package.
// You must call cleanupTestRepo(t, r.repo) to remove the temporary directory after running tests.
func newTestRepo(t *testing.T, setup testRepoSetup) GitRepo {
func newTestRepo(t *testing.T, setup testRepoSetup) (GitRepo, error) {
t.Helper()

tr := createTestRepo(t, setup.branch)
Expand Down Expand Up @@ -100,12 +103,13 @@ func newTestRepo(t *testing.T, setup testRepoSetup) GitRepo {
BuildMetadata: setup.buildMetadata,
Scheme: setup.scheme,
Prefix: !setup.disablePrefix,
StrictMatch: setup.strictMatch,
})
if err != nil {
t.Fatal("Error creating repo: ", err)
return GitRepo{}, err
}

return *r
return *r, nil
}

func TestValidateConfig(t *testing.T) {
Expand Down Expand Up @@ -162,6 +166,7 @@ func TestValidateConfig(t *testing.T) {
PreReleaseTimestampLayout: "epoch",
BuildMetadata: "g12345678",
Prefix: true,
StrictMatch: true,
},
shouldErr: false,
},
Expand Down Expand Up @@ -267,11 +272,84 @@ func TestNewRepoMainAndMaster(t *testing.T) {
}
}

func TestNewRepoStrictMatch(t *testing.T) {
tests := []struct {
name string
setup testRepoSetup
}{
// tests for autotag (default) scheme
{
name: "autotag scheme, bad type commit fails with strict match",
setup: testRepoSetup{
scheme: "autotag",
initialTag: "v1.0.0",
nextCommit: "[foo]: thing 1",
strictMatch: true,
},
},
{
name: "autotag scheme, fails to tag same commit twice with strict match",
setup: testRepoSetup{
scheme: "autotag",
initialTag: "v1.0.0",
strictMatch: true,
},
},

// tests for conventional commits scheme. Based on:
{
name: "conventional commits, bad type commit fails with strict match",
setup: testRepoSetup{
scheme: "conventional",
initialTag: "v1.0.0",
nextCommit: "foo: thing 1",
strictMatch: true,
},
},
{
name: "conventional commits, bad type with breaking change fails with strict match",
setup: testRepoSetup{
scheme: "conventional",
initialTag: "v1.0.0",
nextCommit: "foo: allow provided config object to extend other configs\n\nbody before footer\n\nBREAKING CHANGE: non-backwards compatible",
strictMatch: true,
},
},
{
name: "conventional commits, bad type with ! fails with strict match",
setup: testRepoSetup{
scheme: "conventional",
initialTag: "v1.0.0",
nextCommit: "foo!: thing 1",
strictMatch: true,
},
},
{
name: "conventional commits, fails to tag same commit twice with strict match",
setup: testRepoSetup{
scheme: "conventional",
initialTag: "v1.0.0",
strictMatch: true,
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := newTestRepo(t, tc.setup)
assert.Error(t, err)
})
}
}

func TestMajor(t *testing.T) {
r := newTestRepo(t, testRepoSetup{
r, err := newTestRepo(t, testRepoSetup{
branch: "master",
initialTag: "v1.0.1",
})
if err != nil {
t.Fatal("Error creating repo: ", err)
}
defer cleanupTestRepo(t, r.repo)

v, err := r.MajorBump()
Expand All @@ -287,10 +365,13 @@ func TestMajor(t *testing.T) {
}

func TestMajorWithMain(t *testing.T) {
r := newTestRepo(t, testRepoSetup{
r, err := newTestRepo(t, testRepoSetup{
branch: "main",
initialTag: "v1.0.1",
})
if err != nil {
t.Fatal("Error creating repo: ", err)
}
defer cleanupTestRepo(t, r.repo)

v, err := r.MajorBump()
Expand All @@ -306,9 +387,12 @@ func TestMajorWithMain(t *testing.T) {
}

func TestMinor(t *testing.T) {
r := newTestRepo(t, testRepoSetup{
r, err := newTestRepo(t, testRepoSetup{
initialTag: "v1.0.1",
})
if err != nil {
t.Fatal("Error creating repo: ", err)
}
defer cleanupTestRepo(t, r.repo)

v, err := r.MinorBump()
Expand All @@ -322,9 +406,12 @@ func TestMinor(t *testing.T) {
}

func TestPatch(t *testing.T) {
r := newTestRepo(t, testRepoSetup{
r, err := newTestRepo(t, testRepoSetup{
initialTag: "v1.0.1",
})
if err != nil {
t.Fatal("Error creating repo: ", err)
}
defer cleanupTestRepo(t, r.repo)

v, err := r.PatchBump()
Expand Down Expand Up @@ -638,10 +725,11 @@ func TestAutoTag(t *testing.T) {

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := newTestRepo(t, tc.setup)
r, err := newTestRepo(t, tc.setup)
checkFatal(t, err)
defer cleanupTestRepo(t, r.repo)

err := r.AutoTag()
err = r.AutoTag()
if tc.shouldErr {
assert.Error(t, err)
} else {
Expand Down

0 comments on commit eeee33b

Please sign in to comment.