diff --git a/cmd/server.go b/cmd/server.go index 2f7c680fd3..c425982687 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -105,6 +105,7 @@ const ( GHOrganizationFlag = "gh-org" GHWebhookSecretFlag = "gh-webhook-secret" // nolint: gosec GHAllowMergeableBypassApply = "gh-allow-mergeable-bypass-apply" // nolint: gosec + GHMergeQueueEnabledFlag = "gh-merge-queue-enabled" GiteaBaseURLFlag = "gitea-base-url" GiteaTokenFlag = "gitea-token" GiteaUserFlag = "gitea-user" @@ -560,6 +561,10 @@ var boolFlags = map[string]boolFlag{ description: "Feature flag to enable functionality to allow mergeable check to ignore apply required check", defaultValue: false, }, + GHMergeQueueEnabledFlag: { + description: "Enable handling of GitHub merge queue (merge_group) events. When enabled, Atlantis posts success for plan/apply/policy_check on merge group commits so the merge queue can proceed.", + defaultValue: false, + }, GitlabStatusRetryEnabledFlag: { description: "Enable enhanced retry logic for GitLab pipeline status updates with exponential backoff.", defaultValue: false, diff --git a/runatlantis.io/docs/automerging.md b/runatlantis.io/docs/automerging.md index 5c2f96d34e..7ef6711108 100644 --- a/runatlantis.io/docs/automerging.md +++ b/runatlantis.io/docs/automerging.md @@ -79,3 +79,15 @@ then they should all be applied before Atlantis automatically merges the PR. ## Permissions The Atlantis VCS user must have the ability to merge pull requests. + +## GitHub Merge Queue + +If the PR's base branch requires a [merge queue](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue), Atlantis cannot merge the PR directly via the REST API (GitHub returns `405 Method Not Allowed`). Instead, when automerge fires Atlantis enables auto-merge on the PR via GraphQL, which adds the PR to the merge queue. GitHub then runs the queue's required checks against the merge candidate commit and merges the PR once they pass. + +For this to work end-to-end: + +- Set [`--gh-merge-queue-enabled`](server-configuration.md#--gh-merge-queue-enabled) so Atlantis posts `success` for `plan`/`apply`/`policy_check` on `merge_group` events; otherwise the queue stalls waiting for those checks. +- Subscribe the GitHub webhook to the `merge_group` event (see [Configuring Webhooks](configuring-webhooks.md#github)). +- The Atlantis VCS user / GitHub App must have permission to enable auto-merge on the repo. + +The `--auto-merge-method` setting is honored for non-queue branches; for branches enforcing a merge queue GitHub uses the queue's configured method and ignores any per-PR override. diff --git a/runatlantis.io/docs/configuring-webhooks.md b/runatlantis.io/docs/configuring-webhooks.md index f81934e373..bc77be69c2 100644 --- a/runatlantis.io/docs/configuring-webhooks.md +++ b/runatlantis.io/docs/configuring-webhooks.md @@ -38,6 +38,7 @@ If installing on a single repository, navigate to the repository home page and c * **Pushes** * **Issue comments** * **Pull requests** + * **Merge groups** (only required if you use [GitHub merge queue](server-configuration.md#--gh-merge-queue-enabled) and have set `--gh-merge-queue-enabled`) * leave **Active** checked * click **Add webhook** * See [Next Steps](#next-steps) diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 825a194d9c..d49a33145a 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -739,6 +739,22 @@ ATLANTIS_GH_HOSTNAME="my.github.enterprise.com" Hostname of your GitHub Enterprise installation. If using [GitHub.com](https://github.com), don't set. Defaults to `github.com`. +### `--gh-merge-queue-enabled` + +```bash +atlantis server --gh-merge-queue-enabled +# or +ATLANTIS_GH_MERGE_QUEUE_ENABLED=true +``` + +Enable handling of [GitHub merge queue](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue) (`merge_group`) webhook events. When enabled, on a `merge_group` `checks_requested` event Atlantis posts `success` for the `/plan`, `/apply`, and `/policy_check` commit statuses on the merge group's head SHA so the merge queue can proceed. + +Atlantis does not re-run `terraform plan`/`apply` against the merge ref — the PR was already validated before joining the queue, so posting `success` is sufficient to unblock the queue. `destroyed` actions are ignored. + +To use this, ensure the `merge_group` event is enabled on the GitHub webhook (or for the GitHub App). + +Defaults to `false`. + ### `--gh-org` ```bash diff --git a/server/controllers/events/events_controller.go b/server/controllers/events/events_controller.go index 0faabb6a38..6c91393ca3 100644 --- a/server/controllers/events/events_controller.go +++ b/server/controllers/events/events_controller.go @@ -28,6 +28,7 @@ import ( "github.com/google/go-github/v83/github" "github.com/microcosm-cc/bluemonday" "github.com/runatlantis/atlantis/server/events" + "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "github.com/runatlantis/atlantis/server/events/vcs" "github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud" @@ -87,7 +88,12 @@ type VCSEventsController struct { // startup to support. SupportedVCSHosts []models.VCSHostType `validate:"required"` VCSClient vcs.Client `validate:"required"` - TestingMode bool + // CommitStatusUpdater is used to set commit statuses outside of the regular + // command runner flow, e.g. when handling GitHub merge queue events. + CommitStatusUpdater events.CommitStatusUpdater `validate:"required"` + // GithubMergeQueueEnabled toggles handling of GitHub merge_group events. + GithubMergeQueueEnabled bool + TestingMode bool // BitbucketWebhookSecret is the secret added to this webhook via the Bitbucket // UI that identifies this call as coming from Bitbucket. If empty, no // request validation is done. @@ -201,6 +207,10 @@ func (e *VCSEventsController) handleGithubPost(w http.ResponseWriter, r *http.Re resp = e.HandleGithubPullRequestEvent(logger, event, githubReqID) scope = scope.SubScope(fmt.Sprintf("pr_%s", *event.Action)) scope = common.SetGitScopeTags(scope, event.GetRepo().GetFullName(), event.GetNumber()) + case *github.MergeGroupEvent: + resp = e.HandleGithubMergeGroupEvent(logger, event, githubReqID) + scope = scope.SubScope(fmt.Sprintf("merge_group_%s", event.GetAction())) + scope = common.SetGitScopeTags(scope, event.GetRepo().GetFullName(), 0) default: resp = HTTPResponse{ body: fmt.Sprintf("Ignoring unsupported event %s", githubReqID), @@ -562,6 +572,96 @@ func (e *VCSEventsController) HandleGithubPullRequestEvent(logger logging.Simple return e.handlePullRequestEvent(logger, baseRepo, headRepo, pull, user, pullEventType) } +// HandleGithubMergeGroupEvent handles GitHub merge_group webhook events. When +// a merge group's checks are requested, Atlantis posts success commit statuses +// for plan, apply, and policy_check on the merge group's head SHA so that the +// merge queue can proceed. The PR was already validated by Atlantis before it +// joined the queue, so re-running plans here is unnecessary. +func (e *VCSEventsController) HandleGithubMergeGroupEvent(logger logging.SimpleLogging, event *github.MergeGroupEvent, githubReqID string) HTTPResponse { + if !e.GithubMergeQueueEnabled { + return HTTPResponse{ + body: fmt.Sprintf("Ignoring merge_group event since gh-merge-queue-enabled is false %s", githubReqID), + } + } + + action := event.GetAction() + if action != "checks_requested" { + return HTTPResponse{ + body: fmt.Sprintf("Ignoring merge_group event with action %q %s", action, githubReqID), + } + } + + baseRepo, err := e.Parser.ParseGithubRepo(event.GetRepo()) + if err != nil { + wrapped := fmt.Errorf("parsing merge_group event: %s: %w", githubReqID, err) + return HTTPResponse{ + body: wrapped.Error(), + err: HTTPError{ + code: http.StatusBadRequest, + err: wrapped, + isSilenced: false, + }, + } + } + + logger = logger.With("repo", baseRepo.FullName) + + if !e.RepoAllowlistChecker.IsAllowlisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) { + err := fmt.Errorf("merge_group event from non-allowlisted repo '%s/%s'", baseRepo.VCSHost.Hostname, baseRepo.FullName) + return HTTPResponse{ + body: err.Error(), + err: HTTPError{ + code: http.StatusForbidden, + err: err, + isSilenced: e.SilenceAllowlistErrors, + }, + } + } + + headSHA := event.GetMergeGroup().GetHeadSHA() + if headSHA == "" { + err := fmt.Errorf("merge_group event missing head SHA %s", githubReqID) + return HTTPResponse{ + body: err.Error(), + err: HTTPError{ + code: http.StatusBadRequest, + err: err, + isSilenced: false, + }, + } + } + + logger.Info("Handling GitHub Merge Group 'checks_requested' event for %s", headSHA) + + pull := models.PullRequest{ + HeadCommit: headSHA, + BaseRepo: baseRepo, + } + + cmds := []command.Name{command.Plan, command.Apply, command.PolicyCheck} + var errs []error + for _, cmd := range cmds { + if err := e.CommitStatusUpdater.UpdateCombined(logger, baseRepo, pull, models.SuccessCommitStatus, cmd); err != nil { + errs = append(errs, fmt.Errorf("updating %s status: %w", cmd.String(), err)) + } + } + if len(errs) > 0 { + joined := errors.Join(errs...) + return HTTPResponse{ + body: joined.Error(), + err: HTTPError{ + code: http.StatusInternalServerError, + err: joined, + isSilenced: false, + }, + } + } + + return HTTPResponse{ + body: "Merge group checks marked as successful", + } +} + func (e *VCSEventsController) handlePullRequestEvent(logger logging.SimpleLogging, baseRepo models.Repo, headRepo models.Repo, pull models.PullRequest, user models.User, eventType models.PullRequestEventType) HTTPResponse { if !e.RepoAllowlistChecker.IsAllowlisted(baseRepo.FullName, baseRepo.VCSHost.Hostname) { // If the repo isn't allowlisted and we receive an opened pull request diff --git a/server/controllers/events/events_controller_test.go b/server/controllers/events/events_controller_test.go index 0abc37477f..271f837bfa 100644 --- a/server/controllers/events/events_controller_test.go +++ b/server/controllers/events/events_controller_test.go @@ -51,7 +51,7 @@ var secret = []byte("secret") func TestPost_NotGithubOrGitlab(t *testing.T) { t.Log("when the request is not for gitlab or github a 400 is returned") - e, _, _, _, _, _, _, _, _ := setup(t) + e, _, _, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) e.Post(w, req) @@ -60,7 +60,7 @@ func TestPost_NotGithubOrGitlab(t *testing.T) { func TestPost_UnsupportedVCSGithub(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") - e, _, _, _, _, _, _, _, _ := setup(t) + e, _, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") @@ -71,7 +71,7 @@ func TestPost_UnsupportedVCSGithub(t *testing.T) { func TestPost_UnsupportedVCSGitea(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") - e, _, _, _, _, _, _, _, _ := setup(t) + e, _, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(giteaHeader, "value") @@ -82,7 +82,7 @@ func TestPost_UnsupportedVCSGitea(t *testing.T) { func TestPost_UnsupportedVCSGitlab(t *testing.T) { t.Log("when the request is for an unsupported vcs a 400 is returned") - e, _, _, _, _, _, _, _, _ := setup(t) + e, _, _, _, _, _, _, _, _, _ := setup(t) e.SupportedVCSHosts = nil req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -93,7 +93,7 @@ func TestPost_UnsupportedVCSGitlab(t *testing.T) { func TestPost_InvalidGithubSecret(t *testing.T) { t.Log("when the github payload can't be validated a 400 is returned") - e, v, _, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") @@ -104,7 +104,7 @@ func TestPost_InvalidGithubSecret(t *testing.T) { func TestPost_InvalidGiteaSecret(t *testing.T) { t.Log("when the gitea payload can't be validated a 400 is returned") - e, v, _, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(giteaHeader, "value") @@ -115,7 +115,7 @@ func TestPost_InvalidGiteaSecret(t *testing.T) { func TestPost_InvalidGitlabSecret(t *testing.T) { t.Log("when the gitlab payload can't be validated a 400 is returned") - e, _, gl, _, _, _, _, _, _ := setup(t) + e, _, gl, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -126,7 +126,7 @@ func TestPost_InvalidGitlabSecret(t *testing.T) { func TestPost_UnsupportedGithubEvent(t *testing.T) { t.Log("when the event type is an unsupported github event we ignore it") - e, v, _, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "value") @@ -137,7 +137,7 @@ func TestPost_UnsupportedGithubEvent(t *testing.T) { func TestPost_UnsupportedGiteaEvent(t *testing.T) { t.Log("when the event type is an unsupported gitea event we ignore it") - e, v, _, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(giteaHeader, "value") @@ -149,7 +149,7 @@ func TestPost_UnsupportedGiteaEvent(t *testing.T) { func TestPost_UnsupportedGitlabEvent(t *testing.T) { t.Log("when the event type is an unsupported gitlab event we ignore it") - e, _, gl, _, _, _, _, _, _ := setup(t) + e, _, gl, _, _, _, _, _, _, _ := setup(t) w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -161,7 +161,7 @@ func TestPost_UnsupportedGitlabEvent(t *testing.T) { // Test that if the comment comes from a commit rather than a merge request, // we give an error and ignore it. func TestPost_GitlabCommentOnCommit(t *testing.T) { - e, _, gl, _, _, _, _, _, _ := setup(t) + e, _, gl, _, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) w := httptest.NewRecorder() req.Header.Set(gitlabHeader, "value") @@ -172,7 +172,7 @@ func TestPost_GitlabCommentOnCommit(t *testing.T) { func TestPost_GithubCommentNotCreated(t *testing.T) { t.Log("when the event is a github comment but it's not a created event we ignore it") - e, v, _, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") // comment action is deleted, not created @@ -185,7 +185,7 @@ func TestPost_GithubCommentNotCreated(t *testing.T) { func TestPost_GithubInvalidComment(t *testing.T) { t.Log("when the event is a github comment without all expected data we return a 400") - e, v, _, _, p, _, _, _, _ := setup(t) + e, v, _, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -198,7 +198,7 @@ func TestPost_GithubInvalidComment(t *testing.T) { func TestPost_GitlabCommentInvalidCommand(t *testing.T) { t.Log("when the event is a gitlab comment with an invalid command we ignore it") - e, _, gl, _, _, _, _, _, cp := setup(t) + e, _, gl, _, _, _, _, _, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) @@ -210,7 +210,7 @@ func TestPost_GitlabCommentInvalidCommand(t *testing.T) { func TestPost_GithubCommentInvalidCommand(t *testing.T) { t.Log("when the event is a github comment with an invalid command we ignore it") - e, v, _, _, p, _, _, vcsClient, cp := setup(t) + e, v, _, _, p, _, _, vcsClient, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -362,7 +362,7 @@ func TestPost_GithubCommentNotAllowlistedWithSilenceErrors(t *testing.T) { func TestPost_GitlabCommentResponse(t *testing.T) { // When the event is a gitlab comment that warrants a comment response we comment back. - e, _, gl, _, _, _, _, vcsClient, cp := setup(t) + e, _, gl, _, _, _, _, vcsClient, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeCommentEvent{}, nil) @@ -375,7 +375,7 @@ func TestPost_GitlabCommentResponse(t *testing.T) { func TestPost_GithubCommentResponse(t *testing.T) { t.Log("when the event is a github comment that warrants a comment response we comment back") - e, v, _, _, p, _, _, vcsClient, cp := setup(t) + e, v, _, _, p, _, _, vcsClient, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -393,7 +393,7 @@ func TestPost_GithubCommentResponse(t *testing.T) { func TestPost_GitlabCommentSuccess(t *testing.T) { t.Log("when the event is a gitlab comment with a valid command we call the command handler") - e, _, gl, _, _, cr, _, _, cp := setup(t) + e, _, gl, _, _, cr, _, _, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") cmd := events.CommentCommand{} @@ -408,7 +408,7 @@ func TestPost_GitlabCommentSuccess(t *testing.T) { func TestPost_GithubCommentSuccess(t *testing.T) { t.Log("when the event is a github comment with a valid command we call the command handler") - e, v, _, _, p, cr, _, _, cp := setup(t) + e, v, _, _, p, cr, _, _, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") event := `{"action": "created"}` @@ -427,7 +427,7 @@ func TestPost_GithubCommentSuccess(t *testing.T) { func TestPost_GithubCommentReaction(t *testing.T) { t.Log("when the event is a github comment with a valid command we call the ReactToComment handler") - e, v, _, _, p, _, _, vcsClient, cp := setup(t) + e, v, _, _, p, _, _, vcsClient, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "issue_comment") testComment := "atlantis plan" @@ -447,7 +447,7 @@ func TestPost_GithubCommentReaction(t *testing.T) { func TestPost_GilabCommentReaction(t *testing.T) { t.Log("when the event is a gitlab comment with a valid command we call the ReactToComment handler") - e, _, gl, _, _, _, _, vcsClient, cp := setup(t) + e, _, gl, _, _, _, _, vcsClient, cp, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") cmd := events.CommentCommand{} @@ -461,7 +461,7 @@ func TestPost_GilabCommentReaction(t *testing.T) { func TestPost_GithubPullRequestInvalid(t *testing.T) { t.Log("when the event is a github pull request with invalid data we return a 400") - e, v, _, _, p, _, _, _, _ := setup(t) + e, v, _, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -475,7 +475,7 @@ func TestPost_GithubPullRequestInvalid(t *testing.T) { func TestPost_GitlabMergeRequestInvalid(t *testing.T) { t.Log("when the event is a gitlab merge request with invalid data we return a 400") - e, _, gl, _, p, _, _, _, _ := setup(t) + e, _, gl, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeEvent{}, nil) @@ -489,7 +489,7 @@ func TestPost_GitlabMergeRequestInvalid(t *testing.T) { func TestPost_GithubPullRequestNotAllowlisted(t *testing.T) { t.Log("when the event is a github pull request to a non-allowlisted repo we return a 400") - e, v, _, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _, _ := setup(t) var err error e.RepoAllowlistChecker, err = events.NewRepoAllowlistChecker("github.com/nevermatch") Ok(t, err) @@ -503,9 +503,83 @@ func TestPost_GithubPullRequestNotAllowlisted(t *testing.T) { ResponseContains(t, w, http.StatusForbidden, "pull request event from non-allowlisted repo") } +func TestPost_GithubMergeGroupChecksRequested(t *testing.T) { + t.Log("when a github merge_group checks_requested event arrives we set plan/apply/policy_check to success") + e, v, _, _, p, _, _, _, _, csu := setup(t) + e.GithubMergeQueueEnabled = true + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + req.Header.Set(githubHeader, "merge_group") + + event := `{"action": "checks_requested", "merge_group": {"head_sha": "abc123", "head_ref": "refs/heads/gh-readonly-queue/main/pr-1-abc123", "base_ref": "refs/heads/main", "base_sha": "def456"}, "repository": {"full_name": "owner/repo"}}` + When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) + baseRepo := models.Repo{FullName: "owner/repo", VCSHost: models.VCSHost{Hostname: "github.com", Type: models.Github}} + When(p.ParseGithubRepo(Any[*github.Repository]())).ThenReturn(baseRepo, nil) + + w := httptest.NewRecorder() + e.Post(w, req) + ResponseContains(t, w, http.StatusOK, "Merge group checks marked as successful") + + expectedPull := models.PullRequest{HeadCommit: "abc123", BaseRepo: baseRepo} + csu.VerifyWasCalledOnce().UpdateCombined(Any[logging.SimpleLogging](), Eq(baseRepo), Eq(expectedPull), Eq(models.SuccessCommitStatus), Eq(command.Plan)) + csu.VerifyWasCalledOnce().UpdateCombined(Any[logging.SimpleLogging](), Eq(baseRepo), Eq(expectedPull), Eq(models.SuccessCommitStatus), Eq(command.Apply)) + csu.VerifyWasCalledOnce().UpdateCombined(Any[logging.SimpleLogging](), Eq(baseRepo), Eq(expectedPull), Eq(models.SuccessCommitStatus), Eq(command.PolicyCheck)) +} + +func TestPost_GithubMergeGroupDestroyed(t *testing.T) { + t.Log("when a github merge_group destroyed event arrives we ignore it without updating statuses") + e, v, _, _, _, _, _, _, _, csu := setup(t) + e.GithubMergeQueueEnabled = true + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + req.Header.Set(githubHeader, "merge_group") + + event := `{"action": "destroyed", "reason": "merged", "merge_group": {"head_sha": "abc123"}, "repository": {"full_name": "owner/repo"}}` + When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) + + w := httptest.NewRecorder() + e.Post(w, req) + ResponseContains(t, w, http.StatusOK, `Ignoring merge_group event with action "destroyed"`) + csu.VerifyWasCalled(Never()).UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) +} + +func TestPost_GithubMergeGroupNotAllowlisted(t *testing.T) { + t.Log("when a github merge_group event is from a non-allowlisted repo we return 403") + e, v, _, _, p, _, _, _, _, csu := setup(t) + e.GithubMergeQueueEnabled = true + var err error + e.RepoAllowlistChecker, err = events.NewRepoAllowlistChecker("github.com/nevermatch") + Ok(t, err) + + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + req.Header.Set(githubHeader, "merge_group") + event := `{"action": "checks_requested", "merge_group": {"head_sha": "abc123"}, "repository": {"full_name": "owner/repo"}}` + When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) + When(p.ParseGithubRepo(Any[*github.Repository]())).ThenReturn(models.Repo{FullName: "owner/repo", VCSHost: models.VCSHost{Hostname: "github.com", Type: models.Github}}, nil) + + w := httptest.NewRecorder() + e.Post(w, req) + ResponseContains(t, w, http.StatusForbidden, "merge_group event from non-allowlisted repo") + csu.VerifyWasCalled(Never()).UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) +} + +func TestPost_GithubMergeGroupDisabled(t *testing.T) { + t.Log("when GithubMergeQueueEnabled is false we ignore merge_group events") + e, v, _, _, _, _, _, _, _, csu := setup(t) + e.GithubMergeQueueEnabled = false + req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) + req.Header.Set(githubHeader, "merge_group") + + event := `{"action": "checks_requested", "merge_group": {"head_sha": "abc123"}, "repository": {"full_name": "owner/repo"}}` + When(v.Validate(req, secret)).ThenReturn([]byte(event), nil) + + w := httptest.NewRecorder() + e.Post(w, req) + ResponseContains(t, w, http.StatusOK, "Ignoring merge_group event since gh-merge-queue-enabled is false") + csu.VerifyWasCalled(Never()).UpdateCombined(Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]()) +} + func TestPost_GitlabMergeRequestNotAllowlisted(t *testing.T) { t.Log("when the event is a gitlab merge request to a non-allowlisted repo we return a 400") - e, _, gl, _, p, _, _, _, _ := setup(t) + e, _, gl, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") @@ -524,7 +598,7 @@ func TestPost_GitlabMergeRequestNotAllowlisted(t *testing.T) { func TestPost_GithubPullRequestUnsupportedAction(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") - e, v, _, _, _, _, _, _, _ := setup(t) + e, v, _, _, _, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -539,7 +613,7 @@ func TestPost_GithubPullRequestUnsupportedAction(t *testing.T) { func TestPost_GitlabMergeRequestUnsupportedAction(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a gitlab merge request to a non-allowlisted repo we return a 400") - e, _, gl, _, p, _, _, _, _ := setup(t) + e, _, gl, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent @@ -556,7 +630,7 @@ func TestPost_GitlabMergeRequestUnsupportedAction(t *testing.T) { func TestPost_AzureDevopsPullRequestIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops pull request update that should not trigger workflow we ignore it") - e, _, _, ado, _, _, _, _, _ := setup(t) + e, _, _, ado, _, _, _, _, _, _ := setup(t) event := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", @@ -608,7 +682,7 @@ func TestPost_AzureDevopsPullRequestIgnoreEvent(t *testing.T) { func TestPost_AzureDevopsPullRequestDeletedCommentIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops pull request deleted comment event we ignore it") - e, _, _, ado, _, _, _, _, _ := setup(t) + e, _, _, ado, _, _, _, _, _, _ := setup(t) payload := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", @@ -641,7 +715,7 @@ func TestPost_AzureDevopsPullRequestDeletedCommentIgnoreEvent(t *testing.T) { func TestPost_AzureDevopsPullRequestCommentWebhookTestIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops webhook test we ignore it") - e, _, _, ado, _, _, _, _, _ := setup(t) + e, _, _, ado, _, _, _, _, _, _ := setup(t) event := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", @@ -689,7 +763,7 @@ func TestPost_AzureDevopsPullRequestCommentWebhookTestIgnoreEvent(t *testing.T) func TestPost_AzureDevopsPullRequestWebhookTestIgnoreEvent(t *testing.T) { t.Log("when the event is an azure devops webhook tests we ignore it") - e, _, _, ado, _, _, _, _, _ := setup(t) + e, _, _, ado, _, _, _, _, _, _ := setup(t) event := `{ "subscriptionId": "11111111-1111-1111-1111-111111111111", @@ -736,7 +810,7 @@ func TestPost_AzureDevopsPullRequestWebhookTestIgnoreEvent(t *testing.T) { func TestPost_AzureDevopsPullRequestCommentPassingIgnores(t *testing.T) { t.Log("when the event should not be ignored it should pass through all ignore statements without error") - e, _, _, ado, _, _, _, _, cp := setup(t) + e, _, _, ado, _, _, _, _, cp, _ := setup(t) testComment := "atlantis plan" repo := models.Repo{} @@ -779,7 +853,7 @@ func TestPost_GithubPullRequestClosedErrCleaningPull(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a closed pull request and we have an error calling CleanUpPull we return a 503") RegisterMockTestingT(t) - e, v, _, _, p, _, c, _, _ := setup(t) + e, v, _, _, p, _, c, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -797,7 +871,7 @@ func TestPost_GithubPullRequestClosedErrCleaningPull(t *testing.T) { func TestPost_GitlabMergeRequestClosedErrCleaningPull(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a closed gitlab merge request and an error occurs calling CleanUpPull we return a 500") - e, _, gl, _, p, _, c, _, _ := setup(t) + e, _, gl, _, p, _, c, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") var event gitlab.MergeEvent @@ -815,7 +889,7 @@ func TestPost_GitlabMergeRequestClosedErrCleaningPull(t *testing.T) { func TestPost_GithubClosedPullRequestSuccess(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a pull request and everything works we return a 200") - e, v, _, _, p, _, c, _, _ := setup(t) + e, v, _, _, p, _, c, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(githubHeader, "pull_request") @@ -833,7 +907,7 @@ func TestPost_GithubClosedPullRequestSuccess(t *testing.T) { func TestPost_GitlabMergeRequestSuccess(t *testing.T) { t.Skip("relies too much on mocks, should use real event parser") t.Log("when the event is a gitlab merge request and the cleanup works we return a 200") - e, _, gl, _, p, _, _, _, _ := setup(t) + e, _, gl, _, p, _, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) req.Header.Set(gitlabHeader, "value") When(gl.ParseAndValidate(req, secret)).ThenReturn(gitlab.MergeEvent{}, nil) @@ -958,7 +1032,7 @@ func TestPost_PullOpenedOrUpdated(t *testing.T) { for _, c := range cases { t.Run(c.Description, func(t *testing.T) { - e, v, gl, _, p, cr, _, _, _ := setup(t) + e, v, gl, _, p, cr, _, _, _, _ := setup(t) req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil)) var pullRequest models.PullRequest var repo models.Repo @@ -989,7 +1063,7 @@ func TestPost_PullOpenedOrUpdated(t *testing.T) { } } -func setup(t *testing.T) (events_controllers.VCSEventsController, *mocks.MockGithubRequestValidator, *mocks.MockGitlabRequestParserValidator, *mocks.MockAzureDevopsRequestValidator, *emocks.MockEventParsing, *emocks.MockCommandRunner, *emocks.MockPullCleaner, *vcsmocks.MockClient, *emocks.MockCommentParsing) { +func setup(t *testing.T) (events_controllers.VCSEventsController, *mocks.MockGithubRequestValidator, *mocks.MockGitlabRequestParserValidator, *mocks.MockAzureDevopsRequestValidator, *emocks.MockEventParsing, *emocks.MockCommandRunner, *emocks.MockPullCleaner, *vcsmocks.MockClient, *emocks.MockCommentParsing, *emocks.MockCommitStatusUpdater) { RegisterMockTestingT(t) v := mocks.NewMockGithubRequestValidator() gl := mocks.NewMockGitlabRequestParserValidator() @@ -999,6 +1073,7 @@ func setup(t *testing.T) (events_controllers.VCSEventsController, *mocks.MockGit cr := emocks.NewMockCommandRunner() c := emocks.NewMockPullCleaner() vcsmock := vcsmocks.NewMockClient() + csu := emocks.NewMockCommitStatusUpdater() repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*") Ok(t, err) logger := logging.NewNoopLogger(t) @@ -1025,6 +1100,7 @@ func setup(t *testing.T) (events_controllers.VCSEventsController, *mocks.MockGit GitlabRequestParserValidator: gl, RepoAllowlistChecker: repoAllowlistChecker, VCSClient: vcsmock, + CommitStatusUpdater: csu, } - return e, v, gl, ado, p, cr, c, vcsmock, cp + return e, v, gl, ado, p, cr, c, vcsmock, cp, csu } diff --git a/server/events/vcs/github/client.go b/server/events/vcs/github/client.go index a75e0efd39..09b24ae4d5 100644 --- a/server/events/vcs/github/client.go +++ b/server/events/vcs/github/client.go @@ -1004,7 +1004,10 @@ func (g *Client) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pu return err } -// MergePull merges the pull request. +// MergePull merges the pull request. If the base branch enforces a merge +// queue, Atlantis cannot merge directly via the REST API (GitHub returns 405); +// in that case it enables auto-merge via GraphQL so GitHub will enqueue the +// PR and merge it once the queue's checks pass. func (g *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error { logger.Debug("Merging GitHub pull request %d", pull.Num) // Users can set their repo to disallow certain types of merging. @@ -1055,6 +1058,17 @@ func (g *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest } } + pullNodeID, requiresMergeQueue, err := g.getPullMergeQueueStatus(pull) + if err != nil { + // If we can't determine merge queue status (e.g. token lacks scope), + // fall through to the direct merge — GitHub will reject it with 405 + // if the queue is required, surfacing the failure to the user. + logger.Warn("Failed to determine merge queue status for PR %d: %s — attempting direct merge", pull.Num, err) + } else if requiresMergeQueue { + logger.Info("Base branch of PR %d requires merge queue; enabling auto-merge", pull.Num) + return g.enablePullAutoMerge(logger, pull, pullNodeID, method) + } + // Now we're ready to make our API call to merge the pull request. options := &github.PullRequestOptions{ MergeMethod: method, @@ -1081,6 +1095,69 @@ func (g *Client) MergePull(logger logging.SimpleLogging, pull models.PullRequest return nil } +// getPullMergeQueueStatus returns the PR's GraphQL node ID and whether its +// base branch requires a merge queue (via classic branch protection). +func (g *Client) getPullMergeQueueStatus(pull models.PullRequest) (githubv4.ID, bool, error) { + var query struct { + Repository struct { + PullRequest struct { + ID githubv4.ID + BaseRef struct { + BranchProtectionRule struct { + RequiresMergeQueue githubv4.Boolean + } + } + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + variables := map[string]any{ + "owner": githubv4.String(pull.BaseRepo.Owner), + "name": githubv4.String(pull.BaseRepo.Name), + "number": githubv4.Int(pull.Num), // #nosec G115: PR numbers fit in int32. + } + if err := g.v4Client.Query(g.ctx, &query, variables); err != nil { + return nil, false, fmt.Errorf("querying PR merge queue status: %w", err) + } + return query.Repository.PullRequest.ID, bool(query.Repository.PullRequest.BaseRef.BranchProtectionRule.RequiresMergeQueue), nil +} + +// enablePullAutoMerge enables auto-merge on the PR via GraphQL. For branches +// that require a merge queue, GitHub enqueues the PR and merges it once the +// queue's checks pass. The merge method is honored for non-queue branches; for +// merge-queue branches GitHub ignores it and uses the queue's configured method. +func (g *Client) enablePullAutoMerge(logger logging.SimpleLogging, pull models.PullRequest, pullNodeID githubv4.ID, method string) error { + var mutation struct { + EnablePullRequestAutoMerge struct { + PullRequest struct { + ID githubv4.ID + } + } `graphql:"enablePullRequestAutoMerge(input: $input)"` + } + + var ghMethod githubv4.PullRequestMergeMethod + switch method { + case "merge": + ghMethod = githubv4.PullRequestMergeMethodMerge + case "rebase": + ghMethod = githubv4.PullRequestMergeMethodRebase + case "squash": + ghMethod = githubv4.PullRequestMergeMethodSquash + default: + ghMethod = githubv4.PullRequestMergeMethodMerge + } + + input := githubv4.EnablePullRequestAutoMergeInput{ + PullRequestID: pullNodeID, + MergeMethod: &ghMethod, + ClientMutationID: clientMutationID, + } + logger.Debug("enablePullRequestAutoMerge for PR %d", pull.Num) + if err := g.v4Client.Mutate(g.ctx, &mutation, input, nil); err != nil { + return fmt.Errorf("enabling auto-merge for pull request: %w", err) + } + return nil +} + // MarkdownPullLink specifies the string used in a pull request comment to reference another pull request. func (g *Client) MarkdownPullLink(pull models.PullRequest) (string, error) { return fmt.Sprintf("#%d", pull.Num), nil diff --git a/server/events/vcs/github/client_test.go b/server/events/vcs/github/client_test.go index 132d4edcdb..32df09543b 100644 --- a/server/events/vcs/github/client_test.go +++ b/server/events/vcs/github/client_test.go @@ -1141,6 +1141,9 @@ func TestClient_MergePullHandlesError(t *testing.T) { case "/api/v3/repos/owner/repo": w.Write(jsBytes) // nolint: errcheck return + case "/api/graphql": + w.Write([]byte(`{"data":{"repository":{"pullRequest":{"id":"PR_test","baseRef":{"branchProtectionRule":{"requiresMergeQueue":false}}}}}}`)) // nolint: errcheck + return case "/api/v3/repos/owner/repo/pulls/1/merge": body, err := io.ReadAll(r.Body) Ok(t, err) @@ -1319,6 +1322,9 @@ func TestClient_MergePullCorrectMethod(t *testing.T) { case "/api/v3/repos/runatlantis/atlantis": w.Write([]byte(resp)) // nolint: errcheck return + case "/api/graphql": + w.Write([]byte(`{"data":{"repository":{"pullRequest":{"id":"PR_test","baseRef":{"branchProtectionRule":{"requiresMergeQueue":false}}}}}}`)) // nolint: errcheck + return case "/api/v3/repos/runatlantis/atlantis/pulls/1/merge": body, err := io.ReadAll(r.Body) Ok(t, err) @@ -1377,6 +1383,76 @@ func TestClient_MergePullCorrectMethod(t *testing.T) { } } +// Test that when the base branch requires a merge queue, MergePull enables +// auto-merge via GraphQL instead of issuing a direct REST merge. +func TestClient_MergePullMergeQueue(t *testing.T) { + logger := logging.NewNoopLogger(t) + jsBytes, err := os.ReadFile("testdata/repo.json") + Ok(t, err) + + var sawEnableAutoMerge bool + var directMergeAttempted bool + testServer := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.RequestURI { + case "/api/v3/repos/owner/repo": + w.Write(jsBytes) // nolint: errcheck + return + case "/api/graphql": + body, err := io.ReadAll(r.Body) + Ok(t, err) + defer r.Body.Close() // nolint: errcheck + bodyStr := string(body) + if strings.Contains(bodyStr, "enablePullRequestAutoMerge") { + sawEnableAutoMerge = true + // Verify it includes our PR node ID and a merge method. + Assert(t, strings.Contains(bodyStr, `"pullRequestId":"PR_mergequeue"`), "mutation should target the PR node ID, got: %s", bodyStr) + Assert(t, strings.Contains(bodyStr, `"mergeMethod"`), "mutation should include mergeMethod, got: %s", bodyStr) + w.Write([]byte(`{"data":{"enablePullRequestAutoMerge":{"pullRequest":{"id":"PR_mergequeue"}}}}`)) // nolint: errcheck + return + } + // Otherwise it's the merge-queue-status query. + w.Write([]byte(`{"data":{"repository":{"pullRequest":{"id":"PR_mergequeue","baseRef":{"branchProtectionRule":{"requiresMergeQueue":true}}}}}}`)) // nolint: errcheck + return + case "/api/v3/repos/owner/repo/pulls/1/merge": + directMergeAttempted = true + t.Errorf("direct REST merge should not be called for merge-queue branches") + http.Error(w, "should not be called", http.StatusInternalServerError) + return + default: + t.Errorf("got unexpected request at %q", r.RequestURI) + http.Error(w, "not found", http.StatusNotFound) + return + } + })) + + testServerURL, err := url.Parse(testServer.URL) + Ok(t, err) + client, err := github.New(testServerURL.Host, &github.UserCredentials{"user", "pass", ""}, github.Config{}, 0, logging.NewNoopLogger(t)) + Ok(t, err) + defer disableSSLVerification()() + + err = client.MergePull( + logger, + models.PullRequest{ + BaseRepo: models.Repo{ + FullName: "owner/repo", + Owner: "owner", + Name: "repo", + CloneURL: "", + SanitizedCloneURL: "", + VCSHost: models.VCSHost{ + Type: models.Github, + Hostname: "github.com", + }, + }, + Num: 1, + }, models.PullRequestOptions{}) + Ok(t, err) + Assert(t, sawEnableAutoMerge, "expected enablePullRequestAutoMerge mutation to be invoked") + Assert(t, !directMergeAttempted, "direct merge should not be attempted") +} + func TestClient_GetFileContent(t *testing.T) { logger := logging.NewNoopLogger(t) repo := models.Repo{ diff --git a/server/server.go b/server/server.go index 137332aea3..53f81ea832 100644 --- a/server/server.go +++ b/server/server.go @@ -1001,6 +1001,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { ExecutableName: userConfig.ExecutableName, SupportedVCSHosts: supportedVCSHosts, VCSClient: vcsClient, + CommitStatusUpdater: commitStatusUpdater, + GithubMergeQueueEnabled: userConfig.GithubMergeQueueEnabled, BitbucketWebhookSecret: []byte(userConfig.BitbucketWebhookSecret), AzureDevopsWebhookBasicUser: []byte(userConfig.AzureDevopsWebhookUser), AzureDevopsWebhookBasicPassword: []byte(userConfig.AzureDevopsWebhookPassword), diff --git a/server/user_config.go b/server/user_config.go index e0593b33fa..6befba1d76 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -57,6 +57,7 @@ type UserConfig struct { FailOnPreWorkflowHookError bool `mapstructure:"fail-on-pre-workflow-hook-error"` HideUnchangedPlanComments bool `mapstructure:"hide-unchanged-plan-comments"` GithubAllowMergeableBypassApply bool `mapstructure:"gh-allow-mergeable-bypass-apply"` + GithubMergeQueueEnabled bool `mapstructure:"gh-merge-queue-enabled"` GithubHostname string `mapstructure:"gh-hostname"` GithubToken string `mapstructure:"gh-token"` GithubTokenFile string `mapstructure:"gh-token-file"`