diff --git a/README.md b/README.md index bd2c65740..8f19cb370 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,36 @@ merge: # "rebase", "squash", and "ff-only". method: squash - # Allows the merge method that is used when auto-merging a PR to be different based on the + ##### branch_method has been DEPRECATED in favor of merge_method ##### + # + # Allows the merge method that is used when auto-merging a PR to be different # target branch. The keys of the hash are the target branch name, and the values are the merge method that # will be used for PRs targeting that branch. The valid values are the same as for the "method" key. # Note: If the target branch does not match any of the specified keys, the "method" key is used instead. branch_method: develop: squash master: merge + ##### branch_method has been DEPRECATED in favor of merge_method ##### + + # Allows the merge method that is used when auto-merging a PR to be different + # based on trigger criteria. The first method where ALL triggers match will + # be used. Otherwise, the method specified previously in "merge.method" will + # be used. + # - ALL trigger criteria must match, unlike merge/trigger where ANY match + # will trigger bulldozer. + # - This will override any branch_method logic if one of the methods is + # triggered + # - If no trigger criteria is provided the method is ignored + merge_method: + # "method" defines the merge method. The available options are "merge", + # "rebase", "squash", and "ff-only". + - method: squash + trigger: + # All methods from merge/trigger are supported. Additionally, the + # following additional methods are provided: + + # Pull requests which a number of commits less than or equal to this value are added to the trigger. + max_commits: 3 # "options" defines additional options for the individual merge methods. options: diff --git a/bulldozer/config_v1.go b/bulldozer/config_v1.go index fb494c503..1560ec417 100644 --- a/bulldozer/config_v1.go +++ b/bulldozer/config_v1.go @@ -44,8 +44,9 @@ type MergeConfig struct { DeleteAfterMerge bool `yaml:"delete_after_merge"` AllowMergeWithNoChecks bool `yaml:"allow_merge_with_no_checks"` - Method MergeMethod `yaml:"method"` - Options MergeOptions `yaml:"options"` + Method MergeMethod `yaml:"method"` + MergeMethods []ConditionalMergeMethod `yaml:"merge_method"` + Options MergeOptions `yaml:"options"` BranchMethod map[string]MergeMethod `yaml:"branch_method"` @@ -58,6 +59,11 @@ type MergeOptions struct { Squash *SquashOptions `yaml:"squash"` } +type ConditionalMergeMethod struct { + Method MergeMethod `yaml:"method"` + Trigger Signals `yaml:"trigger"` +} + type SquashOptions struct { Title TitleStrategy `yaml:"title"` Body MessageStrategy `yaml:"body"` diff --git a/bulldozer/evaluate.go b/bulldozer/evaluate.go index c3b0a4473..bb9fc9271 100644 --- a/bulldozer/evaluate.go +++ b/bulldozer/evaluate.go @@ -26,10 +26,10 @@ import ( // IsPRIgnored returns true if the PR is identified as ignored, // false otherwise. Additionally, a description of the reason will be returned. func IsPRIgnored(ctx context.Context, pullCtx pull.Context, config Signals) (bool, string, error) { - matches, reason, err := config.Matches(ctx, pullCtx, "ignored") + matches, reason, err := config.MatchesAny(ctx, pullCtx, "ignored") if err != nil { // ignore must always fail closed (matches on error) - matches = true + return true, reason, err } return matches, reason, err } @@ -37,7 +37,18 @@ func IsPRIgnored(ctx context.Context, pullCtx pull.Context, config Signals) (boo // IsPRTriggered returns true if the PR is identified as triggered, // false otherwise. Additionally, a description of the reason will be returned. func IsPRTriggered(ctx context.Context, pullCtx pull.Context, config Signals) (bool, string, error) { - matches, reason, err := config.Matches(ctx, pullCtx, "triggered") + matches, reason, err := config.MatchesAny(ctx, pullCtx, "triggered") + if err != nil { + // trigger must always fail closed (no match on error) + return false, reason, err + } + return matches, reason, err +} + +// IsMergeMethodTriggered returns true if ALL signals are fully matched, +// false otherwise. Additionally, a description of the reason will be returned. +func IsMergeMethodTriggered(ctx context.Context, pullCtx pull.Context, config Signals) (bool, string, error) { + matches, reason, err := config.MatchesAll(ctx, pullCtx, "triggered") if err != nil { // trigger must always fail closed (no match on error) return false, reason, err diff --git a/bulldozer/merge.go b/bulldozer/merge.go index 28cd9d503..aa9d0bd36 100644 --- a/bulldozer/merge.go +++ b/bulldozer/merge.go @@ -149,21 +149,49 @@ func (m *PushRestrictionMerger) DeleteHead(ctx context.Context, pullCtx pull.Con return m.Normal.DeleteHead(ctx, pullCtx) } -// MergePR merges a pull request if all conditions are met. It logs any errors -// that it encounters. -func MergePR(ctx context.Context, pullCtx pull.Context, merger Merger, mergeConfig MergeConfig) { +// DetermineMergeMethod determines which merge method to use when merging the PR +func DetermineMergeMethod(ctx context.Context, pullCtx pull.Context, mergeConfig MergeConfig) (MergeMethod, error) { logger := zerolog.Ctx(ctx) - base, head := pullCtx.Branches() + base, _ := pullCtx.Branches() mergeMethod := mergeConfig.Method if branchMergeMethod, ok := mergeConfig.BranchMethod[base]; ok { mergeMethod = branchMergeMethod } + + for _, method := range mergeConfig.MergeMethods { + triggered, reason, err := IsMergeMethodTriggered(ctx, pullCtx, method.Trigger) + if err != nil { + err = errors.Wrapf(err, "Failed to determine if merge method '%s' is triggered", method.Method) + return "", err + } + + if triggered { + mergeMethod = method.Method + logger.Debug().Msgf("%s method is triggered because %s matched", mergeMethod, reason) + break + } + } + if !isValidMergeMethod(mergeMethod) { mergeMethod = MergeCommit } + return mergeMethod, nil +} + +// MergePR merges a pull request if all conditions are met. It logs any errors +// that it encounters. +func MergePR(ctx context.Context, pullCtx pull.Context, merger Merger, mergeConfig MergeConfig) { + logger := zerolog.Ctx(ctx) + + mergeMethod, err := DetermineMergeMethod(ctx, pullCtx, mergeConfig) + if err != nil { + logger.Error().Err(err).Msg("Failed to determine merge method") + return + } + commitMsg := CommitMessage{} if mergeMethod == SquashAndMerge { opt := mergeConfig.Options.Squash @@ -210,6 +238,7 @@ func MergePR(ctx context.Context, pullCtx pull.Context, merger Merger, mergeConf time.Sleep(4 * time.Second) } + _, head := pullCtx.Branches() if merged { if mergeConfig.DeleteAfterMerge { attemptDelete(ctx, pullCtx, head, merger) diff --git a/bulldozer/signals.go b/bulldozer/signals.go index 8d10eba21..148ff9d5c 100644 --- a/bulldozer/signals.go +++ b/bulldozer/signals.go @@ -21,45 +21,162 @@ import ( "strings" "github.com/palantir/bulldozer/pull" + "github.com/pkg/errors" "github.com/rs/zerolog" ) +type Signal interface { + // Determine if the signal has values assigned to it and should be considered when matching + Enabled() bool + + // Determine if the signal matches a value in the target pull request + Matches(context.Context, pull.Context, string) (bool, string, error) +} + +type LabelsSignal []string +type CommentSubstringsSignal []string +type CommentsSignal []string +type PRBodySubstringsSignal []string +type BranchesSignal []string +type BranchPatternsSignal []string +type MaxCommitsSignal int + type Signals struct { - Labels []string `yaml:"labels"` - CommentSubstrings []string `yaml:"comment_substrings"` - Comments []string `yaml:"comments"` - PRBodySubstrings []string `yaml:"pr_body_substrings"` - Branches []string `yaml:"branches"` - BranchPatterns []string `yaml:"branch_patterns"` -} - -func (s *Signals) Enabled() bool { - size := 0 - size += len(s.Labels) - size += len(s.CommentSubstrings) - size += len(s.Comments) - size += len(s.PRBodySubstrings) - size += len(s.Branches) - size += len(s.BranchPatterns) - return size > 0 -} - -// Matches returns true if the pull request meets one or more signals. It also + Labels LabelsSignal `yaml:"labels"` + CommentSubstrings CommentSubstringsSignal `yaml:"comment_substrings"` + Comments CommentsSignal `yaml:"comments"` + PRBodySubstrings PRBodySubstringsSignal `yaml:"pr_body_substrings"` + Branches BranchesSignal `yaml:"branches"` + BranchPatterns BranchPatternsSignal `yaml:"branch_patterns"` + MaxCommits MaxCommitsSignal `yaml:"max_commits"` +} + +func (signal LabelsSignal) Enabled() bool { + return len(signal) > 0 +} + +func (signal CommentSubstringsSignal) Enabled() bool { + return len(signal) > 0 +} + +func (signal CommentsSignal) Enabled() bool { + return len(signal) > 0 +} + +func (signal PRBodySubstringsSignal) Enabled() bool { + return len(signal) > 0 +} + +func (signal BranchesSignal) Enabled() bool { + return len(signal) > 0 +} + +func (signal BranchPatternsSignal) Enabled() bool { + return len(signal) > 0 +} + +func (signal MaxCommitsSignal) Enabled() bool { + return signal > 0 +} + +func (s Signals) Enabled() bool { + return s.Labels.Enabled() || + s.CommentSubstrings.Enabled() || + s.Comments.Enabled() || + s.PRBodySubstrings.Enabled() || + s.Branches.Enabled() || + s.BranchPatterns.Enabled() || + s.MaxCommits.Enabled() +} + +// MatchesAll returns true if the pull request matches ALL of the signals. It also +// returns a description of the match status. The tag argument appears +// in this description and indicates the behavior (trigger, ignore) this +// set of signals is associated with. +func (s Signals) MatchesAll(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + if !s.Enabled() { + return false, fmt.Sprintf("no %s signals provided to match against", tag), nil + } + + signals := []Signal{ + &s.Labels, + &s.CommentSubstrings, + &s.Comments, + &s.PRBodySubstrings, + &s.Branches, + &s.BranchPatterns, + &s.MaxCommits, + } + + for _, signal := range signals { + if signal.Enabled() { + matches, _, err := signal.Matches(ctx, pullCtx, tag) + if err != nil { + return false, "", err + } + + if !matches { + return false, fmt.Sprintf("pull request does not match all %s signals", tag), nil + } + } + } + + return true, fmt.Sprintf("pull request matches all %s signals", tag), nil +} + +// MatchesAny returns true if the pull request meets one or more signals. It also // returns a description of the signal that was met. The tag argument appears // in this description and indicates the behavior (trigger, ignore) this // set of signals is associated with. -func (s *Signals) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { +func (s Signals) MatchesAny(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + if !s.Enabled() { + return false, fmt.Sprintf("no %s signals provided to match against", tag), nil + } + + signals := []Signal{ + &s.Labels, + &s.CommentSubstrings, + &s.Comments, + &s.PRBodySubstrings, + &s.Branches, + &s.BranchPatterns, + } + + for _, signal := range signals { + matches, description, err := signal.Matches(ctx, pullCtx, tag) + if err != nil { + return false, "", err + } + + if matches { + return true, description, nil + } + } + + return false, fmt.Sprintf("pull request does not match the %s", tag), nil +} + +// Matches Determines which label signals match the given PR. It returns: +// - A boolean to indicate if a signal matched +// - A description of the first matched signal +func (signal LabelsSignal) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { logger := zerolog.Ctx(ctx) + if !signal.Enabled() { + return false, "", nil + } + labels, err := pullCtx.Labels(ctx) if err != nil { - return false, "unable to list pull request labels", err + return false, "", errors.Wrap(err, "unable to list pull request labels") } if len(labels) == 0 { logger.Debug().Msgf("No labels found to match against") + return false, "", nil } - for _, signalLabel := range s.Labels { + + for _, signalLabel := range signal { for _, label := range labels { if strings.EqualFold(signalLabel, label) { return true, fmt.Sprintf("pull request has a %s label: %q", tag, signalLabel), nil @@ -67,16 +184,31 @@ func (s *Signals) Matches(ctx context.Context, pullCtx pull.Context, tag string) } } + return false, "", nil +} + +// Matches Determines which comment signals match the given PR. It returns: +// - A boolean to indicate if a signal matched +// - A description of the first matched signal +func (signal CommentsSignal) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + logger := zerolog.Ctx(ctx) + + if !signal.Enabled() { + return false, "", nil + } + body := pullCtx.Body() comments, err := pullCtx.Comments(ctx) if err != nil { - return false, "unable to list pull request comments", err + return false, "", errors.Wrap(err, "unable to list pull request comments") } - if len(comments) == 0 { - logger.Debug().Msgf("No comments found to match against") + if len(comments) == 0 && body == "" { + logger.Debug().Msgf("No comments or body content found to match against") + return false, "", nil } - for _, signalComment := range s.Comments { + + for _, signalComment := range signal { if body == signalComment { return true, fmt.Sprintf("pull request body is a %s comment: %q", tag, signalComment), nil } @@ -87,10 +219,31 @@ func (s *Signals) Matches(ctx context.Context, pullCtx pull.Context, tag string) } } - if len(s.CommentSubstrings) == 0 { - logger.Debug().Msgf("No comment substrings found to match against") + return false, "", nil +} + +// Matches Determines which comment substring signals match the given PR. It returns: +// - A boolean to indicate if a signal matched +// - A description of the first matched signal +func (signal CommentSubstringsSignal) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + logger := zerolog.Ctx(ctx) + + if !signal.Enabled() { + return false, "", nil + } + + body := pullCtx.Body() + comments, err := pullCtx.Comments(ctx) + if err != nil { + return false, "", errors.Wrap(err, "unable to list pull request comments") } - for _, signalSubstring := range s.CommentSubstrings { + + if len(comments) == 0 && body == "" { + logger.Debug().Msgf("No comments or body content found to match against") + return false, "", nil + } + + for _, signalSubstring := range signal { if strings.Contains(body, signalSubstring) { return true, fmt.Sprintf("pull request body matches a %s substring: %q", tag, signalSubstring), nil } @@ -101,29 +254,89 @@ func (s *Signals) Matches(ctx context.Context, pullCtx pull.Context, tag string) } } - if len(s.PRBodySubstrings) == 0 { - logger.Debug().Msgf("No PR body substrings found to match against") + return false, "", nil +} + +// Matches Determines which PR body signals match the given PR. It returns: +// - A boolean to indicate if a signal matched +// - A description of the first matched signal +func (signal PRBodySubstringsSignal) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + logger := zerolog.Ctx(ctx) + + if !signal.Enabled() { + return false, "", nil } - for _, signalSubstring := range s.PRBodySubstrings { + + body := pullCtx.Body() + + if body == "" { + logger.Debug().Msgf("No body content found to match against") + return false, "", nil + } + + for _, signalSubstring := range signal { if strings.Contains(body, signalSubstring) { return true, fmt.Sprintf("pull request body matches a %s substring: %q", tag, signalSubstring), nil } } - targetBranch, _ := pullCtx.Branches() - if len(s.Branches) == 0 || len(s.BranchPatterns) == 0 { - logger.Debug().Msgf("No branches or branch patterns found to match against") + return false, "", nil +} + +// Matches Determines which branch signals match the given PR. It returns: +// - A boolean to indicate if a signal matched +// - A description of the first matched signal +func (signal BranchesSignal) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + if !signal.Enabled() { + return false, "", nil } - for _, signalBranch := range s.Branches { + + targetBranch, _ := pullCtx.Branches() + + for _, signalBranch := range signal { if targetBranch == signalBranch { return true, fmt.Sprintf("pull request target is a %s branch: %q", tag, signalBranch), nil } } - for _, signalBranch := range s.BranchPatterns { + + return false, "", nil +} + +// Matches Determines which branch pattern signals match the given PR. It returns: +// - A boolean to indicate if a signal matched +// - A description of the first matched signal +func (signal BranchPatternsSignal) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + if !signal.Enabled() { + return false, "", nil + } + + targetBranch, _ := pullCtx.Branches() + + for _, signalBranch := range signal { if matched, _ := regexp.MatchString(fmt.Sprintf("^%s$", signalBranch), targetBranch); matched { return true, fmt.Sprintf("pull request target branch (%q) matches pattern: %q", targetBranch, signalBranch), nil } } - return false, fmt.Sprintf("pull request does not match the %s", tag), nil + return false, "", nil +} + +// Matches Determines if the number of commits in a PR is at or below a given max. It returns: +// - An empty list if there is no match, otherwise a single string description of the match +// - A match value of 0 if there is no match, otherwise the value of the max commits signal +func (signal MaxCommitsSignal) Matches(ctx context.Context, pullCtx pull.Context, tag string) (bool, string, error) { + logger := zerolog.Ctx(ctx) + + if !signal.Enabled() { + logger.Debug().Msgf("No valid max commits value has been provided to match against") + return false, "", nil + } + + commits, _ := pullCtx.Commits(ctx) + + if len(commits) <= int(signal) { + return true, fmt.Sprintf("pull request has %q commits, which is less than or equal to the maximum of %q", len(commits), signal), nil + } + + return false, "", nil } diff --git a/bulldozer/signals_test.go b/bulldozer/signals_test.go index 2eb71b780..628b2deb7 100644 --- a/bulldozer/signals_test.go +++ b/bulldozer/signals_test.go @@ -24,7 +24,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestSignalsMatches(t *testing.T) { +func TestSignalsMatchesAny(t *testing.T) { signals := Signals{ Labels: []string{"LABEL_MERGE"}, Comments: []string{"FULL_COMMENT_PLZ_MERGE"}, @@ -137,7 +137,218 @@ func TestSignalsMatches(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - matches, reason, err := signals.Matches(ctx, test.PullContext, "testlist") + matches, reason, err := signals.MatchesAny(ctx, test.PullContext, "testlist") + require.NoError(t, err) + + if test.Matches { + assert.True(t, matches, "expected pull request to match, but it didn't") + } else { + assert.False(t, matches, "expected pull request to not match, but it did") + } + assert.Equal(t, test.Reason, reason) + }) + } +} + +func TestSignalsMatchesAnyNoSignals(t *testing.T) { + signals := Signals{} + + ctx := context.Background() + + tests := map[string]struct { + PullContext pull.Context + Matches bool + Reason string + }{ + "noMatchNoSignalsProvidedWithEmptyPR": { + PullContext: &pulltest.MockPullContext{}, + Matches: false, + Reason: `no testlist signals provided to match against`, + }, + "noMatchNoSignalsProvidedWithNonEmptyPR": { + PullContext: &pulltest.MockPullContext{ + CommentValue: []string{"FULL_COMMENT_PLZ_MERGE"}, + }, + Matches: false, + Reason: `no testlist signals provided to match against`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + matches, reason, err := signals.MatchesAny(ctx, test.PullContext, "testlist") + require.NoError(t, err) + + if test.Matches { + assert.True(t, matches, "expected pull request to match, but it didn't") + } else { + assert.False(t, matches, "expected pull request to not match, but it did") + } + assert.Equal(t, test.Reason, reason) + }) + } +} + +func TestSignalsMaxCommits(t *testing.T) { + signals := Signals{ + MaxCommits: 2, + } + ctx := context.Background() + + tests := map[string]struct { + PullContext pull.Context + Matches bool + Reason string + }{ + "noMatchWithGreaterThanMaxCommits": { + PullContext: &pulltest.MockPullContext{ + CommitsValue: []*pull.Commit{ + {SHA: "1", Message: "commit 1"}, + {SHA: "2", Message: "commit 2"}, + {SHA: "3", Message: "commit 3"}, + }, + }, + Matches: false, + Reason: `pull request does not match all testlist signals`, + }, + "matchWithLessThanMaxCommits": { + PullContext: &pulltest.MockPullContext{ + CommitsValue: []*pull.Commit{ + {SHA: "1", Message: "commit 1"}, + }, + }, + Matches: true, + Reason: `pull request matches all testlist signals`, + }, + "matchWithMaxCommits": { + PullContext: &pulltest.MockPullContext{ + CommitsValue: []*pull.Commit{ + {SHA: "1", Message: "commit 1"}, + {SHA: "2", Message: "commit 2"}, + }, + }, + Matches: true, + Reason: `pull request matches all testlist signals`, + }, + "matchWithZeroCommits": { + PullContext: &pulltest.MockPullContext{ + CommitsValue: []*pull.Commit{}, + }, + Matches: true, + Reason: `pull request matches all testlist signals`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + matches, reason, err := signals.MatchesAll(ctx, test.PullContext, "testlist") + require.NoError(t, err) + + if test.Matches { + assert.True(t, matches, "expected pull request to match, but it didn't") + } else { + assert.False(t, matches, "expected pull request to not match, but it did") + } + assert.Equal(t, test.Reason, reason) + }) + } +} + +func TestSignalsMatchesAll(t *testing.T) { + signals := Signals{ + Labels: []string{"LABEL_MERGE", "OTHER_LABEL"}, + Comments: []string{"FULL_COMMENT_PLZ_MERGE", "OTHER_COMMENT"}, + CommentSubstrings: []string{"PLZ_MERGE", "OTHER_SUBSTRING"}, + PRBodySubstrings: []string{":+1:", "OTHER_SUBSTRING"}, + Branches: []string{"test/v9.9.9", "other"}, + BranchPatterns: []string{"test/.*", "^feature/.*$"}, + MaxCommits: 2, + } + + ctx := context.Background() + + tests := map[string]struct { + PullContext pull.Context + Matches bool + Reason string + }{ + "matchWithAll": { + PullContext: &pulltest.MockPullContext{ + LabelValue: []string{"LABEL_MERGE"}, + CommentValue: []string{"FULL_COMMENT_PLZ_MERGE"}, + BodyValue: "My PR Body\n\n\n:+1:", + BranchBase: "test/v9.9.9", + CommitsValue: []*pull.Commit{ + {SHA: "1", Message: "commit 1"}, + {SHA: "2", Message: "commit 2"}, + }, + }, + Matches: true, + Reason: `pull request matches all testlist signals`, + }, + "noMatchWithMissingElements": { + PullContext: &pulltest.MockPullContext{ + LabelValue: []string{"LABEL_MERGE"}, + CommentValue: []string{"FULL_COMMENT_PLZ_MERGE"}, + BodyValue: "My PR Body\n\n\n:-1:", + BranchBase: "test/v1.1.1", + CommitsValue: []*pull.Commit{ + {SHA: "1", Message: "commit 1"}, + {SHA: "2", Message: "commit 2"}, + }, + }, + Matches: false, + Reason: `pull request does not match all testlist signals`, + }, + "noMatchWithEmptyPR": { + PullContext: &pulltest.MockPullContext{}, + Matches: false, + Reason: `pull request does not match all testlist signals`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + matches, reason, err := signals.MatchesAll(ctx, test.PullContext, "testlist") + require.NoError(t, err) + + if test.Matches { + assert.True(t, matches, "expected pull request to match, but it didn't") + } else { + assert.False(t, matches, "expected pull request to not match, but it did") + } + assert.Equal(t, test.Reason, reason) + }) + } +} + +func TestSignalsMatchesAllNoSignals(t *testing.T) { + signals := Signals{} + + ctx := context.Background() + + tests := map[string]struct { + PullContext pull.Context + Matches bool + Reason string + }{ + "noMatchNoSignalsProvidedWithEmptyPR": { + PullContext: &pulltest.MockPullContext{}, + Matches: false, + Reason: `no testlist signals provided to match against`, + }, + "noMatchNoSignalsProvidedWithNonEmptyPR": { + PullContext: &pulltest.MockPullContext{ + CommentValue: []string{"FULL_COMMENT_PLZ_MERGE"}, + }, + Matches: false, + Reason: `no testlist signals provided to match against`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + matches, reason, err := signals.MatchesAll(ctx, test.PullContext, "testlist") require.NoError(t, err) if test.Matches {