Skip to content

Commit 6170836

Browse files
authored
Add custom properties predicates (#1054)
1 parent 9eda44c commit 6170836

File tree

7 files changed

+648
-12
lines changed

7 files changed

+648
-12
lines changed

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,46 @@ if:
475475
has_valid_signatures_by_keys:
476476
key_ids: ["3AA5C34371567BD2"]
477477

478+
# "custom_property_is_not_null" is satisfied if all of the specified repository
479+
# custom properties are set to a non-empty string or array on the target repository.
480+
custom_property_is_not_null:
481+
- "release_tier"
482+
- "service_owner"
483+
484+
# "custom_property_is_null" is satisfied if all of the specified repository
485+
# custom properties are unset or set to an empty string or array on the
486+
# target repository.
487+
custom_property_is_null:
488+
- "deprecated_since"
489+
490+
# "custom_property_matches_any_of" is satisfied if, for each property key in
491+
# the map, the repository custom property exists and its string value matches
492+
# at least one of the provided regular expressions.
493+
#
494+
# Regex matching only applies to string typed (including boolean) custom properties.
495+
# Array-type properties are not matched and will cause the predicate to fail
496+
# for that key. Unset/null properties will also fail the match.
497+
#
498+
# Note: Double-quote strings must escape backslashes while single/plain do not.
499+
# See the Notes on YAML Syntax section of this README for more information.
500+
custom_property_matches_any_of:
501+
environment: ["^prod$", "^staging$"]
502+
service_tier: ["^(critical|high)$"]
503+
504+
# "custom_property_matches_none_of" is satisfied if, for each property key in
505+
# the map, the repository custom property does not exist, its string value matches
506+
# none of the provided regular expressions.
507+
#
508+
# Regex matching only applies to string typed (including boolean) custom properties.
509+
# Array-type properties are not matched and will cause the predicate to always pass
510+
# for that key. Unset/null properties will also pass the match.
511+
#
512+
# Note: Double-quote strings must escape backslashes while single/plain do not.
513+
# See the Notes on YAML Syntax section of this README for more information.
514+
custom_property_matches_none_of:
515+
environment: ["^dev$", "^test$"]
516+
service_tier: ["^low$"]
517+
478518
# "options" specifies a set of restrictions on approvals. If the block does not
479519
# exist, the default values are used.
480520
options:
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright 2025 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+
"fmt"
20+
"maps"
21+
"slices"
22+
"strings"
23+
24+
"github.com/palantir/policy-bot/policy/common"
25+
"github.com/palantir/policy-bot/pull"
26+
"github.com/pkg/errors"
27+
)
28+
29+
type CustomPropertyIsNull []string
30+
type CustomPropertyIsNotNull []string
31+
type CustomPropertyMatchesAnyOf map[string][]common.Regexp
32+
type CustomPropertyMatchesNoneOf map[string][]common.Regexp
33+
34+
var _ Predicate = (CustomPropertyIsNull)(nil)
35+
var _ Predicate = (CustomPropertyIsNotNull)(nil)
36+
var _ Predicate = (CustomPropertyMatchesAnyOf)(nil)
37+
var _ Predicate = (CustomPropertyMatchesNoneOf)(nil)
38+
39+
func formatCustomProperties(customProperties map[string]pull.CustomProperty) []string {
40+
result := []string{}
41+
keys := slices.Sorted(maps.Keys(customProperties))
42+
for _, k := range keys {
43+
v := customProperties[k]
44+
formatted := "(null)"
45+
if v.String != nil {
46+
formatted = *v.String
47+
}
48+
if v.Array != nil {
49+
formatted = "[" + strings.Join(v.Array, ", ") + "]"
50+
}
51+
result = append(result, fmt.Sprintf("%s: %s", k, formatted))
52+
}
53+
return result
54+
}
55+
56+
func (pred CustomPropertyIsNotNull) Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error) {
57+
customProperties, err := prctx.RepositoryCustomProperties()
58+
if err != nil {
59+
return nil, errors.Wrap(err, "failed to get repository custom properties")
60+
}
61+
62+
predicateResult := common.PredicateResult{
63+
ValuePhrase: "custom properties",
64+
Values: formatCustomProperties(customProperties),
65+
ConditionPhrase: "contain",
66+
ConditionValues: pred,
67+
Satisfied: true,
68+
}
69+
70+
for _, property := range pred {
71+
// For custom properties, empty strings and empty arrays are considered unset, and are not returned from the API.
72+
if _, ok := customProperties[property]; !ok {
73+
predicateResult.Satisfied = false
74+
return &predicateResult, nil
75+
}
76+
}
77+
78+
return &predicateResult, nil
79+
}
80+
81+
func (pred CustomPropertyIsNull) Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error) {
82+
customProperties, err := prctx.RepositoryCustomProperties()
83+
if err != nil {
84+
return nil, errors.Wrap(err, "failed to get repository custom properties")
85+
}
86+
87+
predicateResult := common.PredicateResult{
88+
ValuePhrase: "custom properties",
89+
Values: formatCustomProperties(customProperties),
90+
ReverseSkipPhrase: true,
91+
ConditionPhrase: "contain",
92+
ConditionValues: pred,
93+
Satisfied: true,
94+
}
95+
96+
for _, property := range pred {
97+
// For custom properties, empty strings and empty arrays are considered unset, and are not returned from the API.
98+
if _, ok := customProperties[property]; ok {
99+
predicateResult.Satisfied = false
100+
return &predicateResult, nil
101+
}
102+
}
103+
104+
return &predicateResult, nil
105+
}
106+
107+
func (pred CustomPropertyMatchesAnyOf) Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error) {
108+
customProperties, err := prctx.RepositoryCustomProperties()
109+
if err != nil {
110+
return nil, errors.Wrap(err, "failed to get repository custom properties")
111+
}
112+
113+
conditionsMap := make(map[string][]string, len(pred))
114+
for property, allowedValues := range pred {
115+
strValues := make([]string, len(allowedValues))
116+
for i, v := range allowedValues {
117+
strValues[i] = v.String()
118+
}
119+
conditionsMap[property] = strValues
120+
}
121+
122+
predicateResult := common.PredicateResult{
123+
ValuePhrase: "custom properties",
124+
Values: formatCustomProperties(customProperties),
125+
ConditionPhrase: "match one or more of the patterns",
126+
ConditionsMap: conditionsMap,
127+
Satisfied: true,
128+
}
129+
130+
for property, allowedValues := range pred {
131+
propValue, ok := customProperties[property]
132+
if !ok {
133+
predicateResult.Satisfied = false
134+
return &predicateResult, nil
135+
}
136+
137+
if propValue.String == nil || !anyMatches(allowedValues, *propValue.String) {
138+
predicateResult.Satisfied = false
139+
return &predicateResult, nil
140+
}
141+
}
142+
143+
return &predicateResult, nil
144+
}
145+
146+
func (pred CustomPropertyMatchesNoneOf) Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error) {
147+
customProperties, err := prctx.RepositoryCustomProperties()
148+
if err != nil {
149+
return nil, errors.Wrap(err, "failed to get repository custom properties")
150+
}
151+
152+
conditionsMap := make(map[string][]string, len(pred))
153+
for property, allowedValues := range pred {
154+
strValues := make([]string, len(allowedValues))
155+
for i, v := range allowedValues {
156+
strValues[i] = v.String()
157+
}
158+
conditionsMap[property] = strValues
159+
}
160+
161+
predicateResult := common.PredicateResult{
162+
ValuePhrase: "custom properties",
163+
Values: formatCustomProperties(customProperties),
164+
ReverseSkipPhrase: true,
165+
ConditionPhrase: "match one or more of the patterns",
166+
ConditionsMap: conditionsMap,
167+
Satisfied: true,
168+
}
169+
170+
for property, disallowedValues := range pred {
171+
propValue, ok := customProperties[property]
172+
if !ok {
173+
continue
174+
}
175+
176+
if propValue.String != nil && anyMatches(disallowedValues, *propValue.String) {
177+
predicateResult.Satisfied = false
178+
return &predicateResult, nil
179+
}
180+
}
181+
182+
return &predicateResult, nil
183+
}
184+
185+
func (pred CustomPropertyIsNotNull) Trigger() common.Trigger { return common.TriggerStatic }
186+
func (pred CustomPropertyIsNull) Trigger() common.Trigger { return common.TriggerStatic }
187+
func (pred CustomPropertyMatchesAnyOf) Trigger() common.Trigger { return common.TriggerStatic }
188+
func (pred CustomPropertyMatchesNoneOf) Trigger() common.Trigger { return common.TriggerStatic }

0 commit comments

Comments
 (0)