From 7642e8df6bc2b816c07130beaf780aa5266b0e1a Mon Sep 17 00:00:00 2001 From: "Giau. Tran Minh" Date: Thu, 29 Aug 2024 10:30:13 +0700 Subject: [PATCH] atlasaction: allow config approver users on the action --- atlasaction/action.go | 100 ++++++++++++++++++++++++++++++++- go.mod | 2 + go.sum | 4 ++ schema/plan/approve/action.yml | 9 +++ 4 files changed, 112 insertions(+), 3 deletions(-) diff --git a/atlasaction/action.go b/atlasaction/action.go index c472eac..fb8a1ef 100644 --- a/atlasaction/action.go +++ b/atlasaction/action.go @@ -23,6 +23,7 @@ import ( "time" "ariga.io/atlas-go-sdk/atlasexec" + "github.com/shurcooL/githubv4" "golang.org/x/oauth2" ) @@ -606,6 +607,11 @@ func (a *Actions) SchemaPlanApprove(ctx context.Context) error { return fmt.Errorf("unable to get the trigger context: %w", err) case tc.PullRequest != nil: return fmt.Errorf("the action should be run in a branch context") + default: + // Check the approval checks for the schema plan. + if err = a.ApprovalChecks(ctx, tc); err != nil { + return err + } } params := &atlasexec.SchemaPlanApproveParams{ ConfigURL: a.GetInput("config"), @@ -693,6 +699,37 @@ func (a *Actions) SchemaApply(ctx context.Context) error { return nil } +// ApprovalChecks checks the approval checks for the schema plan. +// It will fail the action if the approval checks are not satisfied. +func (a *Actions) ApprovalChecks(ctx context.Context, tc *TriggerContext) error { + if !a.GetBoolInput("require-approval") { + return nil + } + triggerComment := "/approved" + approvers := a.GetArrayInput("approvers") + c := a.GithubClient(tc.Repo, tc.SCM.APIURL) + pr, err := c.MergedPullRequest(ctx, tc.Commit) + if err != nil { + return fmt.Errorf("failed to get the pull request: %w", err) + } + comments, err := c.getIssueComments(ctx, pr) + if err != nil { + return fmt.Errorf("failed to get the pull request comments: %w", err) + } + approved := slices.IndexFunc(comments, func(c githubIssueComment) bool { + if c.AuthorHasAccess() && c.HasCommand(triggerComment) { + return len(approvers) == 0 || slices.Contains(approvers, c.Author()) + } + return false + }) + if approved == -1 { + a.Errorf("Please comment %q on the pull request to approve the action: %s", triggerComment, pr.URL) + return errors.New("Approval checks failed") + } + a.Infof("The action approved by %q", comments[approved].Author()) + return nil +} + // WorkingDir returns the working directory for the action. func (a *Actions) WorkingDir() string { return a.GetInput("working-directory") @@ -1053,10 +1090,15 @@ func RenderTemplate(name string, data any) (string, error) { type ( githubIssueComment struct { - ID int `json:"id"` - Body string `json:"body"` + ID int `json:"id"` + Body string `json:"body"` + AuthorAssociation string `json:"author_association"` + User githubUser `json:"user"` + } + githubUser struct { + ID int `json:"id"` + Login string `json:"login"` } - pullRequestComment struct { ID int `json:"id,omitempty"` Body string `json:"body"` @@ -1074,11 +1116,48 @@ type ( baseURL string repo string client *http.Client + gql *githubv4.Client } ) const defaultGHApiUrl = "https://api.github.com" +func (g *githubAPI) MergedPullRequest(ctx context.Context, commitID string) (*PullRequest, error) { + owner, repo, err := g.ownerRepo() + if err != nil { + return nil, err + } + var resp struct { + Repository struct { + Object struct { + Commit struct { + AssociatedPullRequests struct { + Nodes []struct { + Number int + URL string + } + } `graphql:"associatedPullRequests(first: 1)"` + } `graphql:"... on Commit"` + } `graphql:"object(expression: $commit)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + err = g.gql.Query(ctx, &resp, map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + "commit": githubv4.String(commitID), + }) + if err != nil { + return nil, err + } + if prs := resp.Repository.Object.Commit.AssociatedPullRequests.Nodes; len(prs) > 0 { + return &PullRequest{ + Number: prs[0].Number, + URL: prs[0].URL, + }, nil + } + return nil, nil +} + func (g *githubAPI) UpsertComment(ctx context.Context, pr *PullRequest, id, comment string) error { comments, err := g.getIssueComments(ctx, pr) if err != nil { @@ -1353,6 +1432,21 @@ func (g *githubAPI) ownerRepo() (string, string, error) { return s[0], s[1], nil } +// AuthorHasAccess returns true if the author has write access to the repository. +func (c githubIssueComment) AuthorHasAccess() bool { + return slices.Contains([]string{"OWNER", "MEMBER", "COLLABORATOR"}, c.AuthorAssociation) +} + +// Author returns the author of the comment. +func (c githubIssueComment) Author() string { + return c.User.Login +} + +// HasCommand returns true if the comment has the given command. +func (c githubIssueComment) HasCommand(cmd string) bool { + return strings.Contains(c.Body, cmd) +} + // commentMarker creates a hidden marker to identify the comment as one created by this action. func commentMarker(id string) string { return fmt.Sprintf(``, id) diff --git a/go.mod b/go.mod index fdde845..d5f986b 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/mitchellh/mapstructure v1.1.2 github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 github.com/sethvargo/go-githubactions v1.3.0 + github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/stretchr/testify v1.8.4 golang.org/x/oauth2 v0.22.0 ) @@ -23,6 +24,7 @@ require ( github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/zclconf/go-cty v1.14.1 // indirect golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 5486c49..4225c76 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,10 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sethvargo/go-githubactions v1.3.0 h1:Kg633LIUV2IrJsqy2MfveiED/Ouo+H2P0itWS0eLh8A= github.com/sethvargo/go-githubactions v1.3.0/go.mod h1:7/4WeHgYfSz9U5vwuToCK9KPnELVHAhGtRwLREOQV80= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= +github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= +github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/zclconf/go-cty v1.14.1 h1:t9fyA35fwjjUMcmL5hLER+e/rEPqrbCK1/OSE4SI9KA= diff --git a/schema/plan/approve/action.yml b/schema/plan/approve/action.yml index 352efec..3046fc3 100644 --- a/schema/plan/approve/action.yml +++ b/schema/plan/approve/action.yml @@ -41,6 +41,15 @@ inputs: description: | URL(s) of the desired schema state. required: false + require-approval: + description: | + Whether to require approval before applying the plan. By default, Atlas will not require approval. + default: 'false' + required: false + approvers: + description: | + A list of approvers for the plan. By default, all users with write access to the repository are approvers. + required: false outputs: plan: description: The plan to be applied or generated. (ig. `atlas:///plans/`)