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" } } },