From af8fa99485a8f8062b5ef9bdc0db05b6675489d2 Mon Sep 17 00:00:00 2001 From: Iain Lane Date: Sun, 7 Jul 2024 11:00:20 +0100 Subject: [PATCH] feat(predicate): Add `has_workflow_result` predicate This is a new predicate which matches on the results of an entire GitHub Actions workflow. This is similar to `has_status`, except that matches on statuses only, which for GitHub Actions are roughly equivalent to _jobs_ in a Workflow. It's often convenient to match on every job. The workflows are given as paths in the repository, as these can't be dynamic and are easy to predict when writing policies. --- README.md | 15 + policy/predicate/predicates.go | 6 + policy/predicate/workflow.go | 94 +++++++ policy/predicate/workflow_test.go | 258 ++++++++++++++++++ pull/context.go | 5 + pull/github.go | 87 ++++++ pull/github_test.go | 33 +++ pull/pulltest/context.go | 7 + .../responses/pull_no_workflow_runs.yml | 6 + .../testdata/responses/pull_workflow_runs.yml | 91 ++++++ server/handler/workflow_run.go | 71 +++++ server/server.go | 1 + 12 files changed, 674 insertions(+) create mode 100644 policy/predicate/workflow.go create mode 100644 policy/predicate/workflow_test.go create mode 100644 pull/testdata/responses/pull_no_workflow_runs.yml create mode 100644 pull/testdata/responses/pull_workflow_runs.yml create mode 100644 server/handler/workflow_run.go diff --git a/README.md b/README.md index c350e8eb..c2974b49 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,19 @@ 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. + # If a workflow is run more than once for a commit - for example for a `push` + # and `pull_request` event, the most recent completed run for each event type + # will be considered. + 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: @@ -995,6 +1008,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 | @@ -1013,6 +1027,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 eeb9bb53..8bded609 100644 --- a/policy/predicate/predicates.go +++ b/policy/predicate/predicates.go @@ -35,6 +35,8 @@ type Predicates struct { // rather than just "success". HasSuccessfulStatus *HasSuccessfulStatus `yaml:"has_successful_status"` + HasWorkflowResult *HasWorkflowResult `yaml:"has_workflow_result"` + HasLabels *HasLabels `yaml:"has_labels"` Repository *Repository `yaml:"repository"` @@ -90,6 +92,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..2597af7d --- /dev/null +++ b/policy/predicate/workflow.go @@ -0,0 +1,94 @@ +// 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" + "slices" + "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,omitempty"` + Workflows []string `yaml:"workflows,omitempty"` +} + +func NewHasWorkflowResult(workflows []string, conclusions []string) *HasWorkflowResult { + return &HasWorkflowResult{ + Conclusions: conclusions, + 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") + } + + allowedConclusions := pred.Conclusions + if len(allowedConclusions) == 0 { + allowedConclusions = AllowedConclusions{"success"} + } + + predicateResult := common.PredicateResult{ + ValuePhrase: "workflow results", + ConditionPhrase: fmt.Sprintf("exist and have conclusion %s", allowedConclusions.joinWithOr()), + } + + var missingResults []string + var failingWorkflows []string + for _, workflow := range pred.Workflows { + conclusions, ok := workflowRuns[workflow] + if !ok { + missingResults = append(missingResults, workflow) + } + for _, conclusion := range conclusions { + if !slices.Contains(allowedConclusions, conclusion) { + 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..01b78d25 --- /dev/null +++ b/policy/predicate/workflow_test.go @@ -0,0 +1,258 @@ +// Copyright 2024 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: "a workflow fails and succeeds", + latestWorkflowRunsValue: map[string][]string{ + ".github/workflows/test.yml": {"failure", "success"}, + }, + 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 succeeds and is skipped, but skipped workflows are allowed", + latestWorkflowRunsValue: map[string][]string{ + ".github/workflows/test.yml": {"success", "skipped"}, + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.yml"}, + Conclusions: AllowedConclusions{"skipped", "success"}, + }, + ExpectedPredicateResult: &common.PredicateResult{ + Satisfied: true, + Values: []string{".github/workflows/test.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"}, + }, + }, + { + name: "a workflow succeeds and is skipped, only skipped workflows are allowed", + latestWorkflowRunsValue: map[string][]string{ + ".github/workflows/test.yml": {"success", "skipped"}, + }, + predicate: HasWorkflowResult{ + Workflows: []string{".github/workflows/test.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..da866bc7 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 conclusions of the latest runs, one per event type. + 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 f6dff032..70d45b5f 100644 --- a/pull/github.go +++ b/pull/github.go @@ -141,6 +141,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 +830,92 @@ 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 + } + + opt := &github.ListWorkflowRunsOptions{ + ExcludePullRequests: true, + HeadSHA: ghc.HeadSHA(), + ListOptions: github.ListOptions{ + PerPage: 100, + Page: 0, + }, + } + + // The same workflow file can be triggered multiple times. For example: + // + // on: + // pull_request: + // types: + // - opened + // - synchronize + // - edited + // push: + // + // Within a single event type, we will take the latest result. For example, + // for a `pull_request` event: + // + // If the workflow passes for the `opened` event, yet fails for `edited` + // then we would consider the workflow passed initially, and then failed if + // the user makes an edit to the PR that causes the workflow to run and fail. + // This is because the failure came later and it's what GitHub will show on + // the UI as the result of this workflow. + // + // If a workflow is triggered by multiple event types (`pull_request` and + // `push` in the above example), we will apply the same logic to each event + // type separately. Effectively this means that each one of the types, if + // triggered, will have to match the policy separately. Assuming allowed + // conclusions of `success`, here this would mean that both the + // `pull_request` and `push` events would have to pass, if triggered, for + // the workflow to be considered successful. + runsWithDate := make(map[string]map[string]*github.WorkflowRun) + for { + runs, resp, err := ghc.client.Actions.ListRepositoryWorkflowRuns(ghc.ctx, ghc.owner, ghc.repo, opt) + if err != nil { + return nil, errors.Wrapf(err, "failed to get workflow runs for page %d", opt.Page) + } + + for _, run := range runs.WorkflowRuns { + if run.GetStatus() != "completed" { + continue + } + + eventName := run.GetEvent() + + previousRuns := runsWithDate[*run.Path] + if previousRuns == nil { + previousRuns = make(map[string]*github.WorkflowRun) + runsWithDate[*run.Path] = previousRuns + } + + previousRun := previousRuns[eventName] + + // This is an older run than one we've already saw, so ignore it. + if previousRun != nil && run.GetUpdatedAt().Before(previousRun.GetUpdatedAt().Time) { + continue + } + + previousRuns[eventName] = run + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + workflowRuns := make(map[string][]string, len(runsWithDate)) + + for path, eventRuns := range runsWithDate { + for _, run := range eventRuns { + workflowRuns[path] = append(workflowRuns[path], run.GetConclusion()) + } + } + + 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 e0d1f025..29a1d8da 100644 --- a/pull/github_test.go +++ b/pull/github_test.go @@ -611,6 +611,39 @@ func TestPushedAt(t *testing.T) { }) } +func TestLatestWorkflowRunsNoRuns(t *testing.T) { + rp := &ResponsePlayer{} + noRunsRule := rp.AddRule( + ExactPathMatcher("/repos/testorg/testrepo/actions/runs"), + "testdata/responses/pull_no_workflow_runs.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, noRunsRule.Count, "incorrect http request count") +} + +func TestLatestWorkflowRuns(t *testing.T) { + rp := &ResponsePlayer{} + runsRule := rp.AddRule( + ExactPathMatcher("/repos/testorg/testrepo/actions/runs"), + "testdata/responses/pull_workflow_runs.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"], []string{"success", "skipped"}, "incorrect conclusion for workflow run a") + assert.Equal(t, runs[".github/workflows/b.yml"], []string{"failure"}, "incorrect conclusion for workflow run b") + assert.Equal(t, runs[".github/workflows/c.yml"], []string{"cancelled"}, "incorrect conclusion for workflow run c") + assert.Equal(t, 2, runsRule.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..225640bf 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_no_workflow_runs.yml b/pull/testdata/responses/pull_no_workflow_runs.yml new file mode 100644 index 00000000..313c70a6 --- /dev/null +++ b/pull/testdata/responses/pull_no_workflow_runs.yml @@ -0,0 +1,6 @@ +- status: 200 + body: | + { + "total_count": 0, + "workflow_runs": [] + } diff --git a/pull/testdata/responses/pull_workflow_runs.yml b/pull/testdata/responses/pull_workflow_runs.yml new file mode 100644 index 00000000..07816062 --- /dev/null +++ b/pull/testdata/responses/pull_workflow_runs.yml @@ -0,0 +1,91 @@ +- status: 200 + headers: + Link: | + ; rel="next", + ; rel="last" + body: | + { + "total_count": 7, + "workflow_runs": [ + { + "comment": "This one fails in id: 6, but that timestamp is earlier than this one, so is ignored", + "id": 1, + "path": ".github/workflows/a.yml", + "event": "pull_request", + "status": "completed", + "conclusion": "success", + "updated_at": "2021-01-01T00:00:00Z", + "html_url": "http://github.localhost/repos/testorg/testrepo/actions/runs/1", + "pull_requests": [] + }, + { + "comment": "This one fails in id: 5, and that timestamp is later than this one, so is taken as the resutl of the workflow", + "id": 2, + "path": ".github/workflows/b.yml", + "event": "pull_request", + "status": "completed", + "conclusion": "success", + "updated_at": "2021-01-01T00:00:00Z", + "pull_requests": [] + } + ] + } + +- status: 200 + headers: + Link: | + ; rel="prev", + ; rel="first" + body: | + { + "total_count": 7, + "workflow_runs": [ + { + "id": 3, + "path": ".github/workflows/c.yml", + "event": "pull_request", + "status": "completed", + "conclusion": "cancelled", + "updated_at": "2021-01-01T00:00:00Z", + "pull_requests": [] + }, + { + "id": 4, + "path": ".github/workflows/d.yml", + "event": "pull_request", + "status": "in_progress", + "conclusion": null, + "updated_at": "2021-01-01T00:00:00Z", + "pull_requests": [] + }, + { + "id": 5, + "path": ".github/workflows/b.yml", + "event": "pull_request", + "status": "completed", + "conclusion": "failure", + "updated_at": "2022-01-01T00:00:00Z", + "pull_requests": [] + }, + { + "id": 6, + "path": ".github/workflows/a.yml", + "event": "pull_request", + "status": "completed", + "conclusion": "failure", + "updated_at": "2020-01-01T00:00:00Z", + "html_url": "http://github.localhost/repos/testorg/testrepo/actions/runs/1", + "pull_requests": [] + }, + { + "id": 7, + "path": ".github/workflows/a.yml", + "event": "push", + "status": "completed", + "conclusion": "skipped", + "updated_at": "2020-01-01T00:00:00Z", + "html_url": "http://github.localhost/repos/testorg/testrepo/actions/runs/1", + "pull_requests": [] + } + ] + } diff --git a/server/handler/workflow_run.go b/server/handler/workflow_run.go new file mode 100644 index 00000000..d6b69968 --- /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/v63/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) +} diff --git a/server/server.go b/server/server.go index 2d12d1ad..fe495051 100644 --- a/server/server.go +++ b/server/server.go @@ -186,6 +186,7 @@ func New(c *Config) (*Server, error) { &handler.IssueComment{Base: basePolicyHandler}, &handler.Status{Base: basePolicyHandler}, &handler.CheckRun{Base: basePolicyHandler}, + &handler.WorkflowRun{Base: basePolicyHandler}, }, c.Github.App.WebhookSecret, githubapp.WithErrorCallback(githubapp.MetricsErrorCallback(base.Registry())),