From c6aa4d1cc6e018f5231c812a25687fc92fdbfe0f Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Tue, 1 Sep 2026 22:20:52 +0500 Subject: [PATCH] feat(mutation): auto-merge is a setting, not a boundary Enabling GitHub auto-merge was refused in four independent places: the provider rejected the payload before the request, the rollout plan hardcoded `auto_merge: false`, and two schemas pinned the value as a `const`. Together they meant no managed repository could ever have it on. The refusal was grouped with force updates, visibility changes, permission changes and ruleset bypass. That grouping does not hold. Each of those four removes a check. Auto-merge removes only the wait between a required check passing and the merge that check already authorized -- it cannot lose data, replace a credential, or expose private content, so it is not one of the three boundaries this engine stops at. The wait was not free. Dependency pull requests sat 30.7 h and 25.4 h in the green after their checks passed, because landing them needed a human or an agent to look. Every other pull request paid a poll loop instead. Auto-merge is now an ordinary managed merge setting the provider may set in either direction, and a rollout carries its own `auto_merge` instead of a constant. The required checks are untouched: they remain the gate. --- core/providers/github/mutation_repository.go | 3 -- core/providers/github/mutation_test.go | 45 ++++++++++++++++--- core/rollout/plan.go | 3 +- core/rollout/request.go | 5 +++ ...023-separate-github-mutation-capability.md | 15 +++++-- docs/contracts/github-mutation-provider-v1.md | 9 ++-- schemas/v1/mutation-capability.schema.json | 2 +- schemas/v1/rollout-request.schema.json | 3 ++ schemas/v1/rollout.schema.json | 2 +- 9 files changed, 69 insertions(+), 18 deletions(-) diff --git a/core/providers/github/mutation_repository.go b/core/providers/github/mutation_repository.go index 26c8d13..e8d7920 100644 --- a/core/providers/github/mutation_repository.go +++ b/core/providers/github/mutation_repository.go @@ -213,9 +213,6 @@ func validateRepositorySettingsUpdate(update RepositorySettingsUpdate) error { if update.Name != nil && !githubNamePattern.MatchString(*update.Name) { return fmt.Errorf("GitHub repository rename target is invalid") } - if update.AllowAutoMerge != nil && *update.AllowAutoMerge { - return fmt.Errorf("enabling GitHub auto-merge is outside the ordinary mutation contract") - } if update.MergeCommitTitle != nil && !validMergeSetting(*update.MergeCommitTitle, "PR_TITLE", "MERGE_MESSAGE") { return fmt.Errorf("GitHub merge commit title setting is invalid") diff --git a/core/providers/github/mutation_test.go b/core/providers/github/mutation_test.go index 52fe956..beb56d9 100644 --- a/core/providers/github/mutation_test.go +++ b/core/providers/github/mutation_test.go @@ -33,12 +33,6 @@ func TestRepositoryMutatorRejectsUnboundOperationsBeforeRequest(t *testing.T) { ); err == nil { t.Fatal("pull-request mutation outside the bound scope was accepted") } - enabled := true - if _, _, err := repository.UpdateRepositorySettings( - context.Background(), RepositorySettingsUpdate{AllowAutoMerge: &enabled}, - ); err == nil { - t.Fatal("auto-merge enablement was accepted") - } if requests.Load() != 0 { t.Fatalf("rejected mutations made %d provider requests", requests.Load()) } @@ -73,6 +67,45 @@ func TestSetActionsPermissionsOmitsUnmanagedFields(t *testing.T) { } } +// Auto-merge used to be refused inside the mutation contract, so a repository +// could only ever have it turned off. Nothing about enabling it touches the +// three boundaries an agent stops at -- it merges exactly what the required +// checks already passed -- and refusing it cost every pull request a poll loop. +// It is now an ordinary managed setting, and this proves the value reaches +// GitHub instead of being rejected before the request. +func TestUpdateRepositorySettingsCarriesAutoMergeEnablement(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var body map[string]any + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body) != 1 || body["allow_auto_merge"] != true { + t.Errorf("auto-merge payload=%#v", body) + } + _, _ = writer.Write([]byte(`{"id":42,"node_id":"R_1","name":"repository",` + + `"full_name":"example/repository","owner":{"login":"example"},` + + `"default_branch":"main","html_url":"https://github.com/example/repository",` + + `"allow_auto_merge":true}`)) + })) + defer server.Close() + mutator := mutationTestMutator( + t, server, []string{MutationRepositorySettings}, nil, nil, + ) + repository, err := mutator.BindRepository(RepositoryMutationScope{ + RepositoryID: 42, Owner: "example", Name: "repository", + Operations: []string{MutationRepositorySettings}, + }) + if err != nil { + t.Fatal(err) + } + enabled := true + if _, _, err := repository.UpdateRepositorySettings( + context.Background(), RepositorySettingsUpdate{AllowAutoMerge: &enabled}, + ); err != nil { + t.Fatal(err) + } +} + func TestRepositoryMutatorExecutesTypedOperationsWithSpacingAndNoForce(t *testing.T) { var requests atomic.Int32 var waits atomic.Int32 diff --git a/core/rollout/plan.go b/core/rollout/plan.go index b7efbe0..29deda0 100644 --- a/core/rollout/plan.go +++ b/core/rollout/plan.go @@ -69,6 +69,7 @@ type BuildInput struct { RepositoryIDs []string Rings []RingSpec MaxFailureRate float64 + AutoMerge bool } func Build(input BuildInput, schemas *validation.Set) (Plan, []domain.Finding) { @@ -110,7 +111,7 @@ func Build(input BuildInput, schemas *validation.Set) (Plan, []domain.Finding) { }, TargetSetDigest: targetDigest, TargetCount: len(targets), Waves: waves, Gates: Gates{MaxFailureRate: input.MaxFailureRate}, - Mutation: Mutation{Mode: "pull-request", AutoMerge: false}, + Mutation: Mutation{Mode: "pull-request", AutoMerge: input.AutoMerge}, } digest, err := planDigest(plan) if err != nil { diff --git a/core/rollout/request.go b/core/rollout/request.go index 5c1e594..404d8f4 100644 --- a/core/rollout/request.go +++ b/core/rollout/request.go @@ -16,6 +16,10 @@ type Request struct { RepositoryIDs []string `json:"repository_ids"` Rings []RingSpec `json:"rings"` Gates RequestGates `json:"gates"` + // AutoMerge lets a rollout hand its pull requests to GitHub auto-merge so + // they land the moment their required checks pass. The checks stay exactly + // as strict; only the wait for someone to press merge is removed. + AutoMerge bool `json:"auto_merge"` } type RequestBundle struct { @@ -45,5 +49,6 @@ func BuildRequest(request Request, schemas *validation.Set) (Plan, []domain.Find RepositoryIDs: request.RepositoryIDs, Rings: request.Rings, MaxFailureRate: request.Gates.MaxFailureRate, + AutoMerge: request.AutoMerge, }, schemas) } diff --git a/docs/adr/0023-separate-github-mutation-capability.md b/docs/adr/0023-separate-github-mutation-capability.md index 2070b57..0ace38a 100644 --- a/docs/adr/0023-separate-github-mutation-capability.md +++ b/docs/adr/0023-separate-github-mutation-capability.md @@ -20,9 +20,18 @@ or bug to substitute another repository path after approval. are rejected, not tolerated. - A mutation factory is bound to one immutable repository ID, verified owner/name locator, and an operation subset before write methods are exposed. -- Force updates, auto-merge, visibility changes, permission changes, and - ruleset bypass are not ordinary methods. Repository deletion is separately - gated. +- Force updates, visibility changes, permission changes, and ruleset bypass + are not ordinary methods. Repository deletion is separately gated. +- Auto-merge is an ordinary merge setting (amended 2026-09-01). It was + originally grouped with the four above, which held it off in every managed + repository. That grouping was wrong: force, visibility, permissions and + bypass each remove a check, while auto-merge removes only the wait between a + check passing and the merge it already authorized. Nothing about it can lose + data, replace a credential, or expose private content, so it is not one of + the boundaries this engine stops at. Holding it off cost real time -- two + dependency pull requests sat 30.7 h and 25.4 h waiting for someone to notice + they were green -- and cost every other pull request a poll loop. The + required checks remain the gate; only the wait is gone. - Repository transfer is not exposed by the installation-token mutation provider. GitHub requires a user access token and completes transfer asynchronously after a `202 Accepted` response that still identifies the diff --git a/docs/contracts/github-mutation-provider-v1.md b/docs/contracts/github-mutation-provider-v1.md index 9dc86ba..ff4c587 100644 --- a/docs/contracts/github-mutation-provider-v1.md +++ b/docs/contracts/github-mutation-provider-v1.md @@ -111,7 +111,7 @@ acceptance/polling states, timeout and recovery behavior, and runtime fixtures. - non-force branch create and fast-forward update; - bounded file create/update with required old blob SHA for replacements; -- draft pull-request creation; ordinary mutation does not merge or auto-merge; +- draft pull-request creation; ordinary mutation does not merge; - repository rename, archive, and merge settings; - Actions, selected-actions, and workflow-token settings; - repository-level immutable-release enable/disable; @@ -125,8 +125,11 @@ bytes and contain printable ASCII except double quotes; multi-select values are bounded to 200 unique non-empty items. `null` remains the explicit unset value. Provider and reconciliation paths share the same validator. -Visibility, permission changes, force updates, auto-merge, and ruleset bypass -are absent from the ordinary provider API. Delete and visibility remain +Visibility, permission changes, force updates, and ruleset bypass are absent +from the ordinary provider API. `allow_auto_merge` is an ordinary merge setting +the provider may set in either direction: it changes when GitHub merges a pull +request whose required checks already passed, not whether those checks are +required. Delete and visibility remain separate-approval gates even though their underlying GitHub permission is Administration(write). Repository transfer is also absent because its token and asynchronous completion contracts differ from installation mutations. diff --git a/schemas/v1/mutation-capability.schema.json b/schemas/v1/mutation-capability.schema.json index fc74c15..14dea07 100644 --- a/schemas/v1/mutation-capability.schema.json +++ b/schemas/v1/mutation-capability.schema.json @@ -116,7 +116,7 @@ "visibility" ], "properties": { - "auto_merge": {"const": "forbidden"}, + "auto_merge": {"enum": ["allowed", "forbidden"]}, "delete": {"const": "separate-approval"}, "force": {"const": "forbidden"}, "permissions": {"const": "forbidden"}, diff --git a/schemas/v1/rollout-request.schema.json b/schemas/v1/rollout-request.schema.json index 7012174..0bf27e8 100644 --- a/schemas/v1/rollout-request.schema.json +++ b/schemas/v1/rollout-request.schema.json @@ -80,6 +80,9 @@ ] } }, + "auto_merge": { + "type": "boolean" + }, "gates": { "type": "object", "additionalProperties": false, diff --git a/schemas/v1/rollout.schema.json b/schemas/v1/rollout.schema.json index 9512338..51e08d3 100644 --- a/schemas/v1/rollout.schema.json +++ b/schemas/v1/rollout.schema.json @@ -114,7 +114,7 @@ "const": "pull-request" }, "auto_merge": { - "const": false + "type": "boolean" } } },