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

fix: GitHub mergeability bypassing apply #4193

Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4512ff4
fix: GitHub checks minus apply
henriklundstrom Jan 25, 2024
9f6d529
Do review descision together with status checks
henriklundstrom Jan 26, 2024
f2662fc
Make the current tests pass
henriklundstrom Jan 26, 2024
2ad0148
Move GraphQL test response to separate file
henriklundstrom Jan 26, 2024
64f9560
Remove obsolete things
henriklundstrom Jan 26, 2024
80b3934
Use a switch statement
henriklundstrom Jan 26, 2024
e87dd73
no need for status when we have conclusion
henriklundstrom Jan 30, 2024
670b40c
support multiple mergability responses in test
henriklundstrom Jan 30, 2024
7dd25dd
add test cases for different rollups
henriklundstrom Jan 30, 2024
0b6005f
set review decision to null by default in tests
henriklundstrom Jan 31, 2024
ff24808
use quoted string
henriklundstrom Jan 31, 2024
080b3f3
Merge branch 'main' into f/henriklundstrom/fix-required-checks-minus-…
henriklundstrom Feb 13, 2024
d288d6c
Disregard skipped checks
henriklundstrom Feb 16, 2024
b6c93fa
Handle expected checks (but not from workflows)
henriklundstrom Feb 23, 2024
58042ef
Handle expected required workflows
henriklundstrom Feb 26, 2024
1558d54
Cache repo ids
henriklundstrom Feb 26, 2024
c44e848
add logging
henriklundstrom Feb 28, 2024
5c0b48e
better error messages
henriklundstrom Feb 29, 2024
a22828d
fix: GitHub mergeability for expected checks from rulesets & branch p…
henriklundstrom Mar 1, 2024
fd3a7ea
Merge branch 'main' into f/henriklundstrom/fix-required-checks-minus-…
henriklundstrom Mar 1, 2024
ccf970a
only consider branch protection and active rulesets (#3)
henriklundstrom Mar 27, 2024
1d85f60
consider neutral as passing
henriklundstrom Mar 28, 2024
d5398eb
Merge branch 'main' into f/henriklundstrom/fix-required-checks-minus-…
henriklundstrom Mar 28, 2024
bc44e6f
Merge branch 'main' into f/henriklundstrom/fix-required-checks-minus-…
henriklundstrom May 15, 2024
231e9a0
Merge branch 'main' into f/henriklundstrom/fix-required-checks-minus-…
henriklundstrom Jul 8, 2024
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
194 changes: 79 additions & 115 deletions server/events/vcs/github_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,116 +399,92 @@ func (g *GithubClient) DiscardReviews(repo models.Repo, pull models.PullRequest)
return nil
}

