Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

atlasaction: allow config approver users on the action #216

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 97 additions & 3 deletions atlasaction/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"time"

"ariga.io/atlas-go-sdk/atlasexec"
"github.com/shurcooL/githubv4"
"golang.org/x/oauth2"
)

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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")
}
Comment on lines +715 to +728
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a polling loop here to fetch comments, so it will hold the action until the user comments on the issue to approve this action,

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")
Expand Down Expand Up @@ -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"`
Expand All @@ -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 {
Expand Down Expand Up @@ -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(`<!-- generated by ariga/atlas-action for %v -->`, id)
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
9 changes: 9 additions & 0 deletions schema/plan/approve/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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://<schema>/plans/<id>`)
Expand Down
Loading