diff --git a/README.md b/README.md index 14a3210d..044b6a61 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,16 @@ if: - "status-name-2" - "status-name-3" + # "has_workflow_result" is satisfied if the GitHub Actions workflow runs that + # are specified all finished and concluded with one of the conclusions + # specified. "conclusions" is optional and defaults to ["success"]. + # `workflows` contains the paths to the workflow files that are being checked. + has_workflow_result: + conclusions: ["success", "skipped"] + workflows: + - ".github/workflows/a.yml" + - ".github/workflows/b.yml" + # "has_labels" is satisfied if the pull request has the specified labels # applied has_labels: @@ -983,6 +993,7 @@ The app requires these permissions: | Permission | Access | Reason | | ---------- | ------ | ------ | +| Actions| Read-only | Read workflow run events for the `has_workflow_result` predicate | | Repository contents | Read-only | Read configuration and commit metadata | | Checks | Read-only | Read check run results | | Repository administration | Read-only | Read admin team(s) membership | @@ -1001,6 +1012,7 @@ The app should be subscribed to these events: * Pull request * Pull request review * Status +* Workflow Run There is a [`logo.png`](https://github.com/palantir/policy-bot/blob/develop/logo.png) provided if you'd like to use it as the GitHub application logo. The background diff --git a/policy/predicate/predicates.go b/policy/predicate/predicates.go index d62fa9b1..eb0e9bd0 100644 --- a/policy/predicate/predicates.go +++ b/policy/predicate/predicates.go @@ -31,6 +31,8 @@ type Predicates struct { HasSuccessfulStatus *HasSuccessfulStatus `yaml:"has_successful_status"` + HasWorkflowResult *HasWorkflowResult `yaml:"has_workflow_result"` + HasLabels *HasLabels `yaml:"has_labels"` Repository *Repository `yaml:"repository"` @@ -82,6 +84,10 @@ func (p *Predicates) Predicates() []Predicate { ps = append(ps, Predicate(p.HasSuccessfulStatus)) } + if p.HasWorkflowResult != nil { + ps = append(ps, Predicate(p.HasWorkflowResult)) + } + if p.HasLabels != nil { ps = append(ps, Predicate(p.HasLabels)) } diff --git a/policy/predicate/workflow.go b/policy/predicate/workflow.go new file mode 100644 index 00000000..7aed1228 --- /dev/null +++ b/policy/predicate/workflow.go @@ -0,0 +1,95 @@ +// Copyright 2018 Palantir Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package predicate + +import ( + "context" + "fmt" + "strings" + + "github.com/palantir/policy-bot/policy/common" + "github.com/palantir/policy-bot/pull" + "github.com/pkg/errors" +) + +type HasWorkflowResult struct { + Conclusions allowedConclusions `yaml:"conclusions"` + Workflows []string `yaml:"workflows"` +} + +func NewHasWorkflowResult(workflows []string, conclusions []string) *HasWorkflowResult { + conclusionsSet := make(allowedConclusions, len(conclusions)) + for _, conclusion := range conclusions { + conclusionsSet[conclusion] = unit{} + } + return &HasWorkflowResult{ + Conclusions: conclusionsSet, + Workflows: workflows, + } +} + +var _ Predicate = HasWorkflowResult{} + +func (pred HasWorkflowResult) Evaluate(ctx context.Context, prctx pull.Context) (*common.PredicateResult, error) { + workflowRuns, err := prctx.LatestWorkflowRuns() + if err != nil { + return nil, errors.Wrap(err, "failed to list latest workflow runs") + } + + conclusions := pred.Conclusions + if len(conclusions) == 0 { + conclusions = defaultConclusions + } + + predicateResult := common.PredicateResult{ + ValuePhrase: "workflow results", + ConditionPhrase: fmt.Sprintf("exist and have conclusion %s", conclusions.joinWithOr()), + } + + var missingResults []string + var failingWorkflows []string + for _, workflow := range pred.Workflows { + result, ok := workflowRuns[workflow] + if !ok { + missingResults = append(missingResults, workflow) + } + if _, allowed := conclusions[result]; !allowed { + failingWorkflows = append(failingWorkflows, workflow) + } + } + + if len(missingResults) > 0 { + predicateResult.Values = missingResults + predicateResult.Description = "One or more workflow runs are missing: " + strings.Join(missingResults, ", ") + predicateResult.Satisfied = false + return &predicateResult, nil + } + + if len(failingWorkflows) > 0 { + predicateResult.Values = failingWorkflows + predicateResult.Description = fmt.Sprintf("One or more workflow runs have not concluded with %s: %s", pred.Conclusions.joinWithOr(), strings.Join(failingWorkflows, ",")) + predicateResult.Satisfied = false + return &predicateResult, nil + } + + predicateResult.Values = pred.Workflows + predicateResult.Satisfied = true + + return &predicateResult, nil +} + +func (pred HasWorkflowResult) Trigger() common.Trigger { + return common.TriggerStatus +} diff --git a/policy/predicate/workflow_test.go b/policy/predicate/workflow_test.go new file mode 100644 index 00000000..c6c78f56 --- /dev/null +++ b/policy/predicate/workflow_test.go @@ -0,0 +1,217 @@ +// Copyright 2019 Palantir Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package predicate + +import ( + "context" + "testing" + + "github.com/palantir/policy-bot/policy/common" + "github.com/palantir/policy-bot/pull/pulltest" + "github.com/stretchr/testify/assert" +) + +type WorkflowTestCase struct { + name string + latestWorkflowRunsValue map[string]string + latestWorkflowRunsError error + predicate Predicate + ExpectedPredicateResult *common.PredicateResult +} + +func TestHasSuccessfulWorkflowRun(t *testing.T) { + commonTestCases := []WorkflowTestCase{ + { + name: "all workflows succeed", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "success", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: true, + Values: []string{".github/workflows/test.yml"}, + }, + }, + { + name: "multiple workflows succeed", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "success", + ".github/workflows/test2.yml": "success", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: true, + Values: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + }, + { + name: "a workflow fails", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "failure", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test.yml"}, + }, + }, + { + name: "multiple workflows fail", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "failure", + ".github/workflows/test2.yml": "failure", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + }, + { + name: "one success, one failure", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "success", + ".github/workflows/test2.yml": "failure", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test2.yml"}, + }, + }, + { + name: "a workflow is missing", + latestWorkflowRunsValue: map[string]string{}, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test.yml"}, + }, + }, + { + name: "multiple workflow are missing", + latestWorkflowRunsValue: map[string]string{}, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + }, + { + name: "a workflow is missing, the other workflow is skipped", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test2.yml": "skipped", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test.yml"}, + }, + }, + { + name: "a workflow is skipped, but skipped workflows are allowed", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "skipped", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml"}, + Conclusions: allowedConclusions{"skipped": {}}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: true, + Values: []string{".github/workflows/test.yml"}, + }, + }, + { + name: "a workflow succeeds, the other workflow is skipped, but skipped workflows are allowed", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "success", + ".github/workflows/test2.yml": "skipped", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + Conclusions: allowedConclusions{"skipped": {}, "success": {}}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: true, + Values: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + }, + }, + { + name: "a workflow fails, the other workflow is skipped, but skipped workflows are allowed", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "failure", + ".github/workflows/test2.yml": "skipped", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + Conclusions: allowedConclusions{"skipped": {}, "success": {}}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test.yml"}, + }, + }, + { + name: "a workflow succeeds, the other workflow is skipped, only skipped workflows are allowed", + latestWorkflowRunsValue: map[string]string{ + ".github/workflows/test.yml": "success", + ".github/workflows/test2.yml": "skipped", + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml", ".github/workflows/test2.yml"}, + Conclusions: allowedConclusions{"skipped": {}}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: false, + Values: []string{".github/workflows/test.yml"}, + }, + }, + } + + runWorkflowTestCase(t, commonTestCases) +} + +func runWorkflowTestCase(t *testing.T, cases []WorkflowTestCase) { + ctx := context.Background() + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + predicateResult, err := tc.predicate.Evaluate(ctx, &pulltest.Context{ + LatestWorkflowRunsValue: tc.latestWorkflowRunsValue, + LatestStatusesError: tc.latestWorkflowRunsError, + }) + if assert.NoError(t, err, "evaluation failed") { + assertPredicateResult(t, tc.ExpectedPredicateResult, predicateResult) + } + }) + } +} diff --git a/pull/context.go b/pull/context.go index 1e3329dd..ecf36868 100644 --- a/pull/context.go +++ b/pull/context.go @@ -124,6 +124,11 @@ type Context interface { // LatestStatuses returns a map of status check names to the latest result LatestStatuses() (map[string]string, error) + // LatestWorkflowRuns returns the latest GitHub Actions workflow runs for + // the pull request. The keys of the map are paths to the workflow files and + // the values are the conclusion of the latest run. + LatestWorkflowRuns() (map[string]string, error) + // Labels returns a list of labels applied on the Pull Request Labels() ([]string, error) } diff --git a/pull/github.go b/pull/github.go index 54b95cbc..7d59073c 100644 --- a/pull/github.go +++ b/pull/github.go @@ -26,6 +26,10 @@ import ( ) const ( + // GitHubAppID is the GitHub Actions App ID. When a check suite is created + // by GitHub Actions, its `app.id` field is set to this value. + GitHubAppID = 15368 + // MaxPullRequestFiles is the max number of files returned by GitHub // https://developer.github.com/v3/pulls/#list-pull-requests-files MaxPullRequestFiles = 3000 @@ -141,6 +145,7 @@ type GitHubContext struct { statuses map[string]string labels []string pushedAt map[string]time.Time + workflowRuns map[string]string } // NewGitHubContext creates a new pull.Context that makes GitHub requests to @@ -829,6 +834,89 @@ func (ghc *GitHubContext) getCheckStatuses() (map[string]string, error) { return statuses, nil } +func (ghc *GitHubContext) LatestWorkflowRuns() (map[string]string, error) { + if ghc.workflowRuns != nil { + return ghc.workflowRuns, nil + } + + workflowRuns, err := ghc.getWorkflowRuns() + if err != nil { + return nil, err + } + + ghc.workflowRuns = workflowRuns + + return workflowRuns, nil +} + +func (ghc *GitHubContext) getWorkflowRuns() (map[string]string, error) { + // TODO: We could use the REST API to get the path, once + // https://github.com/google/go-github/commit/7665f317d3985efe6a7828e3af2751c41b3526b1 + // is in a release. + var q struct { + Repository struct { + PullRequest struct { + Commits struct { + Edges []struct { + Node struct { + Commit struct { + CheckSuites struct { + Nodes []struct { + Conclusion string + WorkflowRun struct { + File struct { + Path string + } + } + } + PageInfo *v4PageInfo + } `graphql:"checkSuites(first: 100, after: $checkSuiteCursor, filterBy: {appId: $appId})"` + } + } + } + } `graphql:"commits(last: 1)"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + qvars := map[string]interface{}{ + "checkSuiteCursor": (*githubv4.String)(nil), + "owner": githubv4.String(ghc.owner), + "repo": githubv4.String(ghc.repo), + "number": githubv4.Int(ghc.number), + "appId": githubv4.Int(GitHubAppID), + } + + workflowRuns := make(map[string]string) + + for { + if err := ghc.v4client.Query(ghc.ctx, &q, qvars); err != nil { + return nil, errors.Wrap(err, "failed to load workflow runs") + } + + if len(q.Repository.PullRequest.Commits.Edges) == 0 { + break + } + + commits := q.Repository.PullRequest.Commits.Edges + + for _, commit := range commits { + checkSuites := commit.Node.Commit.CheckSuites.Nodes + for _, checkSuite := range checkSuites { + workflowRuns[checkSuite.WorkflowRun.File.Path] = strings.ToLower(checkSuite.Conclusion) + } + } + + firstCheckSuite := commits[0].Node.Commit.CheckSuites + + if !firstCheckSuite.PageInfo.UpdateCursor(qvars, "checkSuiteCursor") { + break + } + } + + return workflowRuns, nil +} + func (ghc *GitHubContext) Labels() ([]string, error) { if ghc.labels == nil { issueLabels, _, err := ghc.client.Issues.ListLabelsByIssue(ghc.ctx, ghc.owner, ghc.repo, ghc.number, &github.ListOptions{ diff --git a/pull/github_test.go b/pull/github_test.go index 3a69d3ce..f32d86f2 100644 --- a/pull/github_test.go +++ b/pull/github_test.go @@ -611,6 +611,54 @@ func TestPushedAt(t *testing.T) { }) } +func TestLatestWorkflowRunsNoCommits(t *testing.T) { + rp := &ResponsePlayer{} + noCommitsRule := rp.AddRule( + GraphQLNodePrefixMatcher("repository.pullRequest.commits"), + "testdata/responses/pull_no_commits.yml", + ) + + ctx := makeContext(t, rp, nil, nil) + runs, err := ctx.LatestWorkflowRuns() + require.NoError(t, err) + + assert.Len(t, runs, 0, "incorrect number of workflow runs") + assert.Equal(t, 1, noCommitsRule.Count, "incorrect http request count") +} + +func TestLatestWorkflowRunsNoCheckSuites(t *testing.T) { + rp := &ResponsePlayer{} + noCheckSuitesRule := rp.AddRule( + GraphQLNodePrefixMatcher("repository.pullRequest.commits"), + "testdata/responses/pull_no_check_suites.yml", + ) + + ctx := makeContext(t, rp, nil, nil) + runs, err := ctx.LatestWorkflowRuns() + require.NoError(t, err) + + assert.Len(t, runs, 0, "incorrect number of workflow runs") + assert.Equal(t, 1, noCheckSuitesRule.Count, "incorrect http request count") +} + +func TestLatestWorkflowRuns(t *testing.T) { + rp := &ResponsePlayer{} + commitsRule := rp.AddRule( + GraphQLNodePrefixMatcher("repository.pullRequest.commits"), + "testdata/responses/pull_check_suites.yml", + ) + + ctx := makeContext(t, rp, nil, nil) + runs, err := ctx.LatestWorkflowRuns() + require.NoError(t, err) + + assert.Len(t, runs, 3, "incorrect number of workflow runs") + assert.Equal(t, runs[".github/workflows/a.yml"], "success", "incorrect conclusion for workflow run a") + assert.Equal(t, runs[".github/workflows/b.yml"], "failure", "incorrect conclusion for workflow run b") + assert.Equal(t, runs[".github/workflows/c.yml"], "cancelled", "incorrect conclusion for workflow run c") + assert.Equal(t, 2, commitsRule.Count, "incorrect http request count") +} + func makeContext(t *testing.T, rp *ResponsePlayer, pr *github.PullRequest, gc GlobalCache) Context { ctx := context.Background() client := github.NewClient(&http.Client{Transport: rp}) diff --git a/pull/pulltest/context.go b/pull/pulltest/context.go index ea5d89d8..4ce2c423 100644 --- a/pull/pulltest/context.go +++ b/pull/pulltest/context.go @@ -71,6 +71,9 @@ type Context struct { LatestStatusesValue map[string]string LatestStatusesError error + LatestWorkflowRunsValue map[string]string + LatestWorkflowRunsError error + LabelsValue []string LabelsError error @@ -254,6 +257,10 @@ func (c *Context) LatestStatuses() (map[string]string, error) { return c.LatestStatusesValue, c.LatestStatusesError } +func (c *Context) LatestWorkflowRuns() (map[string]string, error) { + return c.LatestWorkflowRunsValue, c.LatestWorkflowRunsError +} + func (c *Context) Labels() ([]string, error) { return c.LabelsValue, c.LabelsError } diff --git a/pull/testdata/responses/pull_check_suites.yml b/pull/testdata/responses/pull_check_suites.yml new file mode 100644 index 00000000..1cd066b6 --- /dev/null +++ b/pull/testdata/responses/pull_check_suites.yml @@ -0,0 +1,82 @@ +- status: 200 + body: | + { + "errors": [], + "data": { + "repository": { + "pullRequest": { + "commits": { + "edges": [ + { + "node": { + "commit": { + "checkSuites": { + "nodes": [ + { + "conclusion": "SUCCESS", + "workflowRun": { + "file": { + "path": ".github/workflows/a.yml" + } + } + }, + { + "conclusion": "FAILURE", + "workflowRun": { + "file": { + "path": ".github/workflows/b.yml" + } + } + } + ], + "pageInfo": { + "endCursor": "2", + "hasNextPage": true + } + } + } + } + } + ] + } + } + } + } + } +- status: 200 + body: | + { + "errors": [], + "data": { + "repository": { + "pullRequest": { + "commits": { + "edges": [ + { + "node": { + "commit": { + "checkSuites": { + "nodes": [ + { + "conclusion": "CANCELLED", + "workflowRun": { + "file": { + "path": ".github/workflows/c.yml" + } + } + } + ], + "pageInfo": { + "endCursor": "3", + "hasNextPage": false + } + } + } + } + } + ] + } + } + } + } + } diff --git a/pull/testdata/responses/pull_no_check_suites.yml b/pull/testdata/responses/pull_no_check_suites.yml new file mode 100644 index 00000000..136b4008 --- /dev/null +++ b/pull/testdata/responses/pull_no_check_suites.yml @@ -0,0 +1,28 @@ +- status: 200 + body: | + { + "errors": [], + "data": { + "repository": { + "pullRequest": { + "commits": { + "edges": [ + { + "node": { + "commit": { + "checkSuites": { + "nodes": [], + "pageInfo": { + "endCursor": null, + "hasNextPage": false + } + } + } + } + } + ] + } + } + } + } + } diff --git a/pull/testdata/responses/pull_no_commits.yml b/pull/testdata/responses/pull_no_commits.yml new file mode 100644 index 00000000..9e4a6553 --- /dev/null +++ b/pull/testdata/responses/pull_no_commits.yml @@ -0,0 +1,15 @@ +- status: 200 + body: | + { + "errors": [], + "data": { + "repository": { + "pullRequest": { + "commits": { + "edges": [ + ] + } + } + } + } + } diff --git a/server/handler/workflow_run.go b/server/handler/workflow_run.go new file mode 100644 index 00000000..73994c8c --- /dev/null +++ b/server/handler/workflow_run.go @@ -0,0 +1,71 @@ +// Copyright 2018 Palantir Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "context" + "encoding/json" + + "github.com/google/go-github/v62/github" + "github.com/palantir/go-githubapp/githubapp" + "github.com/palantir/policy-bot/policy/common" + "github.com/palantir/policy-bot/pull" + "github.com/pkg/errors" +) + +type WorkflowRun struct { + Base +} + +func (h *WorkflowRun) Handles() []string { return []string{"workflow_run"} } + +func (h *WorkflowRun) Handle(ctx context.Context, eventType, deliveryID string, payload []byte) error { + // https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_run + // https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=completed#workflow_run + var event github.WorkflowRunEvent + if err := json.Unmarshal(payload, &event); err != nil { + return errors.Wrap(err, "failed to parse workflow_run event payload") + } + + if event.GetAction() != "completed" { + return nil + } + + repo := event.GetRepo() + ownerName := repo.GetOwner().GetLogin() + repoName := repo.GetName() + commitSHA := event.GetWorkflowRun().GetHeadSHA() + installationID := githubapp.GetInstallationIDFromEvent(&event) + + ctx, logger := githubapp.PrepareRepoContext(ctx, installationID, repo) + + evaluationFailures := 0 + for _, pr := range event.GetWorkflowRun().PullRequests { + if err := h.Evaluate(ctx, installationID, common.TriggerStatus, pull.Locator{ + Owner: ownerName, + Repo: repoName, + Number: pr.GetNumber(), + Value: pr, + }); err != nil { + evaluationFailures++ + logger.Error().Err(err).Msgf("Failed to evaluate pull request '%d' for SHA '%s'", pr.GetNumber(), commitSHA) + } + } + if evaluationFailures == 0 { + return nil + } + + return errors.Errorf("failed to evaluate %d pull requests", evaluationFailures) +}