Skip to content
Merged
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
3 changes: 0 additions & 3 deletions core/providers/github/mutation_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
45 changes: 39 additions & 6 deletions core/providers/github/mutation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion core/rollout/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions core/rollout/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
15 changes: 12 additions & 3 deletions docs/adr/0023-separate-github-mutation-capability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docs/contracts/github-mutation-provider-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion schemas/v1/mutation-capability.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
3 changes: 3 additions & 0 deletions schemas/v1/rollout-request.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
]
}
},
"auto_merge": {
"type": "boolean"
},
"gates": {
"type": "object",
"additionalProperties": false,
Expand Down
2 changes: 1 addition & 1 deletion schemas/v1/rollout.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
"const": "pull-request"
},
"auto_merge": {
"const": false
"type": "boolean"
}
}
},
Expand Down