// isRequiredCheck is a helper function to determine if a check is required or not
func isRequiredCheck(check string, required []string) bool {
//in go1.18 can prob replace this with slices.Contains
for _, r := range required {
if r == check {
return true
}
// IsMergeableMinusApply checks review decision (which takes into account CODEOWNERS) and required checks for PR (excluding the atlantis apply check).
func (g *GithubClient) IsMergeableMinusApply(repo models.Repo, pull *github.PullRequest, vcsstatusname string) (bool, error) {
var query struct {
Repository struct {
PullRequest struct {
ReviewDecision githubv4.String
Commits struct {
Nodes []struct {
Commit struct {
StatusCheckRollup struct {
Contexts struct {
PageInfo struct {
EndCursor githubv4.String
HasNextPage githubv4.Boolean
}
Nodes []struct {
Typename githubv4.String `graphql:"__typename"`
CheckRun struct {
Name githubv4.String
Conclusion githubv4.String
IsRequired githubv4.Boolean `graphql:"isRequired(pullRequestNumber: $number)"`
} `graphql:"... on CheckRun"`
StatusContext struct {
Context githubv4.String
State githubv4.String
IsRequired githubv4.Boolean `graphql:"isRequired(pullRequestNumber: $number)"`
} `graphql:"... on StatusContext"`
}
} `graphql:"contexts(first: 100, after: $cursor)"`
}
}
}
} `graphql:"commits(last: 1)"`
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}

return false
}

// GetCombinedStatusMinusApply checks Statuses for PR, excluding atlantis apply. Returns true if all other statuses are not in failure.
func (g *GithubClient) GetCombinedStatusMinusApply(repo models.Repo, pull *github.PullRequest, vcstatusname string) (bool, error) {
//check combined status api
status, resp, err := g.client.Repositories.GetCombinedStatus(g.ctx, *pull.Head.Repo.Owner.Login, repo.Name, *pull.Head.Ref, nil)
if resp != nil {
g.logger.Debug("GET /repos/%v/%v/commits/%s/status returned: %v", *pull.Head.Repo.Owner.Login, repo.Name, *pull.Head.Ref, resp.StatusCode)
}
if err != nil {
return false, errors.Wrap(err, "getting combined status")
variables := map[string]interface{}{
"owner": githubv4.String(repo.Owner),
"name": githubv4.String(repo.Name),
"number": githubv4.Int(*pull.Number),
"cursor": (*githubv4.String)(nil),
}

//iterate over statuses - return false if we find one that isn't "apply" and doesn't have state = "success"
for _, r := range status.Statuses {
if strings.HasPrefix(*r.Context, fmt.Sprintf("%s/%s", vcstatusname, command.Apply.String())) {
continue
for {
err := g.v4Client.Query(g.ctx, &query, variables)

if err != nil {
return false, err
}
if *r.State != "success" {

if query.Repository.PullRequest.ReviewDecision != "APPROVED" && len(query.Repository.PullRequest.ReviewDecision) != 0 {
return false, nil
}
}

//get required status checks
required, resp, err := g.client.Repositories.GetBranchProtection(context.Background(), repo.Owner, repo.Name, *pull.Base.Ref)
if resp != nil {
g.logger.Debug("GET /repos/%v/%v/branches/%s/protection returned: %v", repo.Owner, repo.Name, *pull.Base.Ref, resp.StatusCode)
}
if err != nil {
return false, errors.Wrap(err, "getting required status checks")
}

if required.RequiredStatusChecks == nil {
return true, nil
}

//check check suite/check run api
checksuites, resp, err := g.client.Checks.ListCheckSuitesForRef(context.Background(), *pull.Head.Repo.Owner.Login, repo.Name, *pull.Head.Ref, nil)
if resp != nil {
g.logger.Debug("GET /repos/%v/%v/commits/%s/check-suites returned: %v", *pull.Head.Repo.Owner.Login, repo.Name, *pull.Head.Ref, resp.StatusCode)
}
if err != nil {
return false, errors.Wrap(err, "getting check suites for ref")
}

//iterate over check completed check suites - return false if we find one that doesnt have conclusion = "success"
for _, c := range checksuites.CheckSuites {
if *c.Status == "completed" {
//iterate over the runs inside the suite
suite, resp, err := g.client.Checks.ListCheckRunsCheckSuite(context.Background(), *pull.Head.Repo.Owner.Login, repo.Name, *c.ID, nil)
if resp != nil {
g.logger.Debug("GET /repos/%v/%v/check-suites/%d/check-runs returned: %v", *pull.Head.Repo.Owner.Login, repo.Name, *c.ID, resp.StatusCode)
}
if err != nil {
return false, errors.Wrap(err, "getting check runs for check suite")
}
if len(query.Repository.PullRequest.Commits.Nodes) == 0 {
return false, errors.New("no commits found on PR")
}

for _, r := range suite.CheckRuns {
//check to see if the check is required
if isRequiredCheck(*r.Name, required.RequiredStatusChecks.Contexts) {
if *c.Conclusion == "success" {
continue
}
for _, context := range query.Repository.PullRequest.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes {
switch context.Typename {
case "CheckRun":
if bool(context.CheckRun.IsRequired) &&
context.CheckRun.Conclusion != "SUCCESS" &&
henriklundstrom marked this conversation as resolved.
Show resolved Hide resolved
!strings.HasPrefix(string(context.CheckRun.Name), fmt.Sprintf("%s/%s", vcsstatusname, command.Apply.String())) {
return false, nil
}
case "StatusContext":
if bool(context.StatusContext.IsRequired) &&
context.StatusContext.State != "SUCCESS" &&
!strings.HasPrefix(string(context.StatusContext.Context), fmt.Sprintf("%s/%s", vcsstatusname, command.Apply.String())) {
return false, nil
}
//ignore checks that arent required
continue
default:
return false, fmt.Errorf("unknown type of status check, %q", context.Typename)
}
}
}

return true, nil
}

// GetPullReviewDecision gets the pull review decision, which takes into account CODEOWNERS
func (g *GithubClient) GetPullReviewDecision(repo models.Repo, pull models.PullRequest) (approvalStatus bool, err error) {
var query struct {
Repository struct {
PullRequest struct {
ReviewDecision string
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}

variables := map[string]interface{}{
"owner": githubv4.String(repo.Owner),
"name": githubv4.String(repo.Name),
"number": githubv4.Int(pull.Num),
}

err = g.v4Client.Query(g.ctx, &query, variables)
if err != nil {
return approvalStatus, errors.Wrap(err, "getting reviewDecision")
}
if !query.Repository.PullRequest.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.PageInfo.HasNextPage {
break
}

if query.Repository.PullRequest.ReviewDecision == "APPROVED" || len(query.Repository.PullRequest.ReviewDecision) == 0 {
return true, nil
variables["cursor"] = githubv4.NewString(query.Repository.PullRequest.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.PageInfo.EndCursor)
}

return false, nil
return true, nil
}

// PullIsMergeable returns true if the pull request is mergeable.
Expand All @@ -518,7 +494,6 @@ func (g *GithubClient) PullIsMergeable(repo models.Repo, pull models.PullRequest
return false, errors.Wrap(err, "getting pull request")
}

state := githubPR.GetMergeableState()
// We map our mergeable check to when the GitHub merge button is clickable.
// This corresponds to the following states:
// clean: No conflicts, all requirements satisfied.
Expand All @@ -528,33 +503,22 @@ func (g *GithubClient) PullIsMergeable(repo models.Repo, pull models.PullRequest
// has_hooks: GitHub Enterprise only, if a repo has custom pre-receive
// hooks. Merging is allowed (green box).
// See: https://github.com/octokit/octokit.net/issues/1763
if state != "clean" && state != "unstable" && state != "has_hooks" {
//mergeable bypass apply code hidden by feature flag
switch githubPR.GetMergeableState() {
case "clean", "unstable", "has_hooks":
return true, nil
case "blocked":
if g.config.AllowMergeableBypassApply {
g.logger.Debug("AllowMergeableBypassApply feature flag is enabled - attempting to bypass apply from mergeable requirements")
if state == "blocked" {
//check status excluding atlantis apply
status, err := g.GetCombinedStatusMinusApply(repo, githubPR, vcsstatusname)
if err != nil {
return false, errors.Wrap(err, "getting pull request status")
}

//check to see if pr is approved using reviewDecision
approved, err := g.GetPullReviewDecision(repo, pull)
if err != nil {
return false, errors.Wrap(err, "getting pull request reviewDecision")
}

//if all other status checks EXCEPT atlantis/apply are successful, and the PR is approved based on reviewDecision, let it proceed
if status && approved {
return true, nil
}
isMergeableMinusApply, err := g.IsMergeableMinusApply(repo, githubPR, vcsstatusname)
if err != nil {
return false, errors.Wrap(err, "getting pull request status")
}
return isMergeableMinusApply, nil
}

return false, nil
default:
return false, nil
}
return true, nil
}

// GetPullRequest returns the pull request.
Expand Down
Loading