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),