Skip to content

Commit 6c8ee52

Browse files
authored
Add a labels predicate with has_any, has_all, and has_none (#1421)
Adds a labels predicate with optional has_any/has_all/has_none sub-keys, structured like the title and repository predicates. Sub-keys combine with AND and matching is case-insensitive. has_labels is deprecated and now calls the new labels implementation.
1 parent d9d6cd8 commit 6c8ee52

6 files changed

Lines changed: 319 additions & 34 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,12 +461,28 @@ if:
461461
- ".github/workflows/a.yml"
462462
- ".github/workflows/b.yml"
463463

464+
# DEPRECATED: Use "labels" below instead, which is more flexible.
464465
# "has_labels" is satisfied if the pull request has the specified labels
465466
# applied
466467
has_labels:
467468
- "label-1"
468469
- "label-2"
469470

471+
# "labels" is satisfied based on the labels applied to the pull request.
472+
# Each sub-key is optional; when more than one is set, all must be
473+
# satisfied. Label matching is case-insensitive.
474+
labels:
475+
# satisfied if the pull request has at least one of these labels
476+
has_any:
477+
- "label-1"
478+
- "label-2"
479+
# satisfied if the pull request has all of these labels
480+
has_all:
481+
- "label-3"
482+
# satisfied if the pull request has none of these labels
483+
has_none:
484+
- "do-not-merge"
485+
470486
# "repository" is satisfied if the pull request repository matches any one of the
471487
# patterns within the "matches" list or does not match all of the patterns
472488
# within the "not_matches" list.

‎policy/predicate/label.go‎

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,41 +17,20 @@ package predicate
1717
import (
1818
"context"
1919
"slices"
20-
"strings"
2120

2221
"github.com/palantir/policy-bot/policy/common"
2322
"github.com/palantir/policy-bot/pull"
24-
"github.com/pkg/errors"
2523
)
2624

25+
// HasLabels checks that the pull request has all of the specified labels.
26+
//
27+
// Deprecated: use the more flexible `Labels` with `has_all` instead.
2728
type HasLabels []string
2829

2930
var _ Predicate = HasLabels([]string{})
3031

3132
func (pred HasLabels) Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error) {
32-
33-
predicateResult := common.PredicateResult{
34-
ValuePhrase: "labels",
35-
ConditionPhrase: "contain the labels",
36-
}
37-
if len(pred) > 0 {
38-
labels, err := prctx.Labels()
39-
if err != nil {
40-
return nil, errors.Wrap(err, "failed to list pull request labels")
41-
}
42-
predicateResult.Values = labels
43-
for _, requiredLabel := range pred {
44-
if !contains(labels, strings.ToLower(requiredLabel)) {
45-
predicateResult.ConditionValues = []string{requiredLabel}
46-
predicateResult.Description = "Missing label: " + requiredLabel
47-
predicateResult.Satisfied = false
48-
return &predicateResult, nil
49-
}
50-
}
51-
}
52-
predicateResult.ConditionValues = pred
53-
predicateResult.Satisfied = true
54-
return &predicateResult, nil
33+
return Labels{HasAll: pred}.Evaluate(ctx, prctx)
5534
}
5635

5736
func (pred HasLabels) Trigger() common.Trigger {

‎policy/predicate/label_test.go‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ func TestHasLabels(t *testing.T) {
3434
LabelsValue: []string{"foo", "bar"},
3535
},
3636
&common.PredicateResult{
37-
Satisfied: true,
38-
Values: []string{"foo", "bar"},
39-
ConditionValues: []string{"foo", "bar"},
37+
Satisfied: true,
38+
Values: []string{"foo", "bar"},
39+
ConditionsMap: map[string][]string{"has all of": {"foo", "bar"}},
4040
},
4141
},
4242
{
@@ -45,9 +45,9 @@ func TestHasLabels(t *testing.T) {
4545
LabelsValue: []string{"foo"},
4646
},
4747
&common.PredicateResult{
48-
Satisfied: false,
49-
Values: []string{"foo"},
50-
ConditionValues: []string{"bar"},
48+
Satisfied: false,
49+
Values: []string{"foo"},
50+
ConditionsMap: map[string][]string{"has all of": {"foo", "bar"}},
5151
},
5252
},
5353
{
@@ -56,9 +56,9 @@ func TestHasLabels(t *testing.T) {
5656
LabelsValue: []string{},
5757
},
5858
&common.PredicateResult{
59-
Satisfied: false,
60-
Values: []string{},
61-
ConditionValues: []string{"foo"},
59+
Satisfied: false,
60+
Values: []string{},
61+
ConditionsMap: map[string][]string{"has all of": {"foo", "bar"}},
6262
},
6363
},
6464
})

