workflow: implement glob pattern matching for tags and branches #742

merged
opened by oppi.li targeting master from push-smlxzowxouqm
Changed files
+509 -25
docs
spindle
nix
workflow
+19 -1
docs/spindle/pipeline.md
···
- `push`: The workflow should run every time a commit is pushed to the repository.
- `pull_request`: The workflow should run every time a pull request is made or updated.
- `manual`: The workflow can be triggered manually.
-
- `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event.
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
···
branch: ["main"]
```
## Engine
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
···
- `push`: The workflow should run every time a commit is pushed to the repository.
- `pull_request`: The workflow should run every time a pull request is made or updated.
- `manual`: The workflow can be triggered manually.
+
- `branch`: Defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. Supports glob patterns using `*` and `**` (e.g., `main`, `develop`, `release-*`). Either `branch` or `tag` (or both) must be specified for `push` events.
+
- `tag`: Defines which tags the workflow should run for. Only used with the `push` event - when tags matching the pattern(s) listed here are pushed, the workflow will trigger. This field has no effect with `pull_request` or `manual` events. Supports glob patterns using `*` and `**` (e.g., `v*`, `v1.*`, `release-**`). Either `branch` or `tag` (or both) must be specified for `push` events.
For example, if you'd like to define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with:
···
branch: ["main"]
```
+
You can also trigger workflows on tag pushes. For instance, to run a deployment workflow when tags matching `v*` are pushed:
+
+
```yaml
+
when:
+
- event: ["push"]
+
tag: ["v*"]
+
```
+
+
You can even combine branch and tag patterns in a single constraint (the workflow triggers if either matches):
+
+
```yaml
+
when:
+
- event: ["push"]
+
branch: ["main", "release-*"]
+
tag: ["v*", "stable"]
+
```
+
## Engine
Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are:
+2 -1
go.mod
···
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
-
github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
···
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.4 // indirect
+
github.com/bmatcuk/doublestar v1.3.4 // indirect
+
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+4
go.sum
···
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
···
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
+
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
+
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
+
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+5 -2
nix/gomod2nix.toml
···
[mod."github.com/bluesky-social/jetstream"]
version = "v0.0.0-20241210005130-ea96859b93d1"
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
[mod."github.com/bmatcuk/doublestar/v4"]
-
version = "v4.7.1"
-
hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA="
[mod."github.com/carlmjohnson/versioninfo"]
version = "v0.22.5"
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
···
[mod."github.com/bluesky-social/jetstream"]
version = "v0.0.0-20241210005130-ea96859b93d1"
hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0="
+
[mod."github.com/bmatcuk/doublestar"]
+
version = "v1.3.4"
+
hash = "sha256-QcHL9WGVAH3vIs4FZH+w1DJxdCHnXkkzODtOfhKR0X0="
[mod."github.com/bmatcuk/doublestar/v4"]
+
version = "v4.9.1"
+
hash = "sha256-0iyHjyTAsfhgYSsE+NKxSNGBuM3Id615VWeQhssTShE="
[mod."github.com/carlmjohnson/versioninfo"]
version = "v0.22.5"
hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw="
+9 -1
workflow/compile.go
···
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
cw := &tangled.Pipeline_Workflow{}
-
if !w.Match(compiler.Trigger) {
compiler.Diagnostics.AddWarning(
w.Name,
WorkflowSkipped,
···
func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow {
cw := &tangled.Pipeline_Workflow{}
+
matched, err := w.Match(compiler.Trigger)
+
if err != nil {
+
compiler.Diagnostics.AddError(
+
w.Name,
+
fmt.Errorf("failed to execute workflow: %w", err),
+
)
+
return nil
+
}
+
if !matched {
compiler.Diagnostics.AddWarning(
w.Name,
WorkflowSkipped,
+125
workflow/compile_test.go
···
assert.Len(t, c.Diagnostics.Errors, 1)
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
}
···
assert.Len(t, c.Diagnostics.Errors, 1)
assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error)
}
+
+
func TestCompileWorkflow_MultipleBranchAndTag(t *testing.T) {
+
wf := Workflow{
+
Name: ".tangled/workflows/branch_and_tag.yml",
+
When: []Constraint{
+
{
+
Event: []string{"push"},
+
Branch: []string{"main", "develop"},
+
Tag: []string{"v*"},
+
},
+
},
+
Engine: "nixery",
+
}
+
+
tests := []struct {
+
name string
+
trigger tangled.Pipeline_TriggerMetadata
+
shouldMatch bool
+
expectedCount int
+
}{
+
{
+
name: "matches main branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/main",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "matches develop branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/develop",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "matches v* tag pattern",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/tags/v1.0.0",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "matches v* tag pattern with different version",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/tags/v2.5.3",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: true,
+
expectedCount: 1,
+
},
+
{
+
name: "does not match master branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/master",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: false,
+
expectedCount: 0,
+
},
+
{
+
name: "does not match non-v tag",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/tags/release-1.0",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: false,
+
expectedCount: 0,
+
},
+
{
+
name: "does not match feature branch",
+
trigger: tangled.Pipeline_TriggerMetadata{
+
Kind: string(TriggerKindPush),
+
Push: &tangled.Pipeline_PushTriggerData{
+
Ref: "refs/heads/feature/new-feature",
+
OldSha: strings.Repeat("0", 40),
+
NewSha: strings.Repeat("f", 40),
+
},
+
},
+
shouldMatch: false,
+
expectedCount: 0,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
c := Compiler{Trigger: tt.trigger}
+
cp := c.Compile([]Workflow{wf})
+
+
assert.Len(t, cp.Workflows, tt.expectedCount)
+
if tt.shouldMatch {
+
assert.Equal(t, wf.Name, cp.Workflows[0].Name)
+
}
+
})
+
}
+
}
+61 -19
workflow/def.go
···
"tangled.org/core/api/tangled"
"github.com/go-git/go-git/v5/plumbing"
"gopkg.in/yaml.v3"
)
···
Constraint struct {
Event StringList `yaml:"event"`
-
Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events
}
CloneOpts struct {
···
return strings.ReplaceAll(string(t), "_", " ")
}
func FromFile(name string, contents []byte) (Workflow, error) {
var wf Workflow
···
}
// if any of the constraints on a workflow is true, return true
-
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
// manual triggers always run the workflow
if trigger.Manual != nil {
-
return true
}
// if not manual, run through the constraint list and see if any one matches
for _, c := range w.When {
-
if c.Match(trigger) {
-
return true
}
}
// no constraints, always run this workflow
if len(w.When) == 0 {
-
return true
}
-
return false
}
-
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) bool {
match := true
// manual triggers always pass this constraint
if trigger.Manual != nil {
-
return true
}
// apply event constraints
···
// apply branch constraints for PRs
if trigger.PullRequest != nil {
-
match = match && c.MatchBranch(trigger.PullRequest.TargetBranch)
}
// apply ref constraints for pushes
if trigger.Push != nil {
-
match = match && c.MatchRef(trigger.Push.Ref)
}
-
return match
-
}
-
-
func (c *Constraint) MatchBranch(branch string) bool {
-
return slices.Contains(c.Branch, branch)
}
-
func (c *Constraint) MatchRef(ref string) bool {
refName := plumbing.ReferenceName(ref)
if refName.IsBranch() {
-
return slices.Contains(c.Branch, refName.Short())
}
-
return false
}
func (c *Constraint) MatchEvent(event string) bool {
···
"tangled.org/core/api/tangled"
+
"github.com/bmatcuk/doublestar"
"github.com/go-git/go-git/v5/plumbing"
"gopkg.in/yaml.v3"
)
···
Constraint struct {
Event StringList `yaml:"event"`
+
Branch StringList `yaml:"branch"` // required for pull_request; for push, either branch or tag must be specified
+
Tag StringList `yaml:"tag"` // optional; only applies to push events
}
CloneOpts struct {
···
return strings.ReplaceAll(string(t), "_", " ")
}
+
// matchesPattern checks if a name matches any of the given patterns.
+
// Patterns can be exact matches or glob patterns using * and **.
+
// * matches any sequence of non-separator characters
+
// ** matches any sequence of characters including separators
+
func matchesPattern(name string, patterns []string) (bool, error) {
+
for _, pattern := range patterns {
+
matched, err := doublestar.Match(pattern, name)
+
if err != nil {
+
return false, err
+
}
+
if matched {
+
return true, nil
+
}
+
}
+
return false, nil
+
}
+
func FromFile(name string, contents []byte) (Workflow, error) {
var wf Workflow
···
}
// if any of the constraints on a workflow is true, return true
+
func (w *Workflow) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
// manual triggers always run the workflow
if trigger.Manual != nil {
+
return true, nil
}
// if not manual, run through the constraint list and see if any one matches
for _, c := range w.When {
+
matched, err := c.Match(trigger)
+
if err != nil {
+
return false, err
+
}
+
if matched {
+
return true, nil
}
}
// no constraints, always run this workflow
if len(w.When) == 0 {
+
return true, nil
}
+
return false, nil
}
+
func (c *Constraint) Match(trigger tangled.Pipeline_TriggerMetadata) (bool, error) {
match := true
// manual triggers always pass this constraint
if trigger.Manual != nil {
+
return true, nil
}
// apply event constraints
···
// apply branch constraints for PRs
if trigger.PullRequest != nil {
+
matched, err := c.MatchBranch(trigger.PullRequest.TargetBranch)
+
if err != nil {
+
return false, err
+
}
+
match = match && matched
}
// apply ref constraints for pushes
if trigger.Push != nil {
+
matched, err := c.MatchRef(trigger.Push.Ref)
+
if err != nil {
+
return false, err
+
}
+
match = match && matched
}
+
return match, nil
}
+
func (c *Constraint) MatchRef(ref string) (bool, error) {
refName := plumbing.ReferenceName(ref)
+
shortName := refName.Short()
+
if refName.IsBranch() {
+
return c.MatchBranch(shortName)
}
+
+
if refName.IsTag() {
+
return c.MatchTag(shortName)
+
}
+
+
return false, nil
+
}
+
+
func (c *Constraint) MatchBranch(branch string) (bool, error) {
+
return matchesPattern(branch, c.Branch)
+
}
+
+
func (c *Constraint) MatchTag(tag string) (bool, error) {
+
return matchesPattern(tag, c.Tag)
}
func (c *Constraint) MatchEvent(event string) bool {
+284 -1
workflow/def_test.go
···
"github.com/stretchr/testify/assert"
)
-
func TestUnmarshalWorkflow(t *testing.T) {
yamlData := `
when:
- event: ["push", "pull_request"]
···
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}
···
"github.com/stretchr/testify/assert"
)
+
func TestUnmarshalWorkflowWithBranch(t *testing.T) {
yamlData := `
when:
- event: ["push", "pull_request"]
···
assert.True(t, wf.CloneOpts.Skip, "Skip should be false")
}
+
+
func TestUnmarshalWorkflowWithTags(t *testing.T) {
+
yamlData := `
+
when:
+
- event: ["push"]
+
tag: ["v*", "release-*"]`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err, "YAML should unmarshal without error")
+
+
assert.Len(t, wf.When, 1, "Should have one constraint")
+
assert.ElementsMatch(t, []string{"v*", "release-*"}, wf.When[0].Tag)
+
assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event)
+
}
+
+
func TestUnmarshalWorkflowWithBranchAndTag(t *testing.T) {
+
yamlData := `
+
when:
+
- event: ["push"]
+
branch: ["main", "develop"]
+
tag: ["v*"]`
+
+
wf, err := FromFile("test.yml", []byte(yamlData))
+
assert.NoError(t, err, "YAML should unmarshal without error")
+
+
assert.Len(t, wf.When, 1, "Should have one constraint")
+
assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch)
+
assert.ElementsMatch(t, []string{"v*"}, wf.When[0].Tag)
+
}
+
+
func TestMatchesPattern(t *testing.T) {
+
tests := []struct {
+
name string
+
input string
+
patterns []string
+
expected bool
+
}{
+
{"exact match", "main", []string{"main"}, true},
+
{"exact match in list", "develop", []string{"main", "develop"}, true},
+
{"no match", "feature", []string{"main", "develop"}, false},
+
{"wildcard prefix", "v1.0.0", []string{"v*"}, true},
+
{"wildcard suffix", "release-1.0", []string{"*-1.0"}, true},
+
{"wildcard middle", "feature-123-test", []string{"feature-*-test"}, true},
+
{"double star prefix", "release-1.0.0", []string{"release-**"}, true},
+
{"double star with slashes", "release/1.0/hotfix", []string{"release/**"}, true},
+
{"double star matches multiple levels", "foo/bar/baz/qux", []string{"foo/**"}, true},
+
{"double star no match", "feature/test", []string{"release/**"}, false},
+
{"no patterns matches nothing", "anything", []string{}, false},
+
{"pattern doesn't match", "v1.0.0", []string{"release-*"}, false},
+
{"complex pattern", "release/v1.2.3", []string{"release/*"}, true},
+
{"single star stops at slash", "release/1.0/hotfix", []string{"release/*"}, false},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := matchesPattern(tt.input, tt.patterns)
+
assert.Equal(t, tt.expected, result, "matchesPattern(%q, %v) should be %v", tt.input, tt.patterns, tt.expected)
+
})
+
}
+
}
+
+
func TestConstraintMatchRef_Branches(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
ref string
+
expected bool
+
}{
+
{
+
name: "exact branch match",
+
constraint: Constraint{Branch: []string{"main"}},
+
ref: "refs/heads/main",
+
expected: true,
+
},
+
{
+
name: "branch glob match",
+
constraint: Constraint{Branch: []string{"feature-*"}},
+
ref: "refs/heads/feature-123",
+
expected: true,
+
},
+
{
+
name: "branch no match",
+
constraint: Constraint{Branch: []string{"main"}},
+
ref: "refs/heads/develop",
+
expected: false,
+
},
+
{
+
name: "no constraints matches nothing",
+
constraint: Constraint{},
+
ref: "refs/heads/anything",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchRef(tt.ref)
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
+
})
+
}
+
}
+
+
func TestConstraintMatchRef_Tags(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
ref string
+
expected bool
+
}{
+
{
+
name: "exact tag match",
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
+
ref: "refs/tags/v1.0.0",
+
expected: true,
+
},
+
{
+
name: "tag glob match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
ref: "refs/tags/v1.2.3",
+
expected: true,
+
},
+
{
+
name: "tag glob with pattern",
+
constraint: Constraint{Tag: []string{"release-*"}},
+
ref: "refs/tags/release-2024",
+
expected: true,
+
},
+
{
+
name: "tag no match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
ref: "refs/tags/release-1.0",
+
expected: false,
+
},
+
{
+
name: "tag not matched when only branch constraint",
+
constraint: Constraint{Branch: []string{"main"}},
+
ref: "refs/tags/v1.0.0",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchRef(tt.ref)
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
+
})
+
}
+
}
+
+
func TestConstraintMatchRef_Combined(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
ref string
+
expected bool
+
}{
+
{
+
name: "matches branch in combined constraint",
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
+
ref: "refs/heads/main",
+
expected: true,
+
},
+
{
+
name: "matches tag in combined constraint",
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
+
ref: "refs/tags/v1.0.0",
+
expected: true,
+
},
+
{
+
name: "no match in combined constraint",
+
constraint: Constraint{Branch: []string{"main"}, Tag: []string{"v*"}},
+
ref: "refs/heads/develop",
+
expected: false,
+
},
+
{
+
name: "glob patterns in combined constraint - branch",
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
+
ref: "refs/heads/release-2024",
+
expected: true,
+
},
+
{
+
name: "glob patterns in combined constraint - tag",
+
constraint: Constraint{Branch: []string{"release-*"}, Tag: []string{"v*"}},
+
ref: "refs/tags/v2.0.0",
+
expected: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchRef(tt.ref)
+
assert.Equal(t, tt.expected, result, "MatchRef should return %v for ref %q", tt.expected, tt.ref)
+
})
+
}
+
}
+
+
func TestConstraintMatchBranch_GlobPatterns(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
branch string
+
expected bool
+
}{
+
{
+
name: "exact match",
+
constraint: Constraint{Branch: []string{"main"}},
+
branch: "main",
+
expected: true,
+
},
+
{
+
name: "glob match",
+
constraint: Constraint{Branch: []string{"feature-*"}},
+
branch: "feature-123",
+
expected: true,
+
},
+
{
+
name: "no match",
+
constraint: Constraint{Branch: []string{"main"}},
+
branch: "develop",
+
expected: false,
+
},
+
{
+
name: "multiple patterns with match",
+
constraint: Constraint{Branch: []string{"main", "release-*"}},
+
branch: "release-1.0",
+
expected: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchBranch(tt.branch)
+
assert.Equal(t, tt.expected, result, "MatchBranch should return %v for branch %q", tt.expected, tt.branch)
+
})
+
}
+
}
+
+
func TestConstraintMatchTag_GlobPatterns(t *testing.T) {
+
tests := []struct {
+
name string
+
constraint Constraint
+
tag string
+
expected bool
+
}{
+
{
+
name: "exact match",
+
constraint: Constraint{Tag: []string{"v1.0.0"}},
+
tag: "v1.0.0",
+
expected: true,
+
},
+
{
+
name: "glob match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
tag: "v2.3.4",
+
expected: true,
+
},
+
{
+
name: "no match",
+
constraint: Constraint{Tag: []string{"v*"}},
+
tag: "release-1.0",
+
expected: false,
+
},
+
{
+
name: "multiple patterns with match",
+
constraint: Constraint{Tag: []string{"v*", "release-*"}},
+
tag: "release-2024",
+
expected: true,
+
},
+
{
+
name: "empty tag list matches nothing",
+
constraint: Constraint{Tag: []string{}},
+
tag: "v1.0.0",
+
expected: false,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result, _ := tt.constraint.MatchTag(tt.tag)
+
assert.Equal(t, tt.expected, result, "MatchTag should return %v for tag %q", tt.expected, tt.tag)
+
})
+
}
+
}