Files
semver-generator/cmd/utils/version.go
T
lukaszraczylo e90191de4c feat(matching): add an opt-in regex keyword matching mode
Fuzzy matching compares each keyword with every word of the commit
message. A word matches if it contains the keyword's letters in order.
Thus "feat" matches "fix(delegation):" and "major" matches
"deps(majors):", which gives false minor and major versions.

The new top-level config key "matching" accepts "fuzzy" (the default)
or "regex". In regex mode every wording keyword is a Go regular
expression. The tool compiles all of them when it loads the config and
runs them against the full commit message, so "(?m)^" anchors a
pattern to the start of a line. An invalid value, an invalid pattern,
or any other config parse error in regex mode stops the tool with a
non-zero exit code. It never falls back to fuzzy matching.

Fuzzy mode does not change. The blacklist check moves into one helper
that both modes use. A replay of 15 repositories gives the same
versions as before. This change improves version precision for the
repositories that opt in.

Claude-Session: https://claude.ai/code/session_01Qq1gbqQek1Zjtofo4VDRdW
2026-09-25 19:35:48 +01:00

222 lines
6.1 KiB
Go

package utils
import (
"regexp"
"strconv"
"strings"
)
// SemVer represents a semantic version
type SemVer struct {
Patch int
Minor int
Major int
Release int
EnableReleaseCandidate bool
}
// FormatSemver formats a semantic version as a string
func FormatSemver(semver SemVer) string {
result := strings.TrimSpace(
strings.Join(
[]string{
strconv.Itoa(semver.Major),
strconv.Itoa(semver.Minor),
strconv.Itoa(semver.Patch),
},
".",
),
)
if semver.EnableReleaseCandidate {
result = strings.TrimSpace(
strings.Join(
[]string{
result,
strings.Join(
[]string{
"rc",
strconv.Itoa(semver.Release),
},
".",
),
},
"-",
),
)
}
return result
}
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
}
// IsParseableSemverTag reports whether tagName looks like a proper semver tag
// (X.Y.Z, optionally vX.Y.Z, optionally with -rc.N suffix) after configured
// prefixes are stripped. Rolling tags like "v1" or "latest" return false so the
// calculator can skip them when picking the latest existing tag — preventing a
// rolling tag tied to the same commit as a real semver tag from "winning" the
// alphabetical iteration in go-git and resetting the baseline to 0.0.0.
func IsParseableSemverTag(tagName string, prefixes []string) bool {
clean := StripTagPrefix(tagName, prefixes)
if idx := strings.Index(clean, "-rc."); idx != -1 {
clean = clean[:idx]
}
parts := strings.Split(clean, ".")
if len(parts) < 3 {
return false
}
for _, p := range parts[:3] {
if len(extractNumber.FindAllString(p, -1)) == 0 {
return false
}
}
return true
}
// ParseExistingSemver parses a semantic version from a tag name
func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string) SemVer {
Debug("Parsing existing semver", map[string]interface{}{"tag": 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])
}
// Set release candidate if detected
if isReleaseCandidate {
semanticVersion.Release = rcVersion
semanticVersion.EnableReleaseCandidate = true
}
return semanticVersion
}
// isBlacklisted reports whether contentStr contains any blacklist term
// (case-insensitive substring match), logging the same way every caller used
// to log it inline. Both matching modes call this so blacklist semantics stay
// identical between fuzzy and regex matching.
func isBlacklisted(contentStr string, blacklist []string) bool {
if len(blacklist) == 0 {
return false
}
lowerContent := strings.ToLower(contentStr)
for _, blacklistTerm := range blacklist {
if strings.Contains(lowerContent, strings.ToLower(blacklistTerm)) {
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
"content": contentStr,
"blacklist_term": blacklistTerm,
})
return true
}
}
return false
}
// 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 {
matches := FuzzyFind(tgt, content)
if len(matches) > 0 {
hasMatch = true
Debug("Found match", map[string]interface{}{
"target": tgt,
"match": strings.Join(matches, ","),
"content": contentStr,
})
break
}
}
// If we have a match, check against blacklist
if hasMatch && isBlacklisted(contentStr, blacklist) {
return false
}
return hasMatch
}
// FuzzyFind is a wrapper for the fuzzy search library to make it easier to mock in tests
var FuzzyFind = func(needle string, haystack []string) []string {
// This will be replaced with the actual implementation in main.go
return nil
}