‎policy/predicate/labels.go‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright 2026 Palantir Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package predicate
16+
17+
import (
18+
"context"
19+
"strings"
20+
21+
"github.com/palantir/policy-bot/policy/common"
22+
"github.com/palantir/policy-bot/pull"
23+
"github.com/pkg/errors"
24+
)
25+
26+
// Labels is satisfied based on the set of labels applied to the pull request.
27+
// Each sub-key is optional; when more than one is set, all must be satisfied
28+
// (they combine with AND). Label matching is case-insensitive, matching the
29+
// behavior of the has_labels predicate.
30+
type Labels struct {
31+
HasAny []string `yaml:"has_any,omitempty"`
32+
HasAll []string `yaml:"has_all,omitempty"`
33+
HasNone []string `yaml:"has_none,omitempty"`
34+
}
35+
36+
var _ Predicate = Labels{}
37+
38+
func (pred Labels) Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error) {
39+
labels, err := prctx.Labels()
40+
if err != nil {
41+
return nil, errors.Wrap(err, "failed to list pull request labels")
42+
}
43+
44+
predicateResult := common.PredicateResult{
45+
ValuePhrase: "labels",
46+
Values: labels,
47+
ConditionPhrase: "satisfy the label requirements",
48+
ConditionsMap: map[string][]string{},
49+
}
50+
51+
satisfied := true
52+
53+
if len(pred.HasAny) > 0 {
54+
predicateResult.ConditionsMap["has any of"] = pred.HasAny
55+
if !anyLabel(labels, pred.HasAny) {
56+
satisfied = false
57+
}
58+
}
59+
if len(pred.HasAll) > 0 {
60+
predicateResult.ConditionsMap["has all of"] = pred.HasAll
61+
if !allLabels(labels, pred.HasAll) {
62+
satisfied = false
63+
}
64+
}
65+
if len(pred.HasNone) > 0 {
66+
predicateResult.ConditionsMap["has none of"] = pred.HasNone
67+
if anyLabel(labels, pred.HasNone) {
68+
satisfied = false
69+
}
70+
}
71+
72+
predicateResult.Satisfied = satisfied
73+
predicateResult.Description = "The labels do not meet the required conditions"
74+
if satisfied {
75+
predicateResult.Description = "The labels meet the required conditions"
76+
}
77+
return &predicateResult, nil
78+
}
79+
80+
func (pred Labels) Trigger() common.Trigger {
81+
return common.TriggerLabel
82+
}
83+
84+
// anyLabel reports whether at least one of the required labels is present.
85+
// prctx.Labels() returns lowercased values, so the configured labels are
86+
// lowercased before comparison to keep matching case-insensitive.
87+
func anyLabel(labels, required []string) bool {
88+
for _, r := range required {
89+
if contains(labels, strings.ToLower(r)) {
90+
return true
91+
}
92+
}
93+
return false
94+
}
95+
96+
// allLabels reports whether every required label is present.
97+
func allLabels(labels, required []string) bool {
98+
for _, r := range required {
99+
if !contains(labels, strings.ToLower(r)) {
100+
return false
101+
}
102+
}
103+
return true
104+
}

0 commit comments

Comments
 (0)