Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for allowing skipped checks in has_successful_status #789

30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,12 +322,15 @@ if:
deletions: "> 100"
total: "> 200"

# "has_successful_status" is satisfied if the status checks that are specified
# are marked successful on the head commit of the pull request.
has_successful_status:
- "status-name-1"
- "status-name-2"
- "status-name-3"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the benefit of people with older policies, I'd like to keep the documentation for this predicate, but with a note that it is deprecated:

Deprecated: Use `has_status` instead, which is more flexible

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep sure: 06ae646

# "has_status" is satisfied if the status checks that are specified are
# finished and concluded with one of the conclusions specified.
# "conclusions" is optional and defaults to ["success"].
has_status:
conclusions: ["success", "skipped"]
statuses:
- "status-name-1"
- "status-name-2"
- "status-name-3"

# "has_labels" is satisfied if the pull request has the specified labels
# applied
Expand Down Expand Up @@ -510,16 +513,17 @@ requires:
# count as approved. If present, conditions are an additional requirement
# beyond the approvals required by "count".
#
# For example, if "count" is 1 and "conditions" contains the "has_successful_status"
# For example, if "count" is 1 and "conditions" contains the "has_status"
# condition with the "build" status, the rule is only approved once the
# "build" status check passes and one authorized reviewer leaves a review.
conditions:
# The "conditions" block accepts all of the keys documented as part
# of the "if" block for predicates. The "has_successful_status" key is
# shown here as an example of one type of condition.
has_successful_status:
- "build"
- "vulnerability scan"
# The "conditions" block accepts all of the keys documented as part of the
# "if" block for predicates. The "has_status" key is shown here as an
# example of one type of condition.
has_status:
statuses:
- "build"
- "vulnerability scan"
```

### Approval Policies
Expand Down
2 changes: 2 additions & 0 deletions policy/approval/approve.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type Options struct {

RequestReview RequestReview `yaml:"request_review"`

CountSkippedStatusAsPassed bool `yaml:"count_skipped_status_as_passed"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is leftover from a previous iteration? It appears unused now

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes indeed. I've dropped it in 2d9c41c.


Methods *common.Methods `yaml:"methods"`
}

