From a793ea3325dbc231dcf5680854d6c40000eb696b Mon Sep 17 00:00:00 2001 From: atatkin <26586327+atatkin@users.noreply.github.com> Date: Thu, 14 Mar 2024 15:02:32 -0700 Subject: [PATCH] Add simulation API (#725) This adds a new POST route api/simulate/:owner/:repo/:number. This route requires a GitHub bearer token with access to read the the underlying pull request. When called, policy-bot will re-evaluate the policy of the referenced pr and return a simulated result back to the caller. The result does NOT get written back to the pull request as a STATUS. A request body can be included specifying a number of options which can modify how the simulation runs. These should be relatively easy to extend with additional options in the future. Today this supports: * Simulating new comments. * Simulating new reviews. * Simulating ignoring existing reviews and comments. * Simulating a change of the base branch. The route can also be used without any options to trigger policy-bot to re-evaluate the pull request as is. The returned simulated result does not currently contain all of the fields of the base result but it should be easy to extend as needed. --- README.md | 79 +++++++ policy/common/actor.go | 12 +- policy/simulated/context.go | 132 ++++++++++++ policy/simulated/context_test.go | 342 +++++++++++++++++++++++++++++++ policy/simulated/options.go | 133 ++++++++++++ policy/simulated/options_test.go | 92 +++++++++ server/handler/simulate.go | 180 ++++++++++++++++ server/server.go | 5 + 8 files changed, 969 insertions(+), 6 deletions(-) create mode 100644 policy/simulated/context.go create mode 100644 policy/simulated/context_test.go create mode 100644 policy/simulated/options.go create mode 100644 policy/simulated/options_test.go create mode 100644 server/handler/simulate.go diff --git a/README.md b/README.md index 7124f80b..a114a057 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ UI to view the detailed approval status of any pull request. - [Approval Policies](#approval-policies) - [Disapproval Policy](#disapproval-policy) - [Testing and Debugging Policies](#testing-and-debugging-policies) + - [Simulation API](#simulation-api) - [Caveats and Notes](#caveats-and-notes) - [Disapproval is Disabled by Default](#disapproval-is-disabled-by-default) - [Interactions with GitHub Reviews](#interactions-with-github-reviews) @@ -549,6 +550,84 @@ $ rcode=$(curl https://policybot.domain/api/validate -XPUT -T path/to/policy.yml $ if [[ "${rcode}" -gt 299 ]]; then cat /tmp/response && exit 1; fi ``` +#### Simulation API + +It can be useful to simulate how Policy Bot would evaluate a pull request if certain conditions were changed. For example: adding a review from a specific user or group, or adjusting the base branch. + +An API endpoint exists at `api/simulate/:org/:repo/:prNumber` to simiulate the result of a pull request. Simulations using this endpoint will NOT write the result back to the pull request status check and will instead return the result. + +This API requires a GitHub token be passed as a bearer token. The token must have the ability to read the pull request the simulation is being run against. + +The API can be used as such: + +```sh +$ curl https://policybot.domain/api/simulate/:org/:repo/:number -H 'authorization: Bearer ' -H 'content-type: application/json' -X POST -d '' +``` + +Currently the data payload can be configured with a few options: + +Ignore any comments from specific users, team members, org members or with specific permissions +```json +{ + "ignore_comments":{ + "users":["ignored-user"], + "teams":["ignored-team"], + "organizations":["ignored-org"], + "permissions":["admin"] + } +} +``` + +Ignore any reviews from specific users, team members, org members or with specific permissions +```json +{ + "ignore_reviews":{ + "users":["ignored-user"], + "teams":["ignored-team"], + "organizations":["ignored-org"], + "permissions":["admin"] + } +} +``` + +Simulate the pull request as if the following comments from the following users had also been added +```json +{ + "add_comments":[ + { + "author":"not-ignored-user", + "body":":+1:", + "created_at": "2020-11-30T14:20:28.000+07:00", + "last_edited_at": "2020-11-30T14:20:28.000+07:00" + } + ] +} +``` + +Simulate the pull request as if the following reviews from the following users had also been added +```json +{ + "add_reviews":[ + { + "author":"not-ignored-user", + "state": "approved", + "body": "test approved review", + "created_at": "2020-11-30T14:20:28.000+07:00", + "last_edited_at": "2020-11-30T14:20:28.000+07:00" + } + ] +} +``` + +Choose a different base branch when simulating the pull request evaluation +```json +{ + "base_branch": "test-branch" +} +``` + +The above can be combined to form more complex simulations. If a Simulation is run without any data being passed, the pull request is evaluated as is. + ### Caveats and Notes There are several additional behaviors that follow from the rules above that diff --git a/policy/common/actor.go b/policy/common/actor.go index b7f7f55c..f72de3a7 100644 --- a/policy/common/actor.go +++ b/policy/common/actor.go @@ -26,17 +26,17 @@ import ( // team and organization memberships. The set of allowed actors is the union of // all conditions in this structure. type Actors struct { - Users []string `yaml:"users"` - Teams []string `yaml:"teams"` - Organizations []string `yaml:"organizations"` + Users []string `yaml:"users" json:"users"` + Teams []string `yaml:"teams" json:"teams"` + Organizations []string `yaml:"organizations" json:"organizations"` // Deprecated: use Permissions with "admin" or "write" - Admins bool `yaml:"admins"` - WriteCollaborators bool `yaml:"write_collaborators"` + Admins bool `yaml:"admins" json:"-"` + WriteCollaborators bool `yaml:"write_collaborators" json:"-"` // A list of GitHub collaborator permissions that are allowed. Values may // be any of "admin", "maintain", "write", "triage", and "read". - Permissions []pull.Permission + Permissions []pull.Permission `yaml:"permissions" json:"permissions"` } // IsEmpty returns true if no conditions for actors are defined. diff --git a/policy/simulated/context.go b/policy/simulated/context.go new file mode 100644 index 00000000..54e642c8 --- /dev/null +++ b/policy/simulated/context.go @@ -0,0 +1,132 @@ +// 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 simulated + +import ( + "context" + + "github.com/palantir/policy-bot/pull" +) + +type Context struct { + pull.Context + ctx context.Context + options Options +} + +func NewContext(ctx context.Context, pullContext pull.Context, options Options) *Context { + return &Context{Context: pullContext, options: options} +} + +func (c *Context) Comments() ([]*pull.Comment, error) { + comments, err := c.Context.Comments() + if err != nil { + return nil, err + } + + comments, err = c.filterIgnoredComments(c.Context, comments) + if err != nil { + return nil, err + } + + comments = c.addApprovalComment(comments) + return comments, nil +} + +func (c *Context) filterIgnoredComments(prCtx pull.Context, comments []*pull.Comment) ([]*pull.Comment, error) { + if c.options.IgnoreComments == nil { + return comments, nil + } + + var filteredComments []*pull.Comment + for _, comment := range comments { + isActor, err := c.options.IgnoreComments.IsActor(c.ctx, prCtx, comment.Author) + if err != nil { + return nil, err + } + + if isActor { + continue + } + + filteredComments = append(filteredComments, comment) + } + + return filteredComments, nil +} + +func (c *Context) addApprovalComment(comments []*pull.Comment) []*pull.Comment { + var commentsToAdd []*pull.Comment + for _, comment := range c.options.AddComments { + commentsToAdd = append(commentsToAdd, comment.toPullComment()) + } + + return append(comments, commentsToAdd...) +} + +func (c *Context) Reviews() ([]*pull.Review, error) { + reviews, err := c.Context.Reviews() + if err != nil { + return nil, err + } + + reviews, err = c.filterIgnoredReviews(c.Context, reviews) + if err != nil { + return nil, err + } + + reviews = c.addApprovalReview(reviews) + return reviews, nil +} + +func (c *Context) filterIgnoredReviews(prCtx pull.Context, reviews []*pull.Review) ([]*pull.Review, error) { + if c.options.IgnoreReviews == nil { + return reviews, nil + } + + var filteredReviews []*pull.Review + for _, review := range reviews { + isActor, err := c.options.IgnoreReviews.IsActor(c.ctx, prCtx, review.Author) + if err != nil { + return nil, err + } + + if isActor { + continue + } + + filteredReviews = append(filteredReviews, review) + } + + return filteredReviews, nil +} + +func (c *Context) addApprovalReview(reviews []*pull.Review) []*pull.Review { + var reviewsToAdd []*pull.Review + for _, review := range c.options.AddReviews { + reviewsToAdd = append(reviewsToAdd, review.toPullReview()) + } + + return append(reviews, reviewsToAdd...) +} + +func (c *Context) Branches() (string, string) { + base, head := c.Context.Branches() + if c.options.BaseBranch != "" { + return c.options.BaseBranch, head + } + + return base, head +} diff --git a/policy/simulated/context_test.go b/policy/simulated/context_test.go new file mode 100644 index 00000000..e97fe5ec --- /dev/null +++ b/policy/simulated/context_test.go @@ -0,0 +1,342 @@ +// 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 simulated + +import ( + "sort" + "testing" + + "github.com/palantir/policy-bot/policy/common" + "github.com/palantir/policy-bot/pull" + "github.com/palantir/policy-bot/pull/pulltest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestComments(t *testing.T) { + tests := map[string]struct { + Comments []*pull.Comment + Options Options + ExpectedCommentAuthors []string + TeamMembership map[string][]string + OrgMembership map[string][]string + Collaborators []*pull.Collaborator + ExpectedError bool + }{ + "ignore comments by user": { + Comments: []*pull.Comment{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + ExpectedCommentAuthors: []string{"rrandom"}, + Options: Options{ + IgnoreComments: &common.Actors{ + Users: []string{"iignore"}, + }, + }, + }, + "ignore comments by team membership": { + Comments: []*pull.Comment{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + IgnoreComments: &common.Actors{ + Teams: []string{"test-team-1"}, + }, + }, + TeamMembership: map[string][]string{ + "iignore": {"test-team-1"}, + }, + ExpectedCommentAuthors: []string{"rrandom"}, + }, + "ignore comments by org membership": { + Comments: []*pull.Comment{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + IgnoreComments: &common.Actors{ + Organizations: []string{"test-org-1"}, + }, + }, + OrgMembership: map[string][]string{ + "iignore": {"test-org-1"}, + }, + ExpectedCommentAuthors: []string{"rrandom"}, + }, + "ignore comments by permission": { + Comments: []*pull.Comment{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + IgnoreComments: &common.Actors{ + Permissions: []pull.Permission{pull.PermissionRead}, + }, + }, + Collaborators: []*pull.Collaborator{ + {Name: "iignore", Permissions: []pull.CollaboratorPermission{ + {Permission: pull.PermissionRead}, + }}, + }, + ExpectedCommentAuthors: []string{"rrandom"}, + }, + "do not ignore any comments": { + Comments: []*pull.Comment{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + ExpectedCommentAuthors: []string{"rrandom", "iignore"}, + }, + "add new comment by sperson": { + Comments: []*pull.Comment{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + AddComments: []Comment{ + {Author: "sperson", Body: ":+1:"}, + }, + }, + ExpectedCommentAuthors: []string{"rrandom", "iignore", "sperson"}, + }, + "add new comment by sperson and ignore one from iignore": { + Comments: []*pull.Comment{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + AddComments: []Comment{ + {Author: "sperson", Body: ":+1:"}, + }, + IgnoreComments: &common.Actors{ + Users: []string{"iignore"}, + }, + }, + ExpectedCommentAuthors: []string{"rrandom", "sperson"}, + }, + } + + for message, test := range tests { + test.Options.setDefaults() + context := Context{ + Context: &pulltest.Context{ + CommentsValue: test.Comments, + TeamMemberships: test.TeamMembership, + OrgMemberships: test.OrgMembership, + CollaboratorsValue: test.Collaborators, + }, + options: test.Options, + } + + sort.Strings(test.ExpectedCommentAuthors) + + comments, err := context.Comments() + if test.ExpectedError { + assert.Error(t, err, message) + } else { + require.NoError(t, err, message) + assert.Equal(t, test.ExpectedCommentAuthors, getCommentAuthors(comments), message) + } + } +} + +func getCommentAuthors(comments []*pull.Comment) []string { + var authors []string + for _, c := range comments { + authors = append(authors, c.Author) + } + + sort.Strings(authors) + return authors +} + +func TestReviews(t *testing.T) { + tests := map[string]struct { + Reviews []*pull.Review + Options Options + ExpectedReviewAuthors []string + ExpectedError bool + TeamMembership map[string][]string + OrgMembership map[string][]string + Collaborators []*pull.Collaborator + }{ + "ignore reviews by iignore": { + Reviews: []*pull.Review{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + ExpectedReviewAuthors: []string{"rrandom"}, + Options: Options{ + IgnoreReviews: &common.Actors{ + Users: []string{"iignore"}, + }, + }, + }, + "ignore reviews by team membership": { + Reviews: []*pull.Review{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + IgnoreReviews: &common.Actors{ + Teams: []string{"test-team-1"}, + }, + }, + TeamMembership: map[string][]string{ + "iignore": {"test-team-1"}, + }, + ExpectedReviewAuthors: []string{"rrandom"}, + }, + "ignore reviews by org membership": { + Reviews: []*pull.Review{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + IgnoreReviews: &common.Actors{ + Organizations: []string{"test-org-1"}, + }, + }, + OrgMembership: map[string][]string{ + "iignore": {"test-org-1"}, + }, + ExpectedReviewAuthors: []string{"rrandom"}, + }, + "ignore reviews by permission": { + Reviews: []*pull.Review{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + IgnoreReviews: &common.Actors{ + Permissions: []pull.Permission{pull.PermissionRead}, + }, + }, + Collaborators: []*pull.Collaborator{ + {Name: "iignore", Permissions: []pull.CollaboratorPermission{ + {Permission: pull.PermissionRead}, + }}, + }, + ExpectedReviewAuthors: []string{"rrandom"}, + }, + "do not ignore any reviews": { + Reviews: []*pull.Review{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + ExpectedReviewAuthors: []string{"rrandom", "iignore"}, + }, + "add new review by sperson": { + Reviews: []*pull.Review{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + AddReviews: []Review{ + {Author: "sperson", State: "approved"}, + }, + }, + ExpectedReviewAuthors: []string{"rrandom", "iignore", "sperson"}, + }, + "add new review by sperson and ignore one from iignore": { + Reviews: []*pull.Review{ + {Author: "rrandom"}, + {Author: "iignore"}, + }, + Options: Options{ + AddReviews: []Review{ + {Author: "sperson", State: "approved"}, + }, + IgnoreReviews: &common.Actors{ + Users: []string{"iignore"}, + }, + }, + ExpectedReviewAuthors: []string{"rrandom", "sperson"}, + }, + } + + for message, test := range tests { + test.Options.setDefaults() + context := Context{ + Context: &pulltest.Context{ + ReviewsValue: test.Reviews, + TeamMemberships: test.TeamMembership, + OrgMemberships: test.OrgMembership, + CollaboratorsValue: test.Collaborators, + }, + options: test.Options, + } + + sort.Strings(test.ExpectedReviewAuthors) + + reviews, err := context.Reviews() + if test.ExpectedError { + assert.Error(t, err, message) + } else { + require.NoError(t, err, message) + assert.Equal(t, test.ExpectedReviewAuthors, getReviewAuthors(reviews), message) + } + } +} + +func getReviewAuthors(reviews []*pull.Review) []string { + var authors []string + for _, c := range reviews { + authors = append(authors, c.Author) + } + + sort.Strings(authors) + return authors +} + +func TestBranches(t *testing.T) { + tests := map[string]struct { + Base string + Head string + Options Options + ExpectedBase string + ExpectedHead string + }{ + "use default base branch": { + Base: "develop", + Head: "aa/feature1", + ExpectedBase: "develop", + ExpectedHead: "aa/feature1", + }, + "use simulated base branch": { + Base: "develop", + Head: "aa/feature1", + ExpectedBase: "simulated-develop", + ExpectedHead: "aa/feature1", + Options: Options{ + BaseBranch: "simulated-develop", + }, + }, + } + + for message, test := range tests { + test.Options.setDefaults() + context := Context{ + Context: &pulltest.Context{BranchBaseName: test.Base, BranchHeadName: test.Head}, + options: test.Options, + } + + base, head := context.Branches() + assert.Equal(t, test.ExpectedBase, base, message) + assert.Equal(t, test.ExpectedHead, head, message) + } +} diff --git a/policy/simulated/options.go b/policy/simulated/options.go new file mode 100644 index 00000000..b7765589 --- /dev/null +++ b/policy/simulated/options.go @@ -0,0 +1,133 @@ +// 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 simulated + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/palantir/policy-bot/policy/common" + "github.com/palantir/policy-bot/pull" + "github.com/pkg/errors" +) + +// Options should contain optional data that can be used to modify the results of the methods on the simulated Context. +type Options struct { + IgnoreComments *common.Actors `json:"ignore_comments"` + IgnoreReviews *common.Actors `json:"ignore_reviews"` + AddComments []Comment `json:"add_comments"` + AddReviews []Review `json:"add_reviews"` + BaseBranch string `json:"base_branch"` +} + +func NewOptionsFromRequest(r *http.Request) (Options, error) { + var o Options + if r.Body == nil { + return o, nil + } + + if err := json.NewDecoder(r.Body).Decode(&o); err != nil { + return o, errors.Wrap(err, "failed to unmarshal body into options") + } + + o.setDefaults() + return o, nil +} + +// setDefaults sets any values for the options that were not intentionally set in the request body but which should have +// consistent values for the length of the simulation, such as the created time for a comment or review. +func (o *Options) setDefaults() { + for i, review := range o.AddReviews { + id := fmt.Sprintf("simulated-reviewID-%d", i) + sha := fmt.Sprintf("simulated-reviewSHA-%d", i) + + review.setDefaults(id, sha) + o.AddReviews[i] = review + } + + for i, comment := range o.AddComments { + comment.setDefaults() + o.AddComments[i] = comment + } +} + +type Comment struct { + CreatedAt *time.Time `json:"created_at"` + LastEditedAt *time.Time `json:"last_edited_at"` + Author string `json:"author"` + Body string `json:"body"` +} + +// setDefaults sets the createdAt and lastEdtedAt values to time.Now() if they are otherwise unset +func (c *Comment) setDefaults() { + now := time.Now() + if c.CreatedAt == nil { + c.CreatedAt = &now + } + + if c.LastEditedAt == nil { + c.LastEditedAt = &now + } +} + +func (c *Comment) toPullComment() *pull.Comment { + return &pull.Comment{ + CreatedAt: *c.CreatedAt, + LastEditedAt: *c.LastEditedAt, + Author: c.Author, + Body: c.Body, + } +} + +type Review struct { + ID string `json:"-"` + SHA string `json:"-"` + CreatedAt *time.Time `json:"created_at"` + LastEditedAt *time.Time `json:"last_edited_at"` + Author string `json:"author"` + Body string `json:"body"` + State string `json:"state"` + Teams []string `json:"-"` +} + +// setDefaults sets the createdAt and lastEdtedAt values to time.Now() if they are otherwise unset +func (r *Review) setDefaults(id, sha string) { + now := time.Now() + if r.CreatedAt == nil { + r.CreatedAt = &now + } + + if r.LastEditedAt == nil { + r.LastEditedAt = &now + } + + r.ID = id + r.SHA = sha +} + +func (r *Review) toPullReview() *pull.Review { + return &pull.Review{ + ID: r.ID, + SHA: r.SHA, + CreatedAt: *r.CreatedAt, + LastEditedAt: *r.LastEditedAt, + Author: r.Author, + State: pull.ReviewState(r.State), + Body: r.Body, + Teams: r.Teams, + } +} diff --git a/policy/simulated/options_test.go b/policy/simulated/options_test.go new file mode 100644 index 00000000..49484ef1 --- /dev/null +++ b/policy/simulated/options_test.go @@ -0,0 +1,92 @@ +// 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 simulated + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/palantir/policy-bot/pull" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOptionsFromRequest(t *testing.T) { + body := ` + { + "ignore_comments":{ + "users":["iignore"], + "teams":[""], + "organizations":[""], + "permissions":["read"] + }, + "ignore_reviews":{ + "users":["iignore"], + "teams":[""], + "organizations":[""], + "permissions":["read"] + }, + "add_comments":[ + {"author":"iignore", "body":":+1:"} + ], + "add_reviews":[ + {"author":"iignore", "body":":+1:", "state":"approved"} + ], + "base_branch":"test-base" + }` + + opt, err := NewOptionsFromRequest(httptest.NewRequest(http.MethodPost, "http:", bytes.NewBuffer([]byte(body)))) + require.NoError(t, err) + + assert.Equal(t, []string{"iignore"}, opt.IgnoreComments.Users) + assert.Equal(t, pull.PermissionRead, opt.IgnoreComments.Permissions[0]) + assert.Equal(t, []string{"iignore"}, opt.IgnoreReviews.Users) + assert.Equal(t, pull.PermissionRead, opt.IgnoreReviews.Permissions[0]) + + assert.Equal(t, "iignore", opt.AddComments[0].Author) + assert.Equal(t, ":+1:", opt.AddComments[0].Body) + + assert.Equal(t, "iignore", opt.AddReviews[0].Author) + assert.Equal(t, ":+1:", opt.AddReviews[0].Body) + + assert.Equal(t, "approved", opt.AddReviews[0].State) + assert.Equal(t, "test-base", opt.BaseBranch) +} + +func TestOptionDefaults(t *testing.T) { + options := Options{ + AddComments: []Comment{ + {Author: "aperson", Body: ":+1:"}, + {Author: "otherperson", Body: ":+1:"}, + }, + AddReviews: []Review{ + {Author: "aperson", Body: ":+1:"}, + {Author: "otherperson", Body: ":+1:"}, + }, + } + + options.setDefaults() + for _, comment := range options.AddComments { + assert.False(t, comment.CreatedAt.IsZero()) + assert.False(t, comment.LastEditedAt.IsZero()) + } + + for _, review := range options.AddReviews { + assert.False(t, review.CreatedAt.IsZero()) + assert.False(t, review.LastEditedAt.IsZero()) + } +} diff --git a/server/handler/simulate.go b/server/handler/simulate.go new file mode 100644 index 00000000..6b1bdbc6 --- /dev/null +++ b/server/handler/simulate.go @@ -0,0 +1,180 @@ +// 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" + "net/http" + "strings" + + "github.com/palantir/go-baseapp/baseapp" + "github.com/palantir/go-githubapp/githubapp" + "github.com/palantir/policy-bot/policy" + "github.com/palantir/policy-bot/policy/common" + "github.com/palantir/policy-bot/policy/simulated" + "github.com/palantir/policy-bot/pull" + "github.com/pkg/errors" +) + +// Simulate provides a baseline for handlers to perform simulated pull request evaluations and +// either return the result or display it in the ui. +type Simulate struct { + Base +} + +// SimulationResponse is the response returned from Simulate, this is a trimmed down version of common.Result with json +// tags. This struct and the newSimulationResponse constructor can be extended to include extra content from common.Result. +type SimulationResponse struct { + Name string `json:"name"` + Description string `json:"description:"` + StatusDescription string `json:"status_description"` + Status string `json:"status"` + Error string `json:"error"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +func (h *Simulate) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + token := getToken(r) + if token == "" { + return writeAPIError(w, http.StatusUnauthorized, "missing token") + } + + client, err := h.NewTokenClient(token) + if err != nil { + return errors.Wrap(err, "failed to create token client") + } + + owner, repo, number, ok := parsePullParams(r) + if !ok { + return writeAPIError(w, http.StatusBadRequest, "failed to parse pull request parameters from request") + } + + pr, _, err := client.PullRequests.Get(ctx, owner, repo, number) + if err != nil { + if isNotFound(err) { + return writeAPIError(w, http.StatusNotFound, "failed to find pull request") + } + + return errors.Wrap(err, "failed to get pull request") + } + + installation, err := h.Installations.GetByOwner(ctx, owner) + if err != nil { + return writeAPIError(w, http.StatusNotFound, "not installed in org") + } + + ctx, _ = h.PreparePRContext(ctx, installation.ID, pr) + options, err := simulated.NewOptionsFromRequest(r) + if err != nil { + return writeAPIError(w, http.StatusBadRequest, "failed to parse options from request") + } + + result, err := h.getSimulatedResult(ctx, installation, pull.Locator{ + Owner: owner, + Repo: repo, + Number: number, + Value: pr, + }, options) + + if err != nil { + return errors.Wrap(err, "failed to get approval result for pull request") + } + + response := newSimulationResponse(result) + baseapp.WriteJSON(w, http.StatusOK, response) + return nil +} + +func getToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + if token, ok := strings.CutPrefix(auth, "Bearer "); ok { + return token + } + + return "" +} + +func (h *Simulate) getSimulatedResult(ctx context.Context, installation githubapp.Installation, loc pull.Locator, options simulated.Options) (*common.Result, error) { + simulatedCtx, config, err := h.newSimulatedContext(ctx, installation.ID, loc, options) + switch { + case err != nil: + return nil, errors.Wrap(err, "failed to generate eval context") + case config.LoadError != nil: + return nil, errors.Wrap(config.LoadError, "failed to load policy file") + case config.ParseError != nil: + return nil, errors.Wrap(config.ParseError, "failed to parse policy") + case config.Config == nil: + // no policy file found on base branch + return nil, nil + } + + evaluator, err := policy.ParsePolicy(config.Config) + if err != nil { + return nil, errors.Wrap(err, "failed to get policy evaluator") + } + + result := evaluator.Evaluate(ctx, simulatedCtx) + return &result, nil +} + +func (h *Simulate) newSimulatedContext(ctx context.Context, installationID int64, loc pull.Locator, options simulated.Options) (*simulated.Context, *FetchedConfig, error) { + client, err := h.NewInstallationClient(installationID) + if err != nil { + return nil, nil, err + } + + v4client, err := h.NewInstallationV4Client(installationID) + if err != nil { + return nil, nil, err + } + + mbrCtx := NewCrossOrgMembershipContext(ctx, client, loc.Owner, h.Installations, h.ClientCreator) + prctx, err := pull.NewGitHubContext(ctx, mbrCtx, h.GlobalCache, client, v4client, loc) + if err != nil { + return nil, nil, err + } + + simulatedPRCtx := simulated.NewContext(ctx, prctx, options) + baseBranch, _ := simulatedPRCtx.Branches() + owner := simulatedPRCtx.RepositoryOwner() + repository := simulatedPRCtx.RepositoryName() + fetchedConfig := h.ConfigFetcher.ConfigForRepositoryBranch(ctx, client, owner, repository, baseBranch) + return simulatedPRCtx, &fetchedConfig, nil +} + +func newSimulationResponse(result *common.Result) *SimulationResponse { + var response SimulationResponse + if result != nil { + if result.Error != nil { + response.Error = result.Error.Error() + } + + response.Name = result.Name + response.Description = result.Description + response.StatusDescription = result.StatusDescription + response.Status = result.Status.String() + } + + return &response +} + +func writeAPIError(w http.ResponseWriter, code int, message string) error { + baseapp.WriteJSON(w, code, ErrorResponse{Error: message}) + return nil +} diff --git a/server/server.go b/server/server.go index c14a1f6e..7575f0f8 100644 --- a/server/server.go +++ b/server/server.go @@ -214,9 +214,14 @@ func New(c *Config) (*Server, error) { // webhook route mux.Handle(pat.Post(githubapp.DefaultWebhookRoute), dispatcher) + simulateHandler := &handler.Simulate{ + Base: basePolicyHandler, + } + // additional API routes mux.Handle(pat.Get("/api/health"), handler.Health()) mux.Handle(pat.Put("/api/validate"), handler.Validate()) + mux.Handle(pat.Post("/api/simulate/:owner/:repo/:number"), hatpear.Try(simulateHandler)) mux.Handle(pat.Get(oauth2.DefaultRoute), oauth2.NewHandler( oauth2.GetConfig(c.Github, nil), oauth2.ForceTLS(forceTLS),