mirror of
https://github.com/lukaszraczylo/semver-generator.git
synced 2026-10-12 00:45:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ae4c042f | ||
|
|
e90191de4c | ||
|
|
9617b281a9 | ||
|
|
6ba4a2674f | ||
|
|
e9507dd3ff | ||
|
|
b5eb8ae15a | ||
|
|
0da4aa71e1 | ||
|
|
ca874d21e5 | ||
|
|
4c21c3f24f | ||
|
|
9d5b4b0b50 | ||
|
|
8459e0ad1d |
@@ -14,6 +14,10 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||
@@ -27,11 +31,11 @@ jobs:
|
||||
update-action-version:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.release.outputs.version != ''
|
||||
if: github.ref == 'refs/heads/main' && needs.release.outputs.version != ''
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
ref: ${{ github.sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update action.yml with release version
|
||||
@@ -52,5 +56,17 @@ jobs:
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "chore: pin action.yml Docker image to v${{ needs.release.outputs.version }}"
|
||||
git push
|
||||
# Push the pin commit onto main explicitly (not just "git push"):
|
||||
# this checkout started from the released commit (github.sha), not
|
||||
# main's tip, so if main moved in the meantime this push is a
|
||||
# non-fast-forward and fails safely instead of overwriting it.
|
||||
git push origin HEAD:main
|
||||
# Only move the rolling tag once the pin commit is confirmed on
|
||||
# main. The "release" job above already moved it to the release
|
||||
# commit, but this job then commits action.yml's image pin on top
|
||||
# of that, so without this the rolling tag would always be one
|
||||
# commit behind, pinning the PREVIOUS image.
|
||||
# "v1" must match the release job's rolling-release-tag input.
|
||||
git tag -f v1 HEAD
|
||||
git push -f origin refs/tags/v1
|
||||
fi
|
||||
|
||||
@@ -21,6 +21,7 @@ Project created overnight, to prove that management of semantic versioning is NO
|
||||
- [Calculations example \[strict matching\]](#calculations-example-strict-matching)
|
||||
- [Release candidates](#release-candidates)
|
||||
- [Tag prefix stripping](#tag-prefix-stripping)
|
||||
- [Matching modes](#matching-modes)
|
||||
- [Example configuration](#example-configuration)
|
||||
- [Good to knows](#good-to-knows)
|
||||
- [Telemetry](#telemetry)
|
||||
@@ -238,6 +239,30 @@ This is particularly useful when:
|
||||
- Your CI/CD creates tags with component prefixes
|
||||
- You want to track versions separately for different parts of your codebase
|
||||
|
||||
#### Matching modes
|
||||
|
||||
Config key `matching` controls how each keyword in `wording` is checked against a commit message. Allowed values are `fuzzy` (the default, also used when the key is absent) and `regex`. Any other value is a config error and `semver-gen` exits non-zero rather than silently falling back to `fuzzy`.
|
||||
|
||||
**`fuzzy` (default):** the commit message is split on whitespace into words, and a keyword matches as soon as one word contains the keyword's letters *in order*, case-insensitive — it does not need to be a whole word or an exact substring. This is convenient but has a real false-positive risk: keyword `feat` also matches the word `fix(delegation):` (f-e-a-t appear in order inside "fix(delegation):"), and keyword `major` matches `deps(majors):`. Short, generic keywords are the most likely to over-match; test your wording list against real commit messages before relying on it.
|
||||
|
||||
**`regex`:** every keyword in `wording.patch` / `wording.minor` / `wording.major` / `wording.release` is a Go RE2 regular expression (the standard library `regexp` package). Patterns are compiled once when the config is loaded — an invalid pattern makes `semver-gen` exit non-zero immediately, naming the wording list and the keyword. Each pattern is matched with `MatchString` against the **full raw commit message** (subject and body, with the original newlines, not the whitespace-split words `fuzzy` uses), so `(?m)^feat(\([^)]*\))?!?:` anchors to the start of a line. Matching is case-sensitive unless the pattern itself uses `(?i)`. The blacklist works exactly the same way in both modes.
|
||||
|
||||
Regex patterns must be quoted in YAML (for example `'(?m)^fix:'`), since unquoted `^`, `:` and other YAML-significant characters can break the file's syntax; a YAML syntax error makes the tool fall back to its defaults, the same as it already does for any unreadable config file.
|
||||
|
||||
```yaml
|
||||
matching: regex
|
||||
wording:
|
||||
patch:
|
||||
- '^fix(\([^)]*\))?:'
|
||||
minor:
|
||||
- '(?m)^feat(\([^)]*\))?!?:'
|
||||
major:
|
||||
# a trailer line anywhere in the commit body, e.g. "Semver-Major: dropped the v1 API"
|
||||
- '(?mi)^semver-major:'
|
||||
```
|
||||
|
||||
With this config, a subject line of `fix: correct off-by-one` bumps patch, `feat: add widget` bumps minor, and a body trailer of `Semver-Major: ...` bumps major regardless of case. `fix(delegation): route around dead worker` bumps patch too — the patch pattern's `(\([^)]*\))?` group allows an optional scope — but it does NOT bump minor, unlike `fuzzy` mode, where keyword `feat` matches it (see above).
|
||||
|
||||
#### Example configuration
|
||||
|
||||
```yaml
|
||||
@@ -276,11 +301,12 @@ wording:
|
||||
* `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.
|
||||
* `matching`: how `wording` keywords are matched against commit messages — `fuzzy` (default) or `regex`. See [Matching modes](#matching-modes).
|
||||
* `wording`: words the program should look for in the git commits to increment (patch|minor|major)
|
||||
|
||||
### Good to knows
|
||||
|
||||
* Word matching uses fuzzy search AND is case INSENSITIVE
|
||||
* Word matching uses fuzzy search AND is case INSENSITIVE by default; set `matching: regex` for anchored, case-sensitive regular expression matching instead — see [Matching modes](#matching-modes)
|
||||
* I do not recommend using common words ( like "the" from the example configuration )
|
||||
* You can specify env variable `LOG_LEVEL=debug` to see what exactly happens during the calculations
|
||||
|
||||
|
||||
+1
-1
@@ -36,4 +36,4 @@ outputs:
|
||||
description: "Calculated semantic version"
|
||||
runs:
|
||||
using: "docker"
|
||||
image: "docker://ghcr.io/lukaszraczylo/semver-generator:1.17.10"
|
||||
image: "docker://ghcr.io/lukaszraczylo/semver-generator:1.17.20"
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ limitations under the License.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -96,6 +97,15 @@ func main() {
|
||||
// Read configuration
|
||||
config, err := utils.ReadConfig(repo.LocalConfigFile)
|
||||
if err != nil {
|
||||
// A bad "matching" value or an invalid regex keyword is a real
|
||||
// misconfiguration, not a missing-file situation: fail loudly
|
||||
// instead of silently falling back to fuzzy matching.
|
||||
if errors.Is(err, utils.ErrInvalidMatchingConfig) {
|
||||
utils.Critical("Invalid matching configuration", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
os.Exit(1)
|
||||
}
|
||||
utils.Error("Unable to find config file. Using defaults and flags.", map[string]interface{}{
|
||||
"file": repo.LocalConfigFile,
|
||||
})
|
||||
@@ -140,6 +150,7 @@ func main() {
|
||||
repo.GitRepo.Commits,
|
||||
repo.GitRepo.Tags,
|
||||
repo.Config.Wording,
|
||||
repo.Config.Matcher,
|
||||
repo.Config.Blacklist,
|
||||
repo.Semver,
|
||||
params.varExisting || repo.Config.Force.Existing,
|
||||
|
||||
+44
-4
@@ -30,6 +30,8 @@ type Config struct {
|
||||
Force Force
|
||||
Blacklist []string
|
||||
TagPrefixes []string // Prefixes to strip from tags before parsing (e.g., "app-", "infra-", "v")
|
||||
Matching string // Effective matching mode: MatchingFuzzy (default) or MatchingRegex
|
||||
Matcher KeywordMatcher
|
||||
}
|
||||
|
||||
// ReadConfig reads the configuration from a file
|
||||
@@ -43,19 +45,57 @@ func ReadConfig(file string) (*Config, error) {
|
||||
return config, err
|
||||
}
|
||||
|
||||
// Read "matching" first, right after ReadInConfig, so every later parse
|
||||
// error below can tell whether regex mode is in play. viper.Get (not
|
||||
// GetString) lets us reject a non-scalar value, such as a YAML list or
|
||||
// map, as a config error instead of silently treating it as "fuzzy".
|
||||
switch rawMatching := viper.Get("matching"); v := rawMatching.(type) {
|
||||
case nil:
|
||||
config.Matching = MatchingFuzzy
|
||||
case string:
|
||||
switch v {
|
||||
case "":
|
||||
config.Matching = MatchingFuzzy
|
||||
case MatchingFuzzy, MatchingRegex:
|
||||
config.Matching = v
|
||||
default:
|
||||
return config, fmt.Errorf("%w: invalid matching value %q, allowed values are %q and %q", ErrInvalidMatchingConfig, v, MatchingFuzzy, MatchingRegex)
|
||||
}
|
||||
default:
|
||||
return config, fmt.Errorf("%w: matching config must be a string, got %v (%T)", ErrInvalidMatchingConfig, v, v)
|
||||
}
|
||||
|
||||
// In regex mode there is no fuzzy fallback: any config parse error below
|
||||
// (wording/force/blacklist/tag_prefixes) is a real misconfiguration, so
|
||||
// wrap it with ErrInvalidMatchingConfig, which main.go treats as fatal.
|
||||
// In fuzzy mode (default), behaviour is unchanged: the error is returned
|
||||
// as-is and the caller logs it and continues with defaults.
|
||||
wrapIfRegex := func(err error) error {
|
||||
if err == nil || config.Matching != MatchingRegex {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrInvalidMatchingConfig, err)
|
||||
}
|
||||
|
||||
if err := viper.UnmarshalKey("wording", &config.Wording); err != nil {
|
||||
return config, fmt.Errorf("error parsing wording config: %w", err)
|
||||
return config, wrapIfRegex(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)
|
||||
return config, wrapIfRegex(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)
|
||||
return config, wrapIfRegex(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, wrapIfRegex(fmt.Errorf("error parsing tag_prefixes config: %w", err))
|
||||
}
|
||||
|
||||
matcher, err := NewKeywordMatcher(config.Matching, config.Wording)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.Matcher = matcher
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplyForcedVersioning(t *testing.T) {
|
||||
@@ -199,3 +202,198 @@ wording:
|
||||
_, err = ReadConfig("non-existent-file.yaml")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func writeTempConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
tempFile, err := os.CreateTemp("", "semver-config-matching-*.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
if _, err := tempFile.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("Failed to write to temp file: %v", err)
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
t.Fatalf("Failed to close temp file: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Remove(tempFile.Name()); err != nil && !os.IsNotExist(err) {
|
||||
t.Logf("failed to remove temp config %s: %v", tempFile.Name(), err)
|
||||
}
|
||||
})
|
||||
return tempFile.Name()
|
||||
}
|
||||
|
||||
func TestReadConfigMatching(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
t.Run("absent matching key defaults to fuzzy", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
wording:
|
||||
patch:
|
||||
- feat
|
||||
`)
|
||||
config, err := ReadConfig(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, MatchingFuzzy, config.Matching)
|
||||
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
|
||||
|
||||
// Behavioural check, not just the type: absent/fuzzy mode means
|
||||
// "fix(delegation): x" fuzzy-matches keyword "feat", using the real
|
||||
// fuzzy library (the same one main.go wires up), not a stand-in.
|
||||
originalFuzzyFind := FuzzyFind
|
||||
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
|
||||
FuzzyFind = fuzzy.FindNormalizedFold
|
||||
assert.True(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Patch, nil))
|
||||
})
|
||||
|
||||
t.Run("explicit fuzzy", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching: fuzzy
|
||||
wording:
|
||||
patch:
|
||||
- update
|
||||
`)
|
||||
config, err := ReadConfig(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, MatchingFuzzy, config.Matching)
|
||||
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
|
||||
|
||||
// Behavioural check, not just the type: same as the absent-key
|
||||
// subtest above, using the real fuzzy library.
|
||||
originalFuzzyFind := FuzzyFind
|
||||
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
|
||||
FuzzyFind = fuzzy.FindNormalizedFold
|
||||
assert.True(t, config.Matcher.Matches("fix(delegation): x", []string{"feat"}, nil))
|
||||
})
|
||||
|
||||
t.Run("explicit regex compiles keywords", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching: regex
|
||||
wording:
|
||||
patch:
|
||||
- "^fix:"
|
||||
minor:
|
||||
- "(?m)^feat(\\([^)]*\\))?!?:"
|
||||
major:
|
||||
- "(?mi)^semver-major:"
|
||||
`)
|
||||
config, err := ReadConfig(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, MatchingRegex, config.Matching)
|
||||
assert.IsType(t, RegexMatcher{}, config.Matcher)
|
||||
// Behavioural check: "fix(delegation): x" matches fuzzy keyword "feat"
|
||||
// (see the absent/fuzzy subtest below) but must NOT match this
|
||||
// anchored regex pattern.
|
||||
assert.False(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Minor, nil))
|
||||
})
|
||||
|
||||
t.Run("unknown matching value errors naming value and allowed values", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching: substring
|
||||
wording:
|
||||
patch:
|
||||
- update
|
||||
`)
|
||||
_, err := ReadConfig(path)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "substring")
|
||||
assert.Contains(t, err.Error(), MatchingFuzzy)
|
||||
assert.Contains(t, err.Error(), MatchingRegex)
|
||||
})
|
||||
|
||||
// Fix 1: an invalid "matching" value must be rejected immediately, before
|
||||
// wording/force/blacklist/tag_prefixes are ever parsed. Previously a
|
||||
// mistyped value like "substring" was accepted as-is by the case string
|
||||
// branch, so a malformed wording section below it returned an unwrapped
|
||||
// parse error instead of ErrInvalidMatchingConfig, and main() silently
|
||||
// fell back to fuzzy mode instead of failing loudly.
|
||||
t.Run("invalid matching value combined with malformed wording section still errors as ErrInvalidMatchingConfig", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching: substring
|
||||
wording: "not-a-map"
|
||||
`)
|
||||
_, err := ReadConfig(path)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "substring")
|
||||
})
|
||||
|
||||
t.Run("invalid regex errors naming wording list and keyword", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching: regex
|
||||
wording:
|
||||
major:
|
||||
- "[unterminated("
|
||||
`)
|
||||
_, err := ReadConfig(path)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "major")
|
||||
assert.Contains(t, err.Error(), "[unterminated(")
|
||||
})
|
||||
|
||||
// Fix 2: no silent fuzzy fallback in regex mode. A malformed "wording"
|
||||
// section (a scalar instead of a map) fails viper.UnmarshalKey, not
|
||||
// NewKeywordMatcher. In regex mode that must still be a fatal,
|
||||
// ErrInvalidMatchingConfig-wrapped error — main() exits non-zero on it
|
||||
// (see cmd/main.go's errors.Is(err, utils.ErrInvalidMatchingConfig)
|
||||
// check) instead of silently logging and falling back to fuzzy matching.
|
||||
t.Run("regex mode + malformed wording section is a fatal error, not a fuzzy fallback", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching: regex
|
||||
wording: "not-a-map"
|
||||
`)
|
||||
_, err := ReadConfig(path)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig, "main() only treats ErrInvalidMatchingConfig as fatal; anything else falls back to fuzzy and continues")
|
||||
assert.Contains(t, err.Error(), "wording")
|
||||
})
|
||||
|
||||
// Contrast case: the exact same malformed "wording" section in fuzzy mode
|
||||
// (the default) must behave exactly as it did before regex mode existed —
|
||||
// an error is returned, but it is NOT wrapped in ErrInvalidMatchingConfig,
|
||||
// so main() logs it and continues with defaults rather than exiting.
|
||||
t.Run("fuzzy mode + malformed wording section stays non-fatal, same as today", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
wording: "not-a-map"
|
||||
`)
|
||||
_, err := ReadConfig(path)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, errors.Is(err, ErrInvalidMatchingConfig), "fuzzy mode must not fail loudly for non-matching config errors")
|
||||
})
|
||||
|
||||
// Fix 4: a non-scalar "matching" value (a YAML list or map) must be an
|
||||
// ErrInvalidMatchingConfig error naming the value, never a silent fuzzy
|
||||
// fallback.
|
||||
t.Run("matching as a YAML list is an error naming the value", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching:
|
||||
- fuzzy
|
||||
- regex
|
||||
wording:
|
||||
patch:
|
||||
- update
|
||||
`)
|
||||
_, err := ReadConfig(path)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "fuzzy")
|
||||
assert.Contains(t, err.Error(), "regex")
|
||||
})
|
||||
|
||||
t.Run("matching as a YAML map is an error naming the value", func(t *testing.T) {
|
||||
path := writeTempConfig(t, `
|
||||
matching:
|
||||
mode: regex
|
||||
wording:
|
||||
patch:
|
||||
- update
|
||||
`)
|
||||
_, err := ReadConfig(path)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "mode")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package utils implements semver-gen's configuration loading, git access,
|
||||
// commit-keyword matching (fuzzy and regex) and version calculation.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Matching mode values accepted by the "matching" config key.
|
||||
const (
|
||||
MatchingFuzzy = "fuzzy"
|
||||
MatchingRegex = "regex"
|
||||
)
|
||||
|
||||
// ErrInvalidMatchingConfig is wrapped by any error NewKeywordMatcher returns.
|
||||
// Callers use errors.Is to tell a bad "matching" value or a bad regex keyword
|
||||
// apart from other, non-fatal config problems.
|
||||
var ErrInvalidMatchingConfig = errors.New("invalid matching configuration")
|
||||
|
||||
// KeywordMatcher decides whether a commit message matches one of a wording
|
||||
// list's keywords, honoring the blacklist. CalculateSemver picks one
|
||||
// implementation, chosen once from config, instead of branching on the
|
||||
// matching mode itself.
|
||||
type KeywordMatcher interface {
|
||||
Matches(commitMessage string, keywords []string, blacklist []string) bool
|
||||
}
|
||||
|
||||
// FuzzyMatcher reproduces the original matching behaviour: the commit message
|
||||
// is split into whitespace-separated words and each keyword is fuzzy-matched
|
||||
// (letters in order, case-insensitive) against those words via FuzzyFind.
|
||||
type FuzzyMatcher struct{}
|
||||
|
||||
// Matches implements KeywordMatcher.
|
||||
func (FuzzyMatcher) Matches(commitMessage string, keywords []string, blacklist []string) bool {
|
||||
return CheckMatches(strings.Fields(commitMessage), keywords, blacklist)
|
||||
}
|
||||
|
||||
// RegexMatcher matches keywords, each a Go RE2 regular expression compiled
|
||||
// once up front, against the full raw commit message (subject and body,
|
||||
// newlines preserved).
|
||||
type RegexMatcher struct {
|
||||
compiled map[string]*regexp.Regexp
|
||||
}
|
||||
|
||||
// Matches implements KeywordMatcher.
|
||||
func (m RegexMatcher) Matches(commitMessage string, keywords []string, blacklist []string) bool {
|
||||
hasMatch := false
|
||||
for _, keyword := range keywords {
|
||||
re, ok := m.compiled[keyword]
|
||||
if !ok {
|
||||
// NewKeywordMatcher compiles every keyword of every wording list up
|
||||
// front, so this means Matches was called with a keyword that
|
||||
// wasn't part of the wording the matcher was built from. Log it
|
||||
// loudly instead of silently skipping, and treat it as no-match
|
||||
// rather than panicking on a nil regexp.
|
||||
Error("Regex keyword was never compiled; treating as no-match", map[string]interface{}{
|
||||
"keyword": keyword,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if re.MatchString(commitMessage) {
|
||||
hasMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasMatch {
|
||||
return false
|
||||
}
|
||||
|
||||
// Match blacklist semantics exactly the same way CheckMatches (fuzzy mode)
|
||||
// does: whitespace-normalized content, so a blacklist term split across a
|
||||
// newline or tab in the raw commit message still suppresses the match.
|
||||
contentStr := strings.Join(strings.Fields(commitMessage), " ")
|
||||
return !isBlacklisted(contentStr, blacklist)
|
||||
}
|
||||
|
||||
// NewKeywordMatcher builds the KeywordMatcher selected by matching ("" and
|
||||
// "fuzzy" both mean fuzzy, "regex" means regex; anything else is an error).
|
||||
// In regex mode every keyword across all four wording lists is compiled
|
||||
// exactly once here, so a bad pattern is caught at config-load time instead
|
||||
// of failing mid-run. Compile and validation errors wrap
|
||||
// ErrInvalidMatchingConfig so callers can fail loudly on them.
|
||||
func NewKeywordMatcher(matching string, wording Wording) (KeywordMatcher, error) {
|
||||
switch matching {
|
||||
case "", MatchingFuzzy:
|
||||
return FuzzyMatcher{}, nil
|
||||
case MatchingRegex:
|
||||
wordingLists := []struct {
|
||||
name string
|
||||
keywords []string
|
||||
}{
|
||||
{"patch", wording.Patch},
|
||||
{"minor", wording.Minor},
|
||||
{"major", wording.Major},
|
||||
{"release", wording.Release},
|
||||
}
|
||||
|
||||
compiled := make(map[string]*regexp.Regexp)
|
||||
for _, list := range wordingLists {
|
||||
for _, keyword := range list.keywords {
|
||||
if _, alreadyCompiled := compiled[keyword]; alreadyCompiled {
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(keyword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid regex in wording.%s keyword %q: %v", ErrInvalidMatchingConfig, list.name, keyword, err)
|
||||
}
|
||||
compiled[keyword] = re
|
||||
}
|
||||
}
|
||||
return RegexMatcher{compiled: compiled}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: invalid matching value %q, allowed values are %q and %q", ErrInvalidMatchingConfig, matching, MatchingFuzzy, MatchingRegex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewKeywordMatcher(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
wording := Wording{
|
||||
Patch: []string{"^fix:"},
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
Major: []string{"(?mi)^semver-major:"},
|
||||
}
|
||||
|
||||
t.Run("empty matching defaults to fuzzy", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher("", wording)
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, FuzzyMatcher{}, m)
|
||||
// Behavioural check, not just the type: fuzzy matching means
|
||||
// "fix(delegation): x" still matches keyword "feat" (f-e-a-t appear
|
||||
// in order inside "fix(delegation):"), using the real fuzzy library
|
||||
// (the same one main.go wires up), not a stand-in.
|
||||
originalFuzzyFind := FuzzyFind
|
||||
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
|
||||
FuzzyFind = fuzzy.FindNormalizedFold
|
||||
assert.True(t, m.Matches("fix(delegation): x", []string{"feat"}, nil))
|
||||
})
|
||||
|
||||
t.Run("fuzzy", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingFuzzy, wording)
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, FuzzyMatcher{}, m)
|
||||
|
||||
// Behavioural check, not just the type: same as the empty-matching
|
||||
// subtest above, using the real fuzzy library.
|
||||
originalFuzzyFind := FuzzyFind
|
||||
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
|
||||
FuzzyFind = fuzzy.FindNormalizedFold
|
||||
assert.True(t, m.Matches("fix(delegation): x", []string{"feat"}, nil))
|
||||
})
|
||||
|
||||
t.Run("regex compiles every keyword once", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingRegex, wording)
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, RegexMatcher{}, m)
|
||||
// Behavioural check: the same input that fuzzy-matches "feat" does not
|
||||
// match the anchored regex pattern in wording.Minor.
|
||||
assert.False(t, m.Matches("fix(delegation): x", wording.Minor, nil))
|
||||
})
|
||||
|
||||
t.Run("unknown value errors naming value and allowed values", func(t *testing.T) {
|
||||
_, err := NewKeywordMatcher("weird", wording)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "weird")
|
||||
assert.Contains(t, err.Error(), MatchingFuzzy)
|
||||
assert.Contains(t, err.Error(), MatchingRegex)
|
||||
})
|
||||
|
||||
t.Run("invalid regex errors naming wording list and keyword", func(t *testing.T) {
|
||||
badWording := Wording{Major: []string{"[unterminated("}}
|
||||
_, err := NewKeywordMatcher(MatchingRegex, badWording)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "major")
|
||||
assert.Contains(t, err.Error(), "[unterminated(")
|
||||
})
|
||||
|
||||
t.Run("invalid release regex errors naming wording list and keyword", func(t *testing.T) {
|
||||
badWording := Wording{Release: []string{"[unterminated("}}
|
||||
_, err := NewKeywordMatcher(MatchingRegex, badWording)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "release")
|
||||
assert.Contains(t, err.Error(), "[unterminated(")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegexMatcherMatches(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
t.Run("anchored feat subject matches", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("feat: add widget\n\nsome body text", []string{`(?m)^feat(\([^)]*\))?!?:`}, nil)
|
||||
assert.True(t, got)
|
||||
})
|
||||
|
||||
t.Run("fix(delegation) subject does not match anchored feat pattern", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("fix(delegation): route around dead worker", []string{`(?m)^feat(\([^)]*\))?!?:`}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
|
||||
t.Run("body trailer Semver-Major matches multiline case-insensitive pattern", func(t *testing.T) {
|
||||
pattern := "(?mi)^semver-major:"
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
message := "fix: patch a bug\n\nDetails here.\n\nSemver-Major: breaking storage format\n"
|
||||
got := m.Matches(message, []string{pattern}, nil)
|
||||
assert.True(t, got)
|
||||
})
|
||||
|
||||
t.Run("feat! subject does not match a trailer-only major pattern", func(t *testing.T) {
|
||||
pattern := "(?mi)^semver-major:"
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("feat!: rework the API\n\nno trailer here", []string{pattern}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
|
||||
t.Run("case sensitivity: pattern without (?i) does not match different case", func(t *testing.T) {
|
||||
pattern := "^FEAT:"
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, m.Matches("feat: lowercase subject", []string{pattern}, nil))
|
||||
assert.True(t, m.Matches("FEAT: uppercase subject", []string{pattern}, nil))
|
||||
})
|
||||
|
||||
t.Run("blacklist still suppresses a regex match", func(t *testing.T) {
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
message := "feat: add widget [skip-ci]"
|
||||
assert.True(t, m.Matches(message, []string{pattern}, nil))
|
||||
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip-ci"}))
|
||||
})
|
||||
|
||||
t.Run("blacklist term split by a newline still suppresses, same as fuzzy mode", func(t *testing.T) {
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
// Blacklist term is "skip ci", but the raw commit message has it split
|
||||
// across a newline: "skip\nci". CheckMatches (fuzzy mode) would still
|
||||
// catch this because it joins strings.Fields(msg) with a single space
|
||||
// before the substring check; the regex-mode blacklist check must do
|
||||
// the same.
|
||||
message := "feat: add widget\n\nskip\nci"
|
||||
assert.True(t, m.Matches(message, []string{pattern}, nil), "sanity: matches without blacklist")
|
||||
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip ci"}))
|
||||
})
|
||||
|
||||
t.Run("blacklist term split by a tab still suppresses, same as fuzzy mode", func(t *testing.T) {
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
message := "feat: add widget\n\nskip\tci"
|
||||
assert.True(t, m.Matches(message, []string{pattern}, nil), "sanity: matches without blacklist")
|
||||
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip ci"}))
|
||||
})
|
||||
|
||||
t.Run("full-message anchoring: mid-message keyword-looking text does not match anchored pattern", func(t *testing.T) {
|
||||
// Discriminating case: if RegexMatcher ran MatchString per
|
||||
// strings.Fields word instead of against the full raw message, the
|
||||
// word "feat:" here would match "(?m)^feat...:" on its own (it starts
|
||||
// a "word"), even though the actual commit subject starts with
|
||||
// "docs:". Matching against the full message must not match.
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("docs: explain feat: usage", []string{pattern}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
|
||||
t.Run("full-message anchoring: multiline trailer matches (?m) anchor on its own line", func(t *testing.T) {
|
||||
// Discriminating case: strings.Fields on this message splits "BREAKING"
|
||||
// and "CHANGE:" into separate words, neither of which starts with
|
||||
// "BREAKING CHANGE:", so a per-word implementation would miss this.
|
||||
// Matching against the full raw message (newlines kept) must match,
|
||||
// because (?m) anchors "^" to the start of the trailer's own line.
|
||||
pattern := `(?m)^BREAKING CHANGE:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("refactor: x\n\nBREAKING CHANGE: drop v1", []string{pattern}, nil)
|
||||
assert.True(t, got)
|
||||
})
|
||||
|
||||
t.Run("keyword missing from compiled map is a no-match, not a panic", func(t *testing.T) {
|
||||
// The matcher was built from wording.Minor only; calling Matches with
|
||||
// an unrelated keyword exercises the "not compiled" branch. It must
|
||||
// log loudly (via the package's Error logger) rather than silently
|
||||
// return, but it must not miss the check or panic on a nil regexp.
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{`(?m)^feat:`}})
|
||||
assert.NoError(t, err)
|
||||
assert.NotPanics(t, func() {
|
||||
got := m.Matches("feat: add widget", []string{"^never-compiled:"}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFuzzyMatcherMatches(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
originalFuzzyFind := FuzzyFind
|
||||
defer func() { FuzzyFind = originalFuzzyFind }()
|
||||
FuzzyFind = func(needle string, haystack []string) []string {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return []string{h}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
m := FuzzyMatcher{}
|
||||
assert.True(t, m.Matches("update dependencies", []string{"update", "fix"}, nil))
|
||||
assert.False(t, m.Matches("chore dependencies", []string{"update", "fix"}, nil))
|
||||
assert.False(t, m.Matches("update dependencies skip-ci", []string{"update", "fix"}, []string{"skip-ci"}))
|
||||
}
|
||||
+11
-5
@@ -9,12 +9,19 @@ func CalculateSemver(
|
||||
commits []CommitDetails,
|
||||
tags []TagDetails,
|
||||
wording Wording,
|
||||
matcher KeywordMatcher,
|
||||
blacklist []string,
|
||||
initialSemver SemVer,
|
||||
respectExisting bool,
|
||||
strictMode bool,
|
||||
tagPrefixes []string,
|
||||
) SemVer {
|
||||
// Default-preserving: callers that don't select a matcher get today's
|
||||
// fuzzy behaviour rather than a nil-pointer panic.
|
||||
if matcher == nil {
|
||||
matcher = FuzzyMatcher{}
|
||||
}
|
||||
|
||||
semver := initialSemver
|
||||
startIndex := 0
|
||||
|
||||
@@ -57,11 +64,10 @@ func CalculateSemver(
|
||||
}
|
||||
|
||||
// Check for keyword matches
|
||||
commitSlice := strings.Fields(commit.Message)
|
||||
matchPatch := CheckMatches(commitSlice, wording.Patch, blacklist)
|
||||
matchMinor := CheckMatches(commitSlice, wording.Minor, blacklist)
|
||||
matchMajor := CheckMatches(commitSlice, wording.Major, blacklist)
|
||||
matchReleaseCandidate := CheckMatches(commitSlice, wording.Release, blacklist)
|
||||
matchPatch := matcher.Matches(commit.Message, wording.Patch, blacklist)
|
||||
matchMinor := matcher.Matches(commit.Message, wording.Minor, blacklist)
|
||||
matchMajor := matcher.Matches(commit.Message, wording.Major, blacklist)
|
||||
matchReleaseCandidate := matcher.Matches(commit.Message, wording.Release, blacklist)
|
||||
|
||||
// Apply version changes based on matches
|
||||
if matchMajor {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -281,6 +282,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
tt.commits,
|
||||
tt.tags,
|
||||
tt.wording,
|
||||
FuzzyMatcher{},
|
||||
tt.blacklist,
|
||||
tt.initialSemver,
|
||||
tt.respectExisting,
|
||||
@@ -296,3 +298,148 @@ func TestCalculateSemver(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculateSemverRegexModeEndToEnd exercises the whole config->matcher->
|
||||
// CalculateSemver path in regex mode, with anchored conventional-commit
|
||||
// patterns and a "Semver-Major:" trailer.
|
||||
func TestCalculateSemverRegexModeEndToEnd(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
wording := Wording{
|
||||
Patch: []string{`^fix:`},
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
Major: []string{`(?mi)^semver-major:`},
|
||||
Release: []string{},
|
||||
}
|
||||
|
||||
matcher, err := NewKeywordMatcher(MatchingRegex, wording)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// c4 ("fix(delegation): ...") is placed AFTER the major commit (c5) on
|
||||
// purpose: the anchored minor pattern `(?m)^feat(\([^)]*\))?!?:` must not
|
||||
// match a message that starts with "fix", so c4 contributes no version
|
||||
// bump at all under correct regex matching. If a future regression made
|
||||
// RegexMatcher match per-word instead of against the full raw message
|
||||
// (the false positive fuzzy mode has, see matcher_test.go's
|
||||
// "full-message anchoring" cases), c4 would wrongly bump Minor. With c4
|
||||
// last, nothing runs after it to reset that spurious bump back to 0, so
|
||||
// the final assertions below would catch it. (With c4 before c5, as it
|
||||
// used to be, the major commit's Minor=0 reset would mask the same bug.)
|
||||
now := time.Now()
|
||||
commits := []CommitDetails{
|
||||
{Hash: "c1", Message: "chore: init repo", Timestamp: now.Add(-5 * time.Hour)},
|
||||
{Hash: "c2", Message: "fix: correct off-by-one", Timestamp: now.Add(-4 * time.Hour)},
|
||||
{Hash: "c3", Message: "feat: add widget\n\nsome body text", Timestamp: now.Add(-3 * time.Hour)},
|
||||
{Hash: "c5", Message: "docs: update README\n\nSemver-Major: breaking storage format", Timestamp: now.Add(-2 * time.Hour)},
|
||||
{Hash: "c4", Message: "fix(delegation): route around dead worker", Timestamp: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
|
||||
got := CalculateSemver(
|
||||
commits,
|
||||
nil,
|
||||
wording,
|
||||
matcher,
|
||||
nil,
|
||||
SemVer{},
|
||||
false,
|
||||
true, // strict mode: only wording matches move the version
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, got.Major, "Major version mismatch")
|
||||
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
|
||||
assert.Equal(t, 1, got.Patch, "Patch version mismatch")
|
||||
assert.Equal(t, 0, got.Release, "Release version mismatch")
|
||||
assert.False(t, got.EnableReleaseCandidate)
|
||||
}
|
||||
|
||||
// TestCalculateSemverRegexModeReleaseCandidateEndToEnd exercises
|
||||
// wording.Release in regex mode end to end: a commit matching the release
|
||||
// pattern must bump the release-candidate counter, exactly like fuzzy mode
|
||||
// does for wording.Release matches.
|
||||
func TestCalculateSemverRegexModeReleaseCandidateEndToEnd(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
wording := Wording{
|
||||
Patch: []string{`^fix:`},
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
Major: []string{`(?mi)^semver-major:`},
|
||||
Release: []string{`(?m)^release-candidate:`},
|
||||
}
|
||||
|
||||
matcher, err := NewKeywordMatcher(MatchingRegex, wording)
|
||||
assert.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
commits := []CommitDetails{
|
||||
{Hash: "c1", Message: "fix: correct off-by-one", Timestamp: now.Add(-2 * time.Hour)},
|
||||
{Hash: "c2", Message: "release-candidate: cut rc for QA", Timestamp: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
|
||||
got := CalculateSemver(
|
||||
commits,
|
||||
nil,
|
||||
wording,
|
||||
matcher,
|
||||
nil,
|
||||
SemVer{},
|
||||
false,
|
||||
true, // strict mode: only wording matches move the version
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, 0, got.Major, "Major version mismatch")
|
||||
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
|
||||
assert.Equal(t, 1, got.Patch, "Patch version mismatch")
|
||||
assert.Equal(t, 1, got.Release, "Release version mismatch")
|
||||
assert.True(t, got.EnableReleaseCandidate)
|
||||
}
|
||||
|
||||
// TestCalculateSemverFuzzyModeUnchanged runs CalculateSemver with the real
|
||||
// fuzzy.FindNormalizedFold implementation (the same one main.go wires up) to
|
||||
// confirm fuzzy mode's results are unchanged by the matcher refactor.
|
||||
func TestCalculateSemverFuzzyModeUnchanged(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
originalFuzzyFind := FuzzyFind
|
||||
defer func() { FuzzyFind = originalFuzzyFind }()
|
||||
FuzzyFind = fuzzy.FindNormalizedFold
|
||||
|
||||
wording := Wording{
|
||||
Patch: []string{"fix"},
|
||||
Minor: []string{"feat"},
|
||||
Major: []string{"breaking"},
|
||||
}
|
||||
blacklist := []string{"skip-ci"}
|
||||
|
||||
now := time.Now()
|
||||
commits := []CommitDetails{
|
||||
{Hash: "c1", Message: "chore: init", Timestamp: now.Add(-6 * time.Hour)},
|
||||
{Hash: "c2", Message: "fix: correct bug", Timestamp: now.Add(-5 * time.Hour)},
|
||||
{Hash: "c3", Message: "feat: add widget", Timestamp: now.Add(-4 * time.Hour)},
|
||||
{Hash: "c4", Message: "docs: update readme", Timestamp: now.Add(-3 * time.Hour)},
|
||||
{Hash: "c5", Message: "breaking: change api contract", Timestamp: now.Add(-2 * time.Hour)},
|
||||
{Hash: "c6", Message: "fix: cleanup skip-ci", Timestamp: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
|
||||
matcher, err := NewKeywordMatcher(MatchingFuzzy, wording)
|
||||
assert.NoError(t, err)
|
||||
|
||||
got := CalculateSemver(
|
||||
commits,
|
||||
nil,
|
||||
wording,
|
||||
matcher,
|
||||
blacklist,
|
||||
SemVer{},
|
||||
false,
|
||||
false, // non-strict: default patch increment applies too
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, 1, got.Major, "Major version mismatch")
|
||||
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
|
||||
assert.Equal(t, 2, got.Patch, "Patch version mismatch")
|
||||
assert.Equal(t, 0, got.Release, "Release version mismatch")
|
||||
assert.False(t, got.EnableReleaseCandidate)
|
||||
}
|
||||
|
||||
+23
-10
@@ -166,6 +166,27 @@ func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string
|
||||
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, " ")
|
||||
@@ -186,16 +207,8 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
|
||||
}
|
||||
|
||||
// If we have a match, check against blacklist
|
||||
if hasMatch && len(blacklist) > 0 {
|
||||
for _, blacklistTerm := range blacklist {
|
||||
if strings.Contains(strings.ToLower(contentStr), strings.ToLower(blacklistTerm)) {
|
||||
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
|
||||
"content": contentStr,
|
||||
"blacklist_term": blacklistTerm,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
if hasMatch && isBlacklisted(contentStr, blacklist) {
|
||||
return false
|
||||
}
|
||||
|
||||
return hasMatch
|
||||
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.4 // indirect
|
||||
github.com/cloudflare/circl v1.6.5 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
@@ -39,7 +39,7 @@ require (
|
||||
github.com/rs/zerolog v1.33.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/skeema/knownhosts v1.3.3 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
@@ -51,10 +51,10 @@ require (
|
||||
github.com/wI2L/jsondiff v0.6.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -11,8 +11,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
|
||||
github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
|
||||
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
||||
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE=
|
||||
@@ -100,8 +100,8 @@ github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9t
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
github.com/skeema/knownhosts v1.3.3 h1:tiFvsjIW+xqP5cqVsod7atn/xm57OY6v9KwxB1xWUyA=
|
||||
github.com/skeema/knownhosts v1.3.3/go.mod h1:2y0ntYxeqSNxOw5pfqSVDcRyBt2FqfxhzNDPoKz/ifQ=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -141,8 +141,8 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -152,8 +152,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -183,8 +183,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
||||
Reference in New Issue
Block a user