Expand Down
10 changes: 5 additions & 5 deletions policy/approval/approve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ func TestIsApproved(t *testing.T) {
r := &Rule{
Requires: Requires{
Conditions: predicate.Predicates{
HasSuccessfulStatus: &predicate.HasSuccessfulStatus{"deploy"},
HasStatus: &predicate.HasStatus{Statuses: []string{"deploy"}},
},
},
}
Expand All @@ -688,7 +688,7 @@ func TestIsApproved(t *testing.T) {
r := &Rule{
Requires: Requires{
Conditions: predicate.Predicates{
HasSuccessfulStatus: &predicate.HasSuccessfulStatus{"build"},
HasStatus: &predicate.HasStatus{Statuses: []string{"build"}},
},
},
}
Expand All @@ -701,7 +701,7 @@ func TestIsApproved(t *testing.T) {
Requires: Requires{
Count: 1,
Conditions: predicate.Predicates{
HasSuccessfulStatus: &predicate.HasSuccessfulStatus{"build"},
HasStatus: &predicate.HasStatus{Statuses: []string{"build"}},
},
},
}
Expand All @@ -717,7 +717,7 @@ func TestIsApproved(t *testing.T) {
Organizations: []string{"everyone"},
},
Conditions: predicate.Predicates{
HasSuccessfulStatus: &predicate.HasSuccessfulStatus{"build"},
HasStatus: &predicate.HasStatus{Statuses: []string{"build"}},
},
},
}
Expand Down Expand Up @@ -825,7 +825,7 @@ func TestTrigger(t *testing.T) {
r := &Rule{
Requires: Requires{
Conditions: predicate.Predicates{
HasSuccessfulStatus: &predicate.HasSuccessfulStatus{"build"},
HasStatus: predicate.NewHasStatus([]string{"status1"}, []string{"skipped", "success"}),
},
},
}
Expand Down
22 changes: 22 additions & 0 deletions policy/approval/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ func TestParsePolicy(t *testing.T) {
- and:
- rule6
- rule7
- or:
- rule8
- rule9
`

ruleText := `
Expand Down Expand Up @@ -67,6 +70,15 @@ func TestParsePolicy(t *testing.T) {
enabled: true
requires:
admins: true
- name: rule8
if:
has_successful_status:
- status1
- name: rule9
if:
has_status:
conclusions: ["success", "skipped"]
statuses: [status2, status3]
`

var policy Policy
Expand Down Expand Up @@ -119,6 +131,16 @@ func TestParsePolicy(t *testing.T) {
&RuleRequirement{
rule: rules[6],
},
&OrRequirement{
requirements: []common.Evaluator{
&RuleRequirement{
rule: rules[7],
},
&RuleRequirement{
rule: rules[8],
},
},
},
},
},
},
Expand Down
61 changes: 61 additions & 0 deletions policy/predicate/predicate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ package predicate

import (
"context"
"fmt"
"slices"
"strings"

"github.com/palantir/policy-bot/policy/common"
"github.com/palantir/policy-bot/pull"
Expand All @@ -27,3 +30,61 @@ type Predicate interface {
// Evaluate determines if the predicate is satisfied.
Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error)
}

type unit struct{}
type set map[string]unit

// allowedConclusions can be one of:
// action_required, cancelled, failure, neutral, success, skipped, stale,
// timed_out
type allowedConclusions set
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious for your opinion here: there's a decent amount of code in this PR for converting between this set representation and a list of strings in different circumstances. Given that the number of elements in the set is almost always going to be less than 3, what do you think about using a simple []string and testing for matching conclusions with slices.Contains?

That could allow duplicates, but duplicates would be obvious in the policy definition and shouldn't cause any problems (other than slightly awkward messages.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah no worries. I'm used to writing such code so I tend to reach for it but it's fine to skip it in this case. See ebee532.

If needed we could dedupe when printing, when looking at the slice or any other time. But I doubt it'll be a big deal since we're talking about a small number of possible values.


// UnmarshalYAML implements the yaml.Unmarshaler interface for allowedConclusions.
// This allows the predicate to be specified in the input as a list of strings,
// which we then convert to a set of strings, for easy membership testing.
func (c *allowedConclusions) UnmarshalYAML(unmarshal func(interface{}) error) error {
var conclusions []string
if err := unmarshal(&conclusions); err != nil {
return fmt.Errorf("failed to unmarshal conclusions: %v", err)
}

*c = make(allowedConclusions, len(conclusions))
for _, conclusion := range conclusions {
(*c)[conclusion] = unit{}
}

return nil
}

// joinWithOr returns a string that represents the allowed conclusions in a
// format that can be used in a sentence. For example, if the allowed
// conclusions are "success" and "failure", this will return "success or
// failure". If there are more than two conclusions, the first n-1 will be
// separated by commas.
func (c allowedConclusions) joinWithOr() string {
length := len(c)

keys := make([]string, 0, length)
for key := range c {
keys = append(keys, key)
}
slices.Sort(keys)

switch length {
case 0:
return ""
case 1:
return keys[0]
case 2:
return keys[0] + " or " + keys[1]
}

head, tail := keys[:length-1], keys[length-1]

return strings.Join(head, ", ") + ", or " + tail
}

// If unspecified, require the status to be successful.
var defaultConclusions allowedConclusions = allowedConclusions{
"success": unit{},
}
87 changes: 87 additions & 0 deletions policy/predicate/predicate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

"github.com/palantir/policy-bot/policy/common"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
)

func assertPredicateResult(t *testing.T, expected, actual *common.PredicateResult) {
Expand All @@ -27,3 +28,89 @@ func assertPredicateResult(t *testing.T, expected, actual *common.PredicateResul
assert.Equal(t, expected.ConditionsMap, actual.ConditionsMap, "conditions were not correct")
assert.Equal(t, expected.ConditionValues, actual.ConditionValues, "conditions were not correct")
}

func TestUnmarshalAllowedConclusions(t *testing.T) {
testCases := []struct {
name string
input string
expected allowedConclusions
expectedErr bool
}{
{
name: "empty",
input: "",
expected: nil,
},
{
name: "single",
input: `["success"]`,
expected: allowedConclusions{"success": unit{}},
},
{
name: "multiple",
input: `["success", "failure"]`,
expected: allowedConclusions{"success": unit{}, "failure": unit{}},
},
{
name: "repeat",
input: `["success", "success"]`,
expected: allowedConclusions{"success": unit{}},
},
{
name: "invalid",
input: `notyaml`,
expectedErr: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var actual allowedConclusions
err := yaml.UnmarshalStrict([]byte(tc.input), &actual)

if tc.expectedErr {
assert.Error(t, err, "UnmarshalStrict should have failed")
return
}

assert.NoError(t, err, "UnmarshalStrict failed")
assert.Equal(t, tc.expected, actual, "values were not correct")
})
}
}

func TestJoinWithOr(t *testing.T) {
testCases := []struct {
name string
input allowedConclusions
expected string
}{
{
name: "empty",
input: nil,
expected: "",
},
{
name: "one conclusion",
input: allowedConclusions{"success": unit{}},
expected: "success",
},
{
name: "two conclusions",
input: allowedConclusions{"success": unit{}, "failure": unit{}},
expected: "failure or success",
},
{
name: "three conclusions",
input: allowedConclusions{"success": unit{}, "failure": unit{}, "cancelled": unit{}},
expected: "cancelled, failure, or success",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := tc.input.joinWithOr()
assert.Equal(t, tc.expected, actual, "values were not correct")
})
}
}
10 changes: 9 additions & 1 deletion policy/predicate/predicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ type Predicates struct {

ModifiedLines *ModifiedLines `yaml:"modified_lines"`

HasSuccessfulStatus *HasSuccessfulStatus `yaml:"has_successful_status"`
HasStatus *HasStatus `yaml:"has_status"`
// `has_successful_status` is a deprecated field that is kept for backwards
// compatibility. `has_status` replaces it, and can accept any conclusion
// rather than just "success".
HasSuccessfulStatus *HasStatus `yaml:"has_successful_status"`

HasLabels *HasLabels `yaml:"has_labels"`

Expand Down Expand Up @@ -78,6 +82,10 @@ func (p *Predicates) Predicates() []Predicate {
ps = append(ps, Predicate(p.ModifiedLines))
}

if p.HasStatus != nil {
ps = append(ps, Predicate(p.HasStatus))
}

if p.HasSuccessfulStatus != nil {
ps = append(ps, Predicate(p.HasSuccessfulStatus))
}
Expand Down
Loading