mirror of
https://github.com/lukaszraczylo/semver-generator.git
synced 2026-06-05 22:49:25 +00:00
Improve calculation logic, add ability to strip prefixes.
This commit is contained in:
@@ -16,6 +16,7 @@ Project created overnight, to prove that management of semantic versioning is NO
|
||||
- [Calculations example \[standard\]](#calculations-example-standard)
|
||||
- [Calculations example \[strict matching\]](#calculations-example-strict-matching)
|
||||
- [Release candidates](#release-candidates)
|
||||
- [Tag prefix stripping](#tag-prefix-stripping)
|
||||
- [Example configuration](#example-configuration)
|
||||
- [Good to know](#good-to-know)
|
||||
|
||||
@@ -29,6 +30,7 @@ Project created overnight, to prove that management of semantic versioning is NO
|
||||
|
||||
* With flag `-e` or config `force.existing: true` the existing tags in versioning will be respected, helping you to avoid the version conflicts.
|
||||
* With config `force.commit: deadbeef` where `deadbeef` is the commit hash - calculations will start from the specified commit.
|
||||
* Tag prefix stripping: The `v` prefix is automatically stripped from tags (e.g., `v1.2.3` → `1.2.3`). Additional prefixes can be configured via `tag_prefixes` for monorepo setups (e.g., `app-1.2.3`, `infra-1.2.3`).
|
||||
|
||||
### Important changes
|
||||
|
||||
@@ -182,6 +184,36 @@ to generate the appropriate release in format `1.3.37-rc.1` and counting up unti
|
||||
- add-rc
|
||||
```
|
||||
|
||||
#### Tag prefix stripping
|
||||
|
||||
When using the `-e` (existing tags) flag, the semver-generator needs to parse existing git tags to determine the current version. Tags often include prefixes that need to be stripped before version parsing.
|
||||
|
||||
**Automatic `v` prefix stripping:**
|
||||
The `v` prefix is always stripped automatically from tags. For example:
|
||||
- `v1.2.3` → parsed as `1.2.3`
|
||||
- `v0.5.0` → parsed as `0.5.0`
|
||||
|
||||
**Custom prefixes for monorepos:**
|
||||
In monorepo setups where different components have their own versioned tags, you can configure additional prefixes to strip:
|
||||
|
||||
```yaml
|
||||
tag_prefixes:
|
||||
- "app-"
|
||||
- "infra-"
|
||||
- "api-"
|
||||
- "frontend-"
|
||||
```
|
||||
|
||||
With this configuration:
|
||||
- `app-1.2.3` → parsed as `1.2.3`
|
||||
- `infra-0.5.0` → parsed as `0.5.0`
|
||||
- `api-2.0.0-rc.1` → parsed as `2.0.0-rc.1` (release candidate)
|
||||
|
||||
This is particularly useful when:
|
||||
- You have multiple services/components in a single repository
|
||||
- Your CI/CD creates tags with component prefixes
|
||||
- You want to track versions separately for different parts of your codebase
|
||||
|
||||
#### Example configuration
|
||||
|
||||
```yaml
|
||||
@@ -196,6 +228,10 @@ blacklist:
|
||||
- "Merge pull request"
|
||||
- "feature/"
|
||||
- "feature:"
|
||||
tag_prefixes:
|
||||
- "app-"
|
||||
- "infra-"
|
||||
- "service-"
|
||||
wording:
|
||||
patch:
|
||||
- update
|
||||
@@ -215,6 +251,7 @@ wording:
|
||||
* `force`: sets the "starting" version, you don't need to specify this section as the default is always `0`
|
||||
* `force.commit`: allows you to set commit hash from which the calculations should start
|
||||
* `blacklist`: terms to ignore when processing commits. Any commit containing these terms will be skipped in version calculations. Useful for ignoring merge commits, feature branch names, and other unwanted triggers.
|
||||
* `tag_prefixes`: prefixes to strip from existing tags before parsing version numbers. Useful for monorepos where tags are prefixed with component names (e.g., `app-1.2.3`, `infra-0.5.0`). The `v` prefix is always stripped automatically.
|
||||
* `wording`: words the program should look for in the git commits to increment (patch|minor|major)
|
||||
|
||||
### Good to knows
|
||||
|
||||
+6
-1
@@ -121,7 +121,11 @@ func main() {
|
||||
}
|
||||
|
||||
// List commits
|
||||
utils.ListCommits(&repo.GitRepo)
|
||||
if _, err := utils.ListCommits(&repo.GitRepo); err != nil {
|
||||
utils.Error("Unable to list commits", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// List existing tags if needed
|
||||
if params.varExisting || repo.Config.Force.Existing {
|
||||
@@ -140,6 +144,7 @@ func main() {
|
||||
repo.Semver,
|
||||
params.varExisting || repo.Config.Force.Existing,
|
||||
params.varStrict || repo.Config.Force.Strict,
|
||||
repo.Config.TagPrefixes,
|
||||
)
|
||||
|
||||
// Print semantic version
|
||||
|
||||
+39
-18
@@ -267,7 +267,7 @@ func (suite *Tests) Test_checkMatches() {
|
||||
if tt.name == "No match" {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// For other test cases, match if the needle is in the haystack
|
||||
for _, h := range haystack {
|
||||
if strings.Contains(h, needle) || strings.Contains(needle, h) {
|
||||
@@ -276,7 +276,7 @@ func (suite *Tests) Test_checkMatches() {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
got := utils.CheckMatches(tt.args.content, tt.args.targets, tt.blacklist)
|
||||
assertObj.Equal(tt.want, got, "Unexpected result in "+tt.name)
|
||||
})
|
||||
@@ -285,7 +285,8 @@ func (suite *Tests) Test_checkMatches() {
|
||||
|
||||
func (suite *Tests) Test_parseExistingSemver() {
|
||||
type args struct {
|
||||
tagName string
|
||||
tagName string
|
||||
prefixes []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -296,7 +297,8 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
{
|
||||
name: "Test parsing existing semver",
|
||||
args: args{
|
||||
tagName: "1.2.3",
|
||||
tagName: "1.2.3",
|
||||
prefixes: []string{},
|
||||
},
|
||||
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
|
||||
wantSemanticVersion: utils.SemVer{
|
||||
@@ -308,7 +310,8 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
{
|
||||
name: "Test parsing existing semver with v",
|
||||
args: args{
|
||||
tagName: "v1.2.3",
|
||||
tagName: "v1.2.3",
|
||||
prefixes: []string{"v"},
|
||||
},
|
||||
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
|
||||
wantSemanticVersion: utils.SemVer{
|
||||
@@ -320,7 +323,8 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
{
|
||||
name: "Test parsing existing semver with rc",
|
||||
args: args{
|
||||
tagName: "1.2.5-rc.7",
|
||||
tagName: "1.2.5-rc.7",
|
||||
prefixes: []string{},
|
||||
},
|
||||
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
|
||||
wantSemanticVersion: utils.SemVer{
|
||||
@@ -331,10 +335,25 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
EnableReleaseCandidate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Test parsing prefixed tag without rc",
|
||||
args: args{
|
||||
tagName: "app-0.0.16",
|
||||
prefixes: []string{"app-", "infra-"},
|
||||
},
|
||||
currentSemver: utils.SemVer{Major: 1, Minor: 1, Patch: 1},
|
||||
wantSemanticVersion: utils.SemVer{
|
||||
Major: 0,
|
||||
Minor: 0,
|
||||
Patch: 16,
|
||||
EnableReleaseCandidate: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Test invalid semver format",
|
||||
args: args{
|
||||
tagName: "invalid",
|
||||
tagName: "invalid",
|
||||
prefixes: []string{},
|
||||
},
|
||||
currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4},
|
||||
wantSemanticVersion: utils.SemVer{
|
||||
@@ -346,7 +365,8 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
{
|
||||
name: "Test partial semver",
|
||||
args: args{
|
||||
tagName: "1.2",
|
||||
tagName: "1.2",
|
||||
prefixes: []string{},
|
||||
},
|
||||
currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4},
|
||||
wantSemanticVersion: utils.SemVer{
|
||||
@@ -358,7 +378,8 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
{
|
||||
name: "Test empty tag",
|
||||
args: args{
|
||||
tagName: "",
|
||||
tagName: "",
|
||||
prefixes: []string{},
|
||||
},
|
||||
currentSemver: utils.SemVer{Major: 2, Minor: 3, Patch: 4},
|
||||
wantSemanticVersion: utils.SemVer{
|
||||
@@ -370,7 +391,7 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
suite.T().Run(tt.name, func(t *testing.T) {
|
||||
got := utils.ParseExistingSemver(tt.args.tagName, tt.currentSemver)
|
||||
got := utils.ParseExistingSemver(tt.args.tagName, tt.currentSemver, tt.args.prefixes)
|
||||
assertObj.Equal(tt.wantSemanticVersion.Major, got.Major, "Unexpected MAJOR semver result in "+tt.name)
|
||||
assertObj.Equal(tt.wantSemanticVersion.Minor, got.Minor, "Unexpected MINOR semver result in "+tt.name)
|
||||
assertObj.Equal(tt.wantSemanticVersion.Patch, got.Patch, "Unexpected PATCH semver result in "+tt.name)
|
||||
@@ -382,10 +403,10 @@ func (suite *Tests) Test_parseExistingSemver() {
|
||||
|
||||
func (suite *Tests) TestSetup_ListCommits() {
|
||||
type fields struct {
|
||||
RepositoryName string
|
||||
RepositoryBranch string
|
||||
LocalConfigFile string
|
||||
GitRepo utils.GitRepository
|
||||
RepositoryName string
|
||||
RepositoryBranch string
|
||||
LocalConfigFile string
|
||||
GitRepo utils.GitRepository
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -441,23 +462,23 @@ func (suite *Tests) TestSetup_ListCommits() {
|
||||
if tt.name == "List commits from existing repository" {
|
||||
t.Skip("Skipping test that requires repository access")
|
||||
}
|
||||
|
||||
|
||||
s := &Setup{
|
||||
RepositoryName: tt.fields.RepositoryName,
|
||||
RepositoryBranch: tt.fields.RepositoryBranch,
|
||||
GitRepo: tt.fields.GitRepo,
|
||||
}
|
||||
|
||||
|
||||
config, _ := utils.ReadConfig(tt.fields.LocalConfigFile)
|
||||
s.Config = config
|
||||
|
||||
|
||||
err := utils.PrepareRepository(&s.GitRepo)
|
||||
if err != nil && !tt.wantErr {
|
||||
if tt.name != "List commits starting with certain hash" {
|
||||
t.Fatalf("Failed to prepare repository: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if err == nil {
|
||||
listOfCommits, err := utils.ListCommits(&s.GitRepo)
|
||||
if !tt.wantErr {
|
||||
|
||||
+22
-12
@@ -26,26 +26,36 @@ type Force struct {
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Wording Wording
|
||||
Force Force
|
||||
Blacklist []string
|
||||
Wording Wording
|
||||
Force Force
|
||||
Blacklist []string
|
||||
TagPrefixes []string // Prefixes to strip from tags before parsing (e.g., "app-", "infra-", "v")
|
||||
}
|
||||
|
||||
// ReadConfig reads the configuration from a file
|
||||
func ReadConfig(file string) (*Config, error) {
|
||||
config := &Config{}
|
||||
|
||||
|
||||
viper.SetConfigFile(file)
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("fatal error config file: %s", err)
|
||||
return config, err
|
||||
}
|
||||
|
||||
viper.UnmarshalKey("wording", &config.Wording)
|
||||
viper.UnmarshalKey("force", &config.Force)
|
||||
viper.UnmarshalKey("blacklist", &config.Blacklist)
|
||||
|
||||
|
||||
if err := viper.UnmarshalKey("wording", &config.Wording); err != nil {
|
||||
return config, fmt.Errorf("error parsing wording config: %w", err)
|
||||
}
|
||||
if err := viper.UnmarshalKey("force", &config.Force); err != nil {
|
||||
return config, fmt.Errorf("error parsing force config: %w", err)
|
||||
}
|
||||
if err := viper.UnmarshalKey("blacklist", &config.Blacklist); err != nil {
|
||||
return config, fmt.Errorf("error parsing blacklist config: %w", err)
|
||||
}
|
||||
if err := viper.UnmarshalKey("tag_prefixes", &config.TagPrefixes); err != nil {
|
||||
return config, fmt.Errorf("error parsing tag_prefixes config: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
@@ -55,14 +65,14 @@ func ApplyForcedVersioning(force Force, semver *SemVer) {
|
||||
Debug("Forced versioning (MAJOR)", map[string]interface{}{"major": force.Major})
|
||||
semver.Major = force.Major
|
||||
}
|
||||
|
||||
|
||||
if force.Minor > 0 {
|
||||
Debug("Forced versioning (MINOR)", map[string]interface{}{"minor": force.Minor})
|
||||
semver.Minor = force.Minor
|
||||
}
|
||||
|
||||
|
||||
if force.Patch > 0 {
|
||||
Debug("Forced versioning (PATCH)", map[string]interface{}{"patch": force.Patch})
|
||||
semver.Patch = force.Patch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-32
@@ -29,14 +29,14 @@ type TagDetails struct {
|
||||
|
||||
// GitRepository represents a git repository
|
||||
type GitRepository struct {
|
||||
Handler *git.Repository
|
||||
Name string
|
||||
Branch string
|
||||
LocalPath string
|
||||
UseLocal bool
|
||||
Commits []CommitDetails
|
||||
Tags []TagDetails
|
||||
StartCommit string
|
||||
Handler *git.Repository
|
||||
Name string
|
||||
Branch string
|
||||
LocalPath string
|
||||
UseLocal bool
|
||||
Commits []CommitDetails
|
||||
Tags []TagDetails
|
||||
StartCommit string
|
||||
}
|
||||
|
||||
// PrepareRepository prepares the git repository for use
|
||||
@@ -47,15 +47,15 @@ func PrepareRepository(repo *GitRepository) error {
|
||||
u, err := url.Parse(repo.Name)
|
||||
if err != nil {
|
||||
Error("Unable to parse repository URL", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"url": repo.Name,
|
||||
"error": err.Error(),
|
||||
"url": repo.Name,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
repo.LocalPath = fmt.Sprintf("/tmp/semver/%s/%s", u.Path, repo.Branch)
|
||||
os.RemoveAll(repo.LocalPath)
|
||||
|
||||
_ = os.RemoveAll(repo.LocalPath) // Ignore error - directory may not exist
|
||||
|
||||
repo.Handler, err = git.PlainClone(repo.LocalPath, false, &git.CloneOptions{
|
||||
URL: repo.Name,
|
||||
ReferenceName: plumbing.NewBranchReferenceName(repo.Branch),
|
||||
@@ -66,11 +66,11 @@ func PrepareRepository(repo *GitRepository) error {
|
||||
},
|
||||
Tags: git.AllTags,
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
Error("Unable to clone repository", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"url": repo.Name,
|
||||
"error": err.Error(),
|
||||
"url": repo.Name,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -79,14 +79,20 @@ func PrepareRepository(repo *GitRepository) error {
|
||||
repo.Handler, err = git.PlainOpen(repo.LocalPath)
|
||||
if err != nil {
|
||||
Error("Unable to open local repository", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"path": repo.LocalPath,
|
||||
"error": err.Error(),
|
||||
"path": repo.LocalPath,
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
os.Chdir(repo.LocalPath)
|
||||
|
||||
if err := os.Chdir(repo.LocalPath); err != nil {
|
||||
Error("Unable to change directory", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"path": repo.LocalPath,
|
||||
})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -105,14 +111,14 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) {
|
||||
if err != nil {
|
||||
return []CommitDetails{}, err
|
||||
}
|
||||
|
||||
|
||||
commitsList, err := repo.Handler.Log(&git.LogOptions{From: ref.Hash()})
|
||||
if err != nil {
|
||||
return []CommitDetails{}, err
|
||||
}
|
||||
|
||||
var tmpResults []CommitDetails
|
||||
commitsList.ForEach(func(c *object.Commit) error {
|
||||
if err := commitsList.ForEach(func(c *object.Commit) error {
|
||||
tmpResults = append(tmpResults, CommitDetails{
|
||||
Hash: c.Hash.String(),
|
||||
Author: c.Author.String(),
|
||||
@@ -123,17 +129,19 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) {
|
||||
return tmpResults[i].Timestamp.Unix() < tmpResults[j].Timestamp.Unix()
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return []CommitDetails{}, err
|
||||
}
|
||||
|
||||
Debug("Listing commits", map[string]interface{}{"commits": tmpResults})
|
||||
|
||||
|
||||
// Filter commits starting from the specified commit if provided
|
||||
if repo.StartCommit != "" {
|
||||
for commitId, cmt := range tmpResults {
|
||||
if cmt.Hash == repo.StartCommit {
|
||||
Debug("Found commit match", map[string]interface{}{
|
||||
"commit": cmt.Hash,
|
||||
"index": commitId,
|
||||
"index": commitId,
|
||||
})
|
||||
repo.Commits = tmpResults[commitId:]
|
||||
break
|
||||
@@ -150,32 +158,32 @@ func ListCommits(repo *GitRepository) ([]CommitDetails, error) {
|
||||
// ListExistingTags lists all tags in the repository
|
||||
func ListExistingTags(repo *GitRepository) {
|
||||
Debug("Listing existing tags", nil)
|
||||
|
||||
|
||||
// Check if Handler is nil to avoid panic
|
||||
if repo.Handler == nil {
|
||||
Debug("Repository handler is nil, skipping tag listing", nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
refs, err := repo.Handler.Tags()
|
||||
if err != nil {
|
||||
Error("Unable to list tags", map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if err := refs.ForEach(func(ref *plumbing.Reference) error {
|
||||
repo.Tags = append(repo.Tags, TagDetails{
|
||||
Name: ref.Name().Short(),
|
||||
Hash: ref.Hash().String(),
|
||||
})
|
||||
|
||||
|
||||
Debug("Found tag", map[string]interface{}{
|
||||
"tag": ref.Name().Short(),
|
||||
"tag": ref.Name().Short(),
|
||||
"hash": ref.Hash().String(),
|
||||
})
|
||||
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
Error("Error iterating tags", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-8
@@ -219,20 +219,23 @@ func downloadBinary(url string) (string, error) {
|
||||
if strings.HasSuffix(url, ".tar.gz") {
|
||||
// For tar.gz, we need to extract the binary
|
||||
if err := extractTarGz(resp.Body, tempFile); err != nil {
|
||||
tempFile.Close()
|
||||
os.Remove(tempPath)
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
// Direct binary download
|
||||
if _, err := io.Copy(tempFile, resp.Body); err != nil {
|
||||
tempFile.Close()
|
||||
os.Remove(tempPath)
|
||||
_ = tempFile.Close()
|
||||
_ = os.Remove(tempPath)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
tempFile.Close()
|
||||
if err := tempFile.Close(); err != nil {
|
||||
_ = os.Remove(tempPath)
|
||||
return "", err
|
||||
}
|
||||
return tempPath, nil
|
||||
}
|
||||
|
||||
@@ -250,10 +253,12 @@ func extractTarGz(r io.Reader, destFile *os.File) error {
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
if _, err := io.Copy(archiveFile, r); err != nil {
|
||||
archiveFile.Close()
|
||||
_ = archiveFile.Close()
|
||||
return err
|
||||
}
|
||||
if err := archiveFile.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
archiveFile.Close()
|
||||
|
||||
// Extract using tar command
|
||||
extractDir, err := os.MkdirTemp("", "semver-generator-extract-*")
|
||||
@@ -336,6 +341,7 @@ var runCommandFunc = func(cmdStr string) error {
|
||||
// replaceBinary replaces the current binary with the new one
|
||||
func replaceBinary(newBinary, currentBinary string) error {
|
||||
// Make the new binary executable
|
||||
// #nosec G302 -- 0755 is required for executable binaries
|
||||
if err := os.Chmod(newBinary, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -350,13 +356,17 @@ func replaceBinary(newBinary, currentBinary string) error {
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst
|
||||
// Note: This function is only called internally with controlled paths from
|
||||
// os.CreateTemp and os.Executable, not with user-supplied paths.
|
||||
func copyFile(src, dst string) error {
|
||||
// #nosec G304 -- src is from os.CreateTemp, not user input
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// #nosec G304 -- dst is from os.Executable, not user input
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -368,6 +378,7 @@ func copyFile(src, dst string) error {
|
||||
}
|
||||
|
||||
// Make executable
|
||||
// #nosec G302 -- 0755 is required for executable binaries
|
||||
return os.Chmod(dst, 0755)
|
||||
}
|
||||
|
||||
@@ -409,7 +420,10 @@ func parseVersionParts(v string) []int {
|
||||
|
||||
for _, p := range parts {
|
||||
var num int
|
||||
fmt.Sscanf(p, "%d", &num)
|
||||
if _, err := fmt.Sscanf(p, "%d", &num); err != nil {
|
||||
// If parsing fails, use 0 for this part
|
||||
num = 0
|
||||
}
|
||||
result = append(result, num)
|
||||
}
|
||||
|
||||
|
||||
+13
-12
@@ -13,6 +13,7 @@ func CalculateSemver(
|
||||
initialSemver SemVer,
|
||||
respectExisting bool,
|
||||
strictMode bool,
|
||||
tagPrefixes []string,
|
||||
) SemVer {
|
||||
semver := initialSemver
|
||||
|
||||
@@ -22,10 +23,10 @@ func CalculateSemver(
|
||||
for _, tagHash := range tags {
|
||||
if commit.Hash == tagHash.Hash {
|
||||
Debug("Found existing tag", map[string]interface{}{
|
||||
"tag": tagHash.Name,
|
||||
"tag": tagHash.Name,
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
})
|
||||
semver = ParseExistingSemver(tagHash.Name, semver)
|
||||
semver = ParseExistingSemver(tagHash.Name, semver, tagPrefixes)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -35,7 +36,7 @@ func CalculateSemver(
|
||||
if !strictMode {
|
||||
semver.Patch++
|
||||
Debug("Incrementing patch (DEFAULT)", map[string]interface{}{
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"semver": FormatSemver(semver),
|
||||
})
|
||||
}
|
||||
@@ -55,44 +56,44 @@ func CalculateSemver(
|
||||
semver.EnableReleaseCandidate = false
|
||||
semver.Release = 0
|
||||
Debug("Incrementing major (WORDING)", map[string]interface{}{
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"semver": FormatSemver(semver),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if matchMinor {
|
||||
semver.Minor++
|
||||
semver.Patch = 1
|
||||
semver.EnableReleaseCandidate = false
|
||||
semver.Release = 0
|
||||
Debug("Incrementing minor (WORDING)", map[string]interface{}{
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"semver": FormatSemver(semver),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if matchReleaseCandidate {
|
||||
semver.Release++
|
||||
semver.Patch = 1
|
||||
semver.EnableReleaseCandidate = true
|
||||
Debug("Incrementing release candidate (WORDING)", map[string]interface{}{
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"semver": FormatSemver(semver),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
if matchPatch {
|
||||
semver.Patch++
|
||||
Debug("Incrementing patch (WORDING)", map[string]interface{}{
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"commit": strings.TrimSuffix(commit.Message, "\n"),
|
||||
"semver": FormatSemver(semver),
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return semver
|
||||
}
|
||||
}
|
||||
|
||||
+52
-11
@@ -19,7 +19,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
// More sophisticated mock implementation for testing
|
||||
for _, h := range haystack {
|
||||
// Check for substring match to better simulate fuzzy search
|
||||
if h == needle || (len(h) >= 3 && len(needle) >= 3 &&
|
||||
if h == needle || (len(h) >= 3 && len(needle) >= 3 &&
|
||||
(h[:3] == needle[:3] || h[len(h)-3:] == needle[len(needle)-3:])) {
|
||||
return []string{h}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
|
||||
// Test data
|
||||
now := time.Now()
|
||||
|
||||
|
||||
// Common wording and blacklist for all tests
|
||||
wording := Wording{
|
||||
Patch: []string{"update", "fix", "initial"},
|
||||
@@ -49,6 +49,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
initialSemver SemVer
|
||||
respectExisting bool
|
||||
strictMode bool
|
||||
tagPrefixes []string
|
||||
want SemVer
|
||||
}{
|
||||
{
|
||||
@@ -76,11 +77,12 @@ func TestCalculateSemver(t *testing.T) {
|
||||
initialSemver: SemVer{},
|
||||
respectExisting: true,
|
||||
strictMode: false,
|
||||
tagPrefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 2,
|
||||
Minor: 0,
|
||||
Patch: 1, // Initial tag 2.0.0 + one patch increment
|
||||
Release: 1,
|
||||
Major: 2,
|
||||
Minor: 0,
|
||||
Patch: 1, // Initial tag 2.0.0 + one patch increment
|
||||
Release: 1,
|
||||
EnableReleaseCandidate: true,
|
||||
},
|
||||
},
|
||||
@@ -109,11 +111,12 @@ func TestCalculateSemver(t *testing.T) {
|
||||
initialSemver: SemVer{},
|
||||
respectExisting: true,
|
||||
strictMode: true,
|
||||
tagPrefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 2,
|
||||
Minor: 0,
|
||||
Patch: 1, // Initial tag 2.0.0 + patch from "update" keyword
|
||||
Release: 1,
|
||||
Major: 2,
|
||||
Minor: 0,
|
||||
Patch: 1, // Initial tag 2.0.0 + patch from "update" keyword
|
||||
Release: 1,
|
||||
EnableReleaseCandidate: true,
|
||||
},
|
||||
},
|
||||
@@ -142,6 +145,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
initialSemver: SemVer{},
|
||||
respectExisting: false,
|
||||
strictMode: false,
|
||||
tagPrefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 0,
|
||||
Minor: 1,
|
||||
@@ -173,6 +177,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
initialSemver: SemVer{Major: 1},
|
||||
respectExisting: false,
|
||||
strictMode: true,
|
||||
tagPrefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 1,
|
||||
Minor: 1,
|
||||
@@ -199,6 +204,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
initialSemver: SemVer{},
|
||||
respectExisting: false,
|
||||
strictMode: false,
|
||||
tagPrefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 0,
|
||||
Minor: 0,
|
||||
@@ -225,6 +231,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
initialSemver: SemVer{},
|
||||
respectExisting: false,
|
||||
strictMode: false,
|
||||
tagPrefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 0,
|
||||
Minor: 0,
|
||||
@@ -233,6 +240,39 @@ func TestCalculateSemver(t *testing.T) {
|
||||
EnableReleaseCandidate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With prefixed tags should not be RC",
|
||||
commits: []CommitDetails{
|
||||
{
|
||||
Hash: "commit1",
|
||||
Message: "tagged commit",
|
||||
Timestamp: now.Add(-3 * time.Hour),
|
||||
},
|
||||
{
|
||||
Hash: "commit2",
|
||||
Message: "another commit",
|
||||
Timestamp: now.Add(-2 * time.Hour),
|
||||
},
|
||||
},
|
||||
tags: []TagDetails{
|
||||
{
|
||||
Name: "app-0.0.16",
|
||||
Hash: "commit1",
|
||||
},
|
||||
},
|
||||
wording: wording,
|
||||
blacklist: blacklist,
|
||||
initialSemver: SemVer{},
|
||||
respectExisting: true,
|
||||
strictMode: true, // Use strict mode for predictable results
|
||||
tagPrefixes: []string{"app-", "infra-"},
|
||||
want: SemVer{
|
||||
Major: 0,
|
||||
Minor: 0,
|
||||
Patch: 16, // From tag, no additional increments in strict mode
|
||||
EnableReleaseCandidate: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -245,6 +285,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
tt.initialSemver,
|
||||
tt.respectExisting,
|
||||
tt.strictMode,
|
||||
tt.tagPrefixes,
|
||||
)
|
||||
|
||||
assert.Equal(t, tt.want.Major, got.Major, "Major version mismatch")
|
||||
@@ -254,4 +295,4 @@ func TestCalculateSemver(t *testing.T) {
|
||||
assert.Equal(t, tt.want.EnableReleaseCandidate, got.EnableReleaseCandidate, "EnableReleaseCandidate mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
-22
@@ -51,52 +51,102 @@ func FormatSemver(semver SemVer) string {
|
||||
|
||||
var extractNumber = regexp.MustCompile("[0-9]+")
|
||||
|
||||
// StripTagPrefix removes configured prefixes from a tag name
|
||||
// The "v" prefix is always stripped by default (e.g., v1.2.3 -> 1.2.3)
|
||||
func StripTagPrefix(tagName string, prefixes []string) string {
|
||||
result := tagName
|
||||
|
||||
// Always strip "v" prefix by default
|
||||
if strings.HasPrefix(result, "v") && len(result) > 1 {
|
||||
// Only strip if followed by a digit (to avoid stripping "version-1.0.0")
|
||||
if result[1] >= '0' && result[1] <= '9' {
|
||||
result = result[1:]
|
||||
Debug("Stripped default 'v' prefix from tag", map[string]interface{}{
|
||||
"original": tagName,
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Then strip any user-configured prefixes
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(result, prefix) {
|
||||
result = strings.TrimPrefix(result, prefix)
|
||||
Debug("Stripped prefix from tag", map[string]interface{}{
|
||||
"original": tagName,
|
||||
"prefix": prefix,
|
||||
"result": result,
|
||||
})
|
||||
break // Only strip one prefix
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseExistingSemver parses a semantic version from a tag name
|
||||
func ParseExistingSemver(tagName string, currentSemver SemVer) SemVer {
|
||||
func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string) SemVer {
|
||||
Debug("Parsing existing semver", map[string]interface{}{"tag": tagName})
|
||||
|
||||
tagNameParts := strings.Split(tagName, ".")
|
||||
|
||||
// Strip configured prefixes before parsing
|
||||
cleanTagName := StripTagPrefix(tagName, prefixes)
|
||||
|
||||
// Check for release candidate pattern (-rc.X) before splitting
|
||||
isReleaseCandidate := false
|
||||
rcVersion := 0
|
||||
if idx := strings.Index(cleanTagName, "-rc."); idx != -1 {
|
||||
isReleaseCandidate = true
|
||||
rcPart := cleanTagName[idx+4:] // Get everything after "-rc."
|
||||
rcMatches := extractNumber.FindAllString(rcPart, 1)
|
||||
if len(rcMatches) > 0 {
|
||||
rcVersion, _ = strconv.Atoi(rcMatches[0])
|
||||
}
|
||||
// Remove the RC suffix for version parsing
|
||||
cleanTagName = cleanTagName[:idx]
|
||||
Debug("Detected release candidate", map[string]interface{}{
|
||||
"rc_version": rcVersion,
|
||||
"clean_tag_name": cleanTagName,
|
||||
})
|
||||
}
|
||||
|
||||
tagNameParts := strings.Split(cleanTagName, ".")
|
||||
if len(tagNameParts) < 3 {
|
||||
Debug("Unable to parse incompatible semver (non x.y.z)", map[string]interface{}{"tag": tagName})
|
||||
return currentSemver
|
||||
}
|
||||
|
||||
|
||||
semanticVersion := SemVer{}
|
||||
|
||||
|
||||
// Extract major version
|
||||
majorMatches := extractNumber.FindAllString(tagNameParts[0], -1)
|
||||
if len(majorMatches) > 0 {
|
||||
semanticVersion.Major, _ = strconv.Atoi(majorMatches[0])
|
||||
}
|
||||
|
||||
|
||||
// Extract minor version
|
||||
minorMatches := extractNumber.FindAllString(tagNameParts[1], -1)
|
||||
if len(minorMatches) > 0 {
|
||||
semanticVersion.Minor, _ = strconv.Atoi(minorMatches[0])
|
||||
}
|
||||
|
||||
|
||||
// Extract patch version
|
||||
patchMatches := extractNumber.FindAllString(tagNameParts[2], -1)
|
||||
if len(patchMatches) > 0 {
|
||||
semanticVersion.Patch, _ = strconv.Atoi(patchMatches[0])
|
||||
}
|
||||
|
||||
// Extract release candidate version if present
|
||||
if len(tagNameParts) > 3 {
|
||||
releaseMatches := extractNumber.FindAllString(tagNameParts[3], -1)
|
||||
if len(releaseMatches) > 0 {
|
||||
semanticVersion.Release, _ = strconv.Atoi(releaseMatches[0])
|
||||
semanticVersion.EnableReleaseCandidate = true
|
||||
}
|
||||
|
||||
// Set release candidate if detected
|
||||
if isReleaseCandidate {
|
||||
semanticVersion.Release = rcVersion
|
||||
semanticVersion.EnableReleaseCandidate = true
|
||||
}
|
||||
|
||||
|
||||
return semanticVersion
|
||||
}
|
||||
|
||||
// CheckMatches checks if any of the targets match the content
|
||||
func CheckMatches(content []string, targets []string, blacklist []string) bool {
|
||||
contentStr := strings.Join(content, " ")
|
||||
|
||||
|
||||
// First check if any target matches
|
||||
hasMatch := false
|
||||
for _, tgt := range targets {
|
||||
@@ -104,8 +154,8 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
|
||||
if len(matches) > 0 {
|
||||
hasMatch = true
|
||||
Debug("Found match", map[string]interface{}{
|
||||
"target": tgt,
|
||||
"match": strings.Join(matches, ","),
|
||||
"target": tgt,
|
||||
"match": strings.Join(matches, ","),
|
||||
"content": contentStr,
|
||||
})
|
||||
break
|
||||
@@ -117,14 +167,14 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
|
||||
for _, blacklistTerm := range blacklist {
|
||||
if strings.Contains(strings.ToLower(contentStr), strings.ToLower(blacklistTerm)) {
|
||||
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
|
||||
"content": contentStr,
|
||||
"content": contentStr,
|
||||
"blacklist_term": blacklistTerm,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return hasMatch
|
||||
}
|
||||
|
||||
@@ -132,4 +182,4 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
|
||||
var FuzzyFind = func(needle string, haystack []string) []string {
|
||||
// This will be replaced with the actual implementation in main.go
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
+64
-11
@@ -58,15 +58,17 @@ func TestParseExistingSemver(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tagName string
|
||||
name string
|
||||
tagName string
|
||||
currentSemver SemVer
|
||||
want SemVer
|
||||
prefixes []string
|
||||
want SemVer
|
||||
}{
|
||||
{
|
||||
name: "Standard semver",
|
||||
tagName: "1.2.3",
|
||||
name: "Standard semver",
|
||||
tagName: "1.2.3",
|
||||
currentSemver: SemVer{},
|
||||
prefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 1,
|
||||
Minor: 2,
|
||||
@@ -74,9 +76,10 @@ func TestParseExistingSemver(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With v prefix",
|
||||
tagName: "v2.3.4",
|
||||
name: "With v prefix configured",
|
||||
tagName: "v2.3.4",
|
||||
currentSemver: SemVer{},
|
||||
prefixes: []string{"v"},
|
||||
want: SemVer{
|
||||
Major: 2,
|
||||
Minor: 3,
|
||||
@@ -84,9 +87,32 @@ func TestParseExistingSemver(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With release candidate",
|
||||
tagName: "3.4.5-rc.2",
|
||||
name: "With app- prefix configured",
|
||||
tagName: "app-1.2.3",
|
||||
currentSemver: SemVer{},
|
||||
prefixes: []string{"app-", "infra-"},
|
||||
want: SemVer{
|
||||
Major: 1,
|
||||
Minor: 2,
|
||||
Patch: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With prefix but not in config - should still parse numbers",
|
||||
tagName: "v2.3.4",
|
||||
currentSemver: SemVer{},
|
||||
prefixes: []string{}, // v not in prefixes
|
||||
want: SemVer{
|
||||
Major: 2,
|
||||
Minor: 3,
|
||||
Patch: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With release candidate",
|
||||
tagName: "3.4.5-rc.2",
|
||||
currentSemver: SemVer{},
|
||||
prefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 3,
|
||||
Minor: 4,
|
||||
@@ -95,6 +121,31 @@ func TestParseExistingSemver(t *testing.T) {
|
||||
EnableReleaseCandidate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With prefix and release candidate",
|
||||
tagName: "app-1.0.0-rc.3",
|
||||
currentSemver: SemVer{},
|
||||
prefixes: []string{"app-"},
|
||||
want: SemVer{
|
||||
Major: 1,
|
||||
Minor: 0,
|
||||
Patch: 0,
|
||||
Release: 3,
|
||||
EnableReleaseCandidate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Prefixed tag without RC should NOT be RC",
|
||||
tagName: "app-0.0.16",
|
||||
currentSemver: SemVer{},
|
||||
prefixes: []string{"app-"},
|
||||
want: SemVer{
|
||||
Major: 0,
|
||||
Minor: 0,
|
||||
Patch: 16,
|
||||
EnableReleaseCandidate: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid format",
|
||||
tagName: "not-a-semver",
|
||||
@@ -103,6 +154,7 @@ func TestParseExistingSemver(t *testing.T) {
|
||||
Minor: 1,
|
||||
Patch: 1,
|
||||
},
|
||||
prefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 1,
|
||||
Minor: 1,
|
||||
@@ -117,6 +169,7 @@ func TestParseExistingSemver(t *testing.T) {
|
||||
Minor: 5,
|
||||
Patch: 5,
|
||||
},
|
||||
prefixes: []string{},
|
||||
want: SemVer{
|
||||
Major: 5,
|
||||
Minor: 5,
|
||||
@@ -127,7 +180,7 @@ func TestParseExistingSemver(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseExistingSemver(tt.tagName, tt.currentSemver)
|
||||
got := ParseExistingSemver(tt.tagName, tt.currentSemver, tt.prefixes)
|
||||
assert.Equal(t, tt.want.Major, got.Major, "Major version mismatch")
|
||||
assert.Equal(t, tt.want.Minor, got.Minor, "Minor version mismatch")
|
||||
assert.Equal(t, tt.want.Patch, got.Patch, "Patch version mismatch")
|
||||
@@ -196,4 +249,4 @@ func TestCheckMatches(t *testing.T) {
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ force:
|
||||
existing: true
|
||||
strict: false
|
||||
commit: 960207e4677476ad31a9f389f74eaf9f33d49613
|
||||
# tag_prefixes: (optional) prefixes to strip from existing tags
|
||||
# Note: "v" prefix is always stripped automatically
|
||||
# tag_prefixes:
|
||||
# - "app-"
|
||||
# - "service-"
|
||||
wording:
|
||||
patch:
|
||||
- update
|
||||
|
||||
@@ -8,6 +8,10 @@ blacklist:
|
||||
- "Merge pull request"
|
||||
- "feature/"
|
||||
- "feature:"
|
||||
tag_prefixes:
|
||||
# Note: "v" prefix is stripped automatically, no need to include it here
|
||||
- "app-"
|
||||
- "infra-"
|
||||
wording:
|
||||
patch:
|
||||
- update
|
||||
|
||||
Reference in New Issue
Block a user