Merge pull request 'Gate: a merge learnt seconds late moves the trunk; and a setting check-gates-merge so the merge check can stop gating (operator 2026-10-25 22:56)' (#483) from fix/merge-without-check-gate into main

Reviewed-on: #483
This commit was merged in pull request #483.
This commit is contained in:
2026-10-25 22:18:41 +00:00
20 changed files with 328 additions and 20 deletions
+13
View File
@@ -15,6 +15,19 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean {
} catch { return false; } } catch { return false; }
} }
/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses
* us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false
* is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an
* older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */
export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean {
const file = env.MESH_GITEA_CONFIG_FILE;
if (!file) return true;
try {
const v = (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)["check-gates-merge"];
return !(v === false || v === "false");
} catch { return true; }
}
/** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */ /** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */
export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> { export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> {
let said = false; let said = false;
+8 -2
View File
@@ -19,6 +19,7 @@ import { mergeableUnder, newestByContext } from "./checks.js";
import { MERGE_METHODS, Refused, checkName } from "./guard.js"; import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js"; import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { checkGatesMerge } from "./mainrules.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js"; import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */ /** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
@@ -31,11 +32,13 @@ export interface MergeDeps {
emit?: (type: string, body: unknown) => Promise<void>; emit?: (type: string, body: unknown) => Promise<void>;
stateDir?: string; stateDir?: string;
now?: () => Date; now?: () => Date;
/** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */
checkGates?: () => boolean;
} }
export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition { export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition {
const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR, const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
now: given.now ?? (() => new Date()) }; now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) };
return { return {
name: "gitea_merge_pull_request", name: "gitea_merge_pull_request",
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " + description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
@@ -126,7 +129,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
} }
const kept = maintained(required); const kept = maintained(required);
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0; const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
if (byCheck) { // **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's
// Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without
// approved_heads the check still judges.
if (byCheck && (deps.checkGates() || !approved)) {
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it // **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves. // is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
const check = checks[CHECK_STATUS]; const check = checks[CHECK_STATUS];
+8 -1
View File
@@ -181,6 +181,12 @@
"trusted": true, "trusted": true,
"default": {} "default": {}
}, },
"check-gates-merge": {
"type": "boolean",
"description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.",
"trusted": true,
"default": true
},
"main-mergers": { "main-mergers": {
"type": "list", "type": "list",
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.", "description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
@@ -316,7 +322,8 @@
"takes-config": [ "takes-config": [
"url", "url",
"keep-main-rules", "keep-main-rules",
"main-mergers" "main-mergers",
"check-gates-merge"
] ]
}, },
{ {
+12
View File
@@ -525,6 +525,18 @@ test("gitea_merge_pull_request judges mesh/check on a main requiring no status,
state.statuses = line(TRUNK); state.statuses = line(TRUNK);
assert.equal(((await merge()) as Record<string, unknown>).merged, true); assert.equal(((await merge()) as Record<string, unknown>).merged, true);
// check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says;
// without approved_heads it is refused as before.
const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false });
const mergeUngated = (args: Record<string, unknown> = { approved_heads }) =>
ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) {
state.merged = false;
state.statuses = statuses;
await assert.rejects(mergeUngated({}), /Approve/);
assert.equal(((await mergeUngated()) as Record<string, unknown>).merged, true, JSON.stringify(statuses));
}
}); });
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => { test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
@@ -36,6 +36,15 @@ func TestTheBoundsAreSettingsWithTheirDefaults(t *testing.T) {
if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour { if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour {
t.Fatalf("empty file: floor %s, author %s", h.refusalFloor(), h.authorBound()) t.Fatalf("empty file: floor %s, author %s", h.refusalFloor(), h.authorBound())
} }
if !h.checkGates() {
t.Fatalf("check-gates-merge unset reads false; the default is true")
}
if s, err = read(`{"check-gates-merge": false}`); err != nil || s.CheckGatesMerge == nil || *s.CheckGatesMerge || h.checkGates() {
t.Fatalf("check-gates-merge false: %+v %v, gates %v", s, err, h.checkGates())
}
if _, err := read(`{"check-gates-merge": "no"}`); err == nil {
t.Fatalf("a check-gates-merge that is not true or false was taken")
}
if _, err := read(`{"author-new-head-minutes": 0}`); err == nil { if _, err := read(`{"author-new-head-minutes": 0}`); err == nil {
t.Fatalf("a bound of zero minutes was taken") t.Fatalf("a bound of zero minutes was taken")
} }
@@ -67,7 +67,9 @@ func (h *Holder) CommitStatus(e ForgeStatusEvent) {
h.takeCheckStatus(e.Owner+"/"+e.Repo, e.SHA, e.State, e.Description) h.takeCheckStatus(e.Owner+"/"+e.Repo, e.SHA, e.State, e.Description)
} }
if e.State != "success" { if e.State != "success" {
h.withdrawForStatus(e) if h.checkGates() {
h.withdrawForStatus(e) // while the check gates no merge, its status is shown and takes back no ask
}
return return
} }
repository := strings.ToLower(e.Owner + "/" + e.Repo) repository := strings.ToLower(e.Owner + "/" + e.Repo)
@@ -16,6 +16,12 @@ func verdictMoving(repo string, number int, sha string, _ ...string) CheckedEven
return verdict(repo, number, sha, "pass") return verdict(repo, number, sha, "pass")
} }
// ungated is the setting check-gates-merge false: mesh/check gates no merge (the operator, 2026-10-25 22:56).
func ungated(w *world) {
off := false
w.h.Settings = func() Settings { return Settings{CheckGatesMerge: &off} }
}
func statusOn(w *world, repo, commit string) string { func statusOn(w *world, repo, commit string) string {
w.forge.mu.Lock() w.forge.mu.Lock()
defer w.forge.mu.Unlock() defer w.forge.mu.Unlock()
@@ -261,7 +261,7 @@ func (h *Holder) keepGroup(g *Group) {
// settleAndKeep moves a delivery as far as its facts allow, and keeps it. // settleAndKeep moves a delivery as far as its facts allow, and keeps it.
func (h *Holder) settleAndKeep(d *Delivery) { func (h *Holder) settleAndKeep(d *Delivery) {
ts := settle(d, Facts{Now: h.Now()}) ts := settle(d, h.factsAt(h.Now()))
h.moved(d, ts) h.moved(d, ts)
h.closeGroupOf(d) h.closeGroupOf(d)
} }
@@ -378,7 +378,7 @@ func (h *Holder) pullUpdated(p PullEvent) (isNew bool) {
// the gate's status on that very commit. A decided one is taken; a pending or errored one is asked again. // the gate's status on that very commit. A decided one is taken; a pending or errored one is asked again.
if v := decidedOnHead(p.HeadChecks, now); v != nil { if v := decidedOnHead(p.HeadChecks, now); v != nil {
d.Check = v d.Check = v
ts = append(ts, settle(d, Facts{Now: now})...) ts = append(ts, settle(d, h.factsAt(now))...)
} }
h.moved(d, ts) h.moved(d, ts)
if !h.takePlaceOf(replaced, d) { if !h.takePlaceOf(replaced, d) {
@@ -700,7 +700,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
} }
d, ts = fresh, []Transition{t} d, ts = fresh, []Transition{t}
if d.Check != nil { if d.Check != nil {
ts = append(ts, settle(d, Facts{Now: now})...) ts = append(ts, settle(d, h.factsAt(now))...)
} }
} else if d == nil { } else if d == nil {
head := p.HeadSHA head := p.HeadSHA
@@ -726,6 +726,13 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
d.MergedAs = p.MergeCommit d.MergedAs = p.MergeCommit
if t, err := time.Parse(time.RFC3339, p.MergedAt); err == nil { if t, err := time.Parse(time.RFC3339, p.MergedAt); err == nil {
d.MergedAt = t.UTC() d.MergedAt = t.UTC()
if late && now.Sub(d.MergedAt) < replayLate {
// Read from the forge's open pull requests seconds after the merge, before its event arrived (mesh-delivery#51,
// 2026-10-25 21:43: merged 21:43:11, learnt late 21:43:14): the trunk has not moved past it, so it is taken as a
// merge heard as it happened. Taken late, it moved no trunk and its group's finish left it out of source.main,
// which kept naming the trunk before it, and the checker judged every head on that.
late = false
}
} else if !late { } else if !late {
d.MergedAt = now d.MergedAt = now
} }
@@ -735,7 +742,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
d.Ready.SourceMoved = true d.Ready.SourceMoved = true
} }
} }
ts = append(ts, settle(d, Facts{Now: now})...) ts = append(ts, settle(d, h.factsAt(now))...)
if d.State == Held && d.HeldWhy == "" { if d.State == Held && d.HeldWhy == "" {
d.HeldWhy = "merged without a passing check: only a person decides what follows" d.HeldWhy = "merged without a passing check: only a person decides what follows"
} }
@@ -639,6 +639,42 @@ func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
t.Fatalf("source.main moved for a merge learnt late: %+v", w.source.moves[moves:]) t.Fatalf("source.main moved for a merge learnt late: %+v", w.source.moves[moves:])
} }
}}, }},
// mesh-delivery#51 (2026-10-25 21:43): the open list was read seconds after the bridge merged it and before the forge's
// merged event, so it was learnt late, moved no trunk, and its group's finish left it out of source.main. A merge
// learnt within replayLate of its landing is taken as heard as it happened; one a gate before the fix took late is
// written at the catch-up of the start.
{"a merge learnt from the open list seconds after it landed moves source.main", func(t *testing.T) {
const repo, head, merge = "novox/mesh-delivery", "4628ba26b41a4628ba26b41a4628ba26b41a4628", "db53ec975f01db53ec975f01db53ec975f01db53"
for _, oldGate := range []bool{false, true} {
w := newWorld(t)
w.merges.manual = true
w.h.PullUpdated(pr(repo, 51, head, "fix/1945", "modules/a/a.go"))
w.h.Checked(judgedAt(repo, 51, head, "pass", "t1t1t1t1t1t1"))
w.settleAll()
w.source.main = &sdk.Source{V: sdk.Version, Group: "before", Heads: map[string]string{repo: "73be3df28cd4"}}
w.forge.pulls[repo+"#51"] = ForgePull{Number: 51, State: "closed", Merged: true, MergeCommit: merge,
MergedAt: w.h.Now().Add(-3 * time.Second).UTC().Format(time.RFC3339), HeadSHA: head, Base: "main"}
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
w.h.wantForge()
w.settleAll()
if oldGate { // what the gate before the fix left
w.h.mu.Lock()
kept := w.h.deliveries[IDOf(repo, head)]
kept.LearntLate, kept.InSource, kept.Ready.SourceMoved = true, true, true
w.h.mu.Unlock()
w.source.main.Heads[repo] = "73be3df28cd4"
w.h.SourceCatchUp()
w.settleAll()
}
d := w.delivery(IDOf(repo, head))
if d.State != Merged || (!oldGate && d.LearntLate) || w.source.main.Heads[repo] != merge {
t.Fatalf("old gate %v: %s, learnt late %v, source.main %v", oldGate, d.State, d.LearntLate, w.source.main.Heads)
}
if heads, _ := w.h.SourceMain()["heads"].(map[string]string); heads[repo] != merge {
t.Fatalf("delivery_status's source-main says %v", w.h.SourceMain())
}
}
}},
// Review round 1, blocker 2 (1275/AC6): an out-of-date refusal at t0 with no other event is said urgent at t0+10 // Review round 1, blocker 2 (1275/AC6): an out-of-date refusal at t0 with no other event is said urgent at t0+10
// minutes: the gate asks itself for a look then. // minutes: the gate asks itself for a look then.
{"an out-of-date refusal turns urgent on the clock alone", func(t *testing.T) { {"an out-of-date refusal turns urgent on the clock alone", func(t *testing.T) {
@@ -360,8 +360,13 @@ func tools(h *Holder, l *listening) []stdio.Tool {
{Name: "delivery_status", {Name: "delivery_status",
Description: "Whether this owner works: how many deliveries in each state, the groups, what is owed to " + Description: "Whether this owner works: how many deliveries in each state, the groups, what is owed to " +
"the forge and the bus and not yet done, how many owed effects were tried since it started (done, failed " + "the forge and the bus and not yet done, how many owed effects were tried since it started (done, failed " +
"and tried again, given up) and when the last was, whether its events arrive, and the last refusals.", "and tried again, given up) and when the last was, whether its events arrive, the last refusals, and " +
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }}, "source.main as the gate's bucket source holds it (each repository's trunk head the checker judges on).",
Run: func(map[string]any) (any, error) {
out := h.Status(l.get())
out["source-main"] = h.SourceMain()
return out, nil
}},
} }
// A verb that changes a delivery asks for a look at what it makes possible (react.go). // A verb that changes a delivery asks for a look at what it makes possible (react.go).
for i := range list { for i := range list {
@@ -105,6 +105,26 @@ func (kvSource) Move(update func(prev *sdk.Source) (sdk.Source, bool)) error {
return fmt.Errorf("source.main was written by another writer first %d times: %w", changeAttempts, stdio.ErrChanged) return fmt.Errorf("source.main was written by another writer first %d times: %w", changeAttempts, stdio.ErrChanged)
} }
// SourceMain is source.main as the bucket source holds it, read and never written: its group and each repository's trunk
// head, or why it cannot be read. Outside the lock.
func (h *Holder) SourceMain() map[string]any {
if h.Source == nil {
return map[string]any{"error": "no source bucket"}
}
var held *sdk.Source
err := h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
held = prev
return sdk.Source{}, false
})
switch {
case err != nil:
return map[string]any{"error": err.Error()}
case held == nil:
return map[string]any{"heads": map[string]string{}, "says": "source.main is not written yet"}
}
return map[string]any{"group": held.Group, "heads": held.Heads}
}
// approvedOf are the heads an Approve covers as a merge carries them: `<repository>@<number>` to the full head. // approvedOf are the heads an Approve covers as a merge carries them: `<repository>@<number>` to the full head.
func (h *Holder) approvedOf(heads map[string]string) map[string]string { func (h *Holder) approvedOf(heads map[string]string) map[string]string {
out := map[string]string{} out := map[string]string{}
@@ -434,6 +454,18 @@ func gateMerges(d *Delivery) bool {
return d.Ready != nil && d.Ready.Approved != nil && d.Ready.AskID != "" && len(d.Ready.Heads) > 0 return d.Ready != nil && d.Ready.Approved != nil && d.Ready.AskID != "" && len(d.Ready.Heads) > 0
} }
// leftOutOfForgeSource is whether settleForgeMerges never writes a merge to source.main: one the gate handed on an Approve
// (finish writes it), and one learnt after the fact, long past or with no time the forge said. One learnt late within
// replayLate of its merge, as a gate before the fix took mesh-delivery#51 (2026-10-25, its group's finish left it out and
// source.main kept the trunk before it), is weighed like any merge heard from the forge at a catch-up, and never moves
// source.main back (olderHead).
func (d *Delivery) leftOutOfForgeSource() bool {
if d.LearntLate {
return d.MergedAt.IsZero() || d.Since.Sub(d.MergedAt) >= replayLate
}
return gateMerges(d)
}
// settleForgeMerges moves source.main for each merge into the trunk heard only from the forge (issue 888): a pull request // settleForgeMerges moves source.main for each merge into the trunk heard only from the forge (issue 888): a pull request
// merged at the forge directly, not handed by the gate on an Approve. Every other delivery's trunk moved with it (trunkMoved), // merged at the forge directly, not handed by the gate on an Approve. Every other delivery's trunk moved with it (trunkMoved),
// and the checker judges on the trunk source.main names, so source.main moves too. A merge replayed as history, or older than // and the checker judges on the trunk source.main names, so source.main moves too. A merge replayed as history, or older than
@@ -447,7 +479,7 @@ func (h *Holder) settleForgeMerges() []func() {
again = h.newestInSource() again = h.newestInSource()
} }
for _, d := range h.deliveries { for _, d := range h.deliveries {
if d.MergedAs == "" || (d.InSource && !again[d]) || gateMerges(d) || d.LearntLate { if d.MergedAs == "" || (d.InSource && !again[d]) || d.leftOutOfForgeSource() {
continue continue
} }
if d.Base != MergeBranch || d.replayed() || h.newerMergeOf(d) { if d.Base != MergeBranch || d.replayed() || h.newerMergeOf(d) {
@@ -564,7 +596,7 @@ func (h *Holder) SourceCatchUp() {
func (h *Holder) newestInSource() map[*Delivery]bool { func (h *Holder) newestInSource() map[*Delivery]bool {
newest := map[string]*Delivery{} newest := map[string]*Delivery{}
for _, d := range h.deliveries { for _, d := range h.deliveries {
if d.MergedAs == "" || !d.InSource || gateMerges(d) || d.LearntLate || d.Base != MergeBranch || d.replayed() { if d.MergedAs == "" || !d.InSource || d.leftOutOfForgeSource() || d.Base != MergeBranch || d.replayed() {
continue continue
} }
if o := newest[d.Repository]; o == nil || mergedWhen(d).After(mergedWhen(o)) { if o := newest[d.Repository]; o == nil || mergedWhen(d).After(mergedWhen(o)) {
@@ -155,6 +155,13 @@ func (h *Holder) takeCheckStatus(repository, sha, state, desc string) {
} }
h.moved(d, []Transition{t}) h.moved(d, []Transition{t})
} }
if d.State == Ready && !h.checkGates() {
// The check gates no merge (check-gates-merge false): its verdict is shown on the delivery, and moves nothing.
d.Check = v
h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s (shown; the check gates no merge)", d.ID, state, oneLine(clip(desc, 120)))
h.keep(d)
return
}
if d.State != Proposed { if d.State != Proposed {
h.keep(d) h.keep(d)
return // past its verdict: the merge reads the forge's status itself return // past its verdict: the merge reads the forge's status itself
@@ -157,6 +157,7 @@ func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun {
func (h *Holder) readyAsks(now time.Time) []func() { func (h *Holder) readyAsks(now time.Time) []func() {
var asks []func() var asks []func()
h.endStaleAsks() h.endStaleAsks()
gates := h.checkGates()
ids := make([]string, 0, len(h.deliveries)) ids := make([]string, 0, len(h.deliveries))
for id := range h.deliveries { for id := range h.deliveries {
ids = append(ids, id) ids = append(ids, id)
@@ -167,6 +168,9 @@ func (h *Holder) readyAsks(now time.Time) []func() {
if d.Number == 0 || d.State.Final() || d.MergedAs != "" { if d.Number == 0 || d.State.Final() || d.MergedAs != "" {
continue // not of a pull request, or past its merge continue // not of a pull request, or past its merge
} }
if !gates {
h.ungate(d, now)
}
if d.Ready == nil && d.State == Ready { if d.Ready == nil && d.State == Ready {
h.runOf(d, now) h.runOf(d, now)
h.Poke() // its check, if none is asked, is worth asking now (askCheck) h.Poke() // its check, if none is asked, is worth asking now (askCheck)
@@ -271,6 +275,30 @@ func (h *Holder) readyAsks(now time.Time) []func() {
return asks return asks
} }
// ungate stands a delivery where it is while mesh/check gates no merge (check-gates-merge false): a proposed one is ready
// with no verdict, and a rejected one, refused on a verdict before the setting changed, is proposed again and so ready. Its
// verdict stays on it, shown. Under the lock.
func (h *Holder) ungate(d *Delivery, now time.Time) {
f := h.factsAt(now)
if d.State == Rejected {
f.By, f.Why = byOwner, "the check gates no merge (check-gates-merge false): its verdict is shown, not waited on"
t, err := Apply(d, EvRecheck, true, f)
if err != nil {
h.refuse(err)
return
}
if d.Ready != nil {
d.Ready.Failed = ""
}
h.moved(d, []Transition{t})
f.By, f.Why = "", ""
}
if d.State == Proposed {
h.moved(d, settle(d, f))
h.closeGroupOf(d)
}
}
// sayAfterLine tells a pull request, once, that its description holds an `after: <repository>` line, which is not read // sayAfterLine tells a pull request, once, that its description holds an `after: <repository>` line, which is not read
// (novox/hq ADR 0419 §2): the order of a delivery comes from the module graph alone, and no refusal asks for the line. // (novox/hq ADR 0419 §2): the order of a delivery comes from the module graph alone, and no refusal asks for the line.
// Once per pull request, not per head. Under the lock; the comment is said outside it. // Once per pull request, not per head. Under the lock; the comment is said outside it.
@@ -337,6 +365,9 @@ func (r *ReadyRun) noteRefusal(d *Delivery, answer string) {
// stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an // stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an
// Approve waits for that check first. // Approve waits for that check first.
func (h *Holder) readyWaits(d *Delivery) string { func (h *Holder) readyWaits(d *Delivery) string {
if !h.checkGates() {
return h.ungatedWaits(d)
}
if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil { if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil {
return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head" return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head"
} }
@@ -369,6 +400,27 @@ func (h *Holder) readyWaits(d *Delivery) string {
return h.groupTurnToMerge(d) return h.groupTurnToMerge(d)
} }
// ungatedWaits is readyWaits while mesh/check gates no merge (check-gates-merge false): a ready delivery waits only for its
// review at its head, and a group member for every member to be ready; no verdict, composed check or trunk is waited on.
func (h *Holder) ungatedWaits(d *Delivery) string {
if d.State != Ready {
return "it is " + string(d.State) + ", not ready yet"
}
if h.covering(d) == nil {
if w := h.reviewWaits(d); w != "" {
return w
}
}
if g := h.groups[d.Group]; d.Group != "" && g != nil {
for _, m := range h.membersOf(g) {
if m.State != Ready && m.State != Merged {
return "its group " + g.ID + " waits for " + m.ID + ", which is " + string(m.State)
}
}
}
return ""
}
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it. // waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
const waitsForReview = "waits for a review at " const waitsForReview = "waits for a review at "
@@ -64,6 +64,43 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
if len(w.forge.merges) != 1 { if len(w.forge.merges) != 1 {
t.Fatalf("merged again: %v", w.forge.merges) t.Fatalf("merged again: %v", w.forge.merges)
} }
// check-gates-merge false (the operator, 2026-10-25 22:56): a reviewed pull request is asked with no verdict, another
// merge moving its trunk puts it out of nothing, a failed verdict is shown on it and holds nothing, and the gate's
// status and show say the mode.
u := newWorld(t)
ungated(u)
u.op.auto = false
const other = "3333333333333333333333333333333333333333"
u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go"))
u.settleAll()
if u.op.count() != 2 || u.state(IDOf("novox/app", readyHead)) != Ready {
t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s", u.op.count(), u.state(IDOf("novox/app", readyHead)))
}
if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates {
t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening"))
}
shown, _ := u.h.Show(IDOf("novox/app", other))
if gates, _ := shown.(map[string]any)["check-gates-merge"].(bool); gates {
t.Fatalf("show does not say the check gates no merge: %v", shown)
}
u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1"))
u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
u.settleAll()
d = u.delivery(IDOf("novox/app", other))
if d.State != Ready || d.Check == nil || d.Check.Passes() {
t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check)
}
for _, a := range u.op.open() {
if strings.Contains(a, "feat/y") {
u.op.answer(a, OptionApprove)
}
}
u.settleAll()
if len(u.forge.merges) != 1 || u.forge.merges[0] != "novox/app#8@"+other {
t.Fatalf("ungated, the approved head is merged %v; wanted once, with no verdict waited on", u.forge.merges)
}
} }
// 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a // 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a
@@ -207,6 +244,22 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
if w.op.count() != 1 { if w.op.count() != 1 {
t.Fatalf("a second Approve was asked: %d", w.op.count()) t.Fatalf("a second Approve was asked: %d", w.op.count())
} }
// check-gates-merge false: the group is asked once with no verdict and no composed check, and merges whole in one pass.
u := newWorld(t)
ungated(u)
u.op.auto = false
u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
u.settleAll()
if u.op.count() != 1 {
t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count())
}
u.op.answer(u.op.open()[0], OptionApprove)
u.settleAll()
if len(u.forge.merges) != 2 {
t.Fatalf("ungated, both members merge in one pass: %v", u.forge.merges)
}
} }
// **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a green pull request // **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a green pull request
@@ -25,6 +25,10 @@ const (
type Settings struct { type Settings struct {
RefusalFloorMinutes *int `json:"refusal-stall-floor-minutes"` RefusalFloorMinutes *int `json:"refusal-stall-floor-minutes"`
AuthorBoundMinutes *int `json:"author-new-head-minutes"` AuthorBoundMinutes *int `json:"author-new-head-minutes"`
// CheckGatesMerge is whether mesh/check gates the merge (the default, true). False (the operator, 2026-10-25 22:56:
// "run the check locally only, but not as a gate, it loses us too much time"): a reviewed pull request is ready and its
// Approve asked without a verdict, a trunk's move puts nothing out of date, and a verdict is shown, never waited on.
CheckGatesMerge *bool `json:"check-gates-merge"`
} }
// readSettings reads the settings file; a value that is not a whole number of minutes, one or more, is refused. // readSettings reads the settings file; a value that is not a whole number of minutes, one or more, is refused.
@@ -55,9 +59,30 @@ func readSettings(path string) (Settings, error) {
return Settings{}, fmt.Errorf("%s is not a number", name) return Settings{}, fmt.Errorf("%s is not a number", name)
} }
} }
switch v := loose["check-gates-merge"].(type) {
case nil:
case bool:
s.CheckGatesMerge = &v
default:
return Settings{}, errors.New("check-gates-merge is not true or false")
}
return s, nil return s, nil
} }
// checkGates is whether mesh/check gates the merge: true unless the setting check-gates-merge is false.
func (h *Holder) checkGates() bool {
if h.Settings == nil {
return true
}
v := h.Settings().CheckGatesMerge
return v == nil || *v
}
// factsAt are the facts a settle reads at now: the moment, and whether the check gates the merge.
func (h *Holder) factsAt(now time.Time) Facts {
return Facts{Now: now, Ungated: !h.checkGates()}
}
// minutesOr is a setting's minutes, or the default when it gives none. // minutesOr is a setting's minutes, or the default when it gives none.
func minutesOr(n *int, def time.Duration) time.Duration { func minutesOr(n *int, def time.Duration) time.Duration {
if n == nil { if n == nil {
@@ -84,6 +84,9 @@ type Facts struct {
Now time.Time Now time.Time
By string By string
Why string Why string
// Ungated is the setting check-gates-merge false: mesh/check gates no merge, so a proposed delivery is accepted with no
// verdict, none is refused for one, and a trunk's move puts none back to proposed.
Ungated bool
} }
// Row is one transition the table holds. // Row is one transition the table holds.
@@ -132,8 +135,11 @@ var Table = []Row{
// cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again. // cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again.
{From: []State{Proposed}, Event: EvAccepted, To: Ready, {From: []State{Proposed}, Event: EvAccepted, To: Ready,
Guard: "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and " + Guard: "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and " +
"the repository's own check did not fail", "the repository's own check did not fail; or the check gates no merge (check-gates-merge false)",
Holds: func(d *Delivery, f Facts) error { Holds: func(d *Delivery, f Facts) error {
if f.Ungated {
return nil
}
if d.Check == nil { if d.Check == nil {
return errors.New("no verdict") return errors.New("no verdict")
} }
@@ -144,8 +150,11 @@ var Table = []Row{
}}, }},
{From: []State{Proposed}, Event: EvRefused, To: Rejected, {From: []State{Proposed}, Event: EvRefused, To: Rejected,
Guard: "a verdict names this commit and the trunk's head as its trunk commit, and the gate failed or could not run, " + Guard: "a verdict names this commit and the trunk's head as its trunk commit, and the gate failed or could not run, " +
"or the repository's own check failed", "or the repository's own check failed; never while the check gates no merge",
Holds: func(d *Delivery, f Facts) error { Holds: func(d *Delivery, f Facts) error {
if f.Ungated {
return errors.New("the check gates no merge (check-gates-merge false)")
}
if d.Check == nil { if d.Check == nil {
return errors.New("no verdict") return errors.New("no verdict")
} }
@@ -157,8 +166,11 @@ var Table = []Row{
// **A ready delivery whose verdict went out of date goes back to proposed** (novox/hq ADR 0323 §7): another // **A ready delivery whose verdict went out of date goes back to proposed** (novox/hq ADR 0323 §7): another
// merge moved its trunk. A rejected one stays rejected, its status on the forge saying the verdict is out of date. // merge moved its trunk. A rejected one stays rejected, its status on the forge saying the verdict is out of date.
{From: []State{Ready}, Event: EvTrunkMoved, To: Proposed, {From: []State{Ready}, Event: EvTrunkMoved, To: Proposed,
Guard: "its verdict names a trunk commit that is not its trunk's head", Guard: "its verdict names a trunk commit that is not its trunk's head, and the check gates the merge",
Holds: func(d *Delivery, f Facts) error { Holds: func(d *Delivery, f Facts) error {
if f.Ungated {
return errors.New("the check gates no merge (check-gates-merge false)")
}
return unless(d.MergedAs == "" && d.outOfDate() != "", "not merged, and its verdict is not out of date") return unless(d.MergedAs == "" && d.outOfDate() != "", "not merged, and its verdict is not out of date")
}, },
Says: func(d *Delivery) string { return d.outOfDate() }}, Says: func(d *Delivery) string { return d.outOfDate() }},
@@ -155,6 +155,22 @@ func TestTheTableKeepsItsRules(t *testing.T) {
if _, err := Apply(d0, EvReask, true, Facts{Now: time.Now(), By: byH2, Why: "stalled"}); err == nil { if _, err := Apply(d0, EvReask, true, Facts{Now: time.Now(), By: byH2, Why: "stalled"}); err == nil {
t.Fatal("a check re-asked once was re-asked again") t.Fatal("a check re-asked once was re-asked again")
} }
// While the check gates no merge (check-gates-merge false): a proposed delivery is accepted with no verdict or a failed
// one, none is refused, and a trunk's move puts no ready one back to proposed.
off := Facts{Now: time.Now(), Ungated: true}
for _, check := range []*Verdict{nil, {Gate: "fail"}} {
p := &Delivery{ID: "novox/app@c0", State: Proposed, Number: 1, Check: check}
if _, err := Apply(p, EvRefused, false, off); err == nil {
t.Fatalf("ungated, a verdict %+v refused the delivery", check)
}
if _, err := Apply(p, EvAccepted, false, off); err != nil || p.State != Ready {
t.Fatalf("ungated, a delivery with the verdict %+v is %s: %v", check, p.State, err)
}
}
stale := &Delivery{ID: "novox/app@c0", State: Ready, Check: &Verdict{Gate: "pass", Trunk: "d28ca95bbbbb"}, TrunkNow: "690966dccccc"}
if _, err := Apply(stale, EvTrunkMoved, false, off); err == nil {
t.Fatal("ungated, a trunk's move put a ready delivery back to proposed")
}
// A ready delivery off the trunk is refused its merge, whatever else is true of it. // A ready delivery off the trunk is refused its merge, whatever else is true of it.
d := &Delivery{ID: "novox/app@c0", State: Ready} d := &Delivery{ID: "novox/app@c0", State: Ready}
if _, err := Apply(d, EvMerged, false, Facts{Now: time.Now()}); err == nil { if _, err := Apply(d, EvMerged, false, Facts{Now: time.Now()}); err == nil {
@@ -75,7 +75,7 @@ func (h *Holder) Show(id string) (any, error) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
if d := h.deliveries[id]; d != nil { if d := h.deliveries[id]; d != nil {
out := map[string]any{"delivery": d, "waits": h.waitsFor(d)} out := map[string]any{"delivery": d, "waits": h.waitsFor(d), "check-gates-merge": h.checkGates()}
if g := h.groups[d.Group]; g != nil { if g := h.groups[d.Group]; g != nil {
state, why, _ := h.groupState(g, h.membersOf(g)) state, why, _ := h.groupState(g, h.membersOf(g))
out["group"] = map[string]any{"group": g, "state": state, "why": why} out["group"] = map[string]any{"group": g, "state": state, "why": why}
@@ -85,7 +85,7 @@ func (h *Holder) Show(id string) (any, error) {
if g := h.groups[id]; g != nil { if g := h.groups[id]; g != nil {
members := h.membersOf(g) members := h.membersOf(g)
state, why, _ := h.groupState(g, members) state, why, _ := h.groupState(g, members)
return map[string]any{"group": g, "state": state, "why": why, "members": members}, nil return map[string]any{"group": g, "state": state, "why": why, "members": members, "check-gates-merge": h.checkGates()}, nil
} }
return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id) return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id)
} }
@@ -286,7 +286,7 @@ func (h *Holder) Status(listening string) map[string]any {
byState[string(s)] = n byState[string(s)] = n
} }
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty), out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
"events": listening, "started": h.started.Format(time.RFC3339)} "events": listening, "started": h.started.Format(time.RFC3339), "check-gates-merge": h.checkGates()}
// The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again, // The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again,
// or given up, and when the last was tried. A count since the start, said with that start: two readings in // or given up, and when the last was tried. A count since the start, said with that start: two readings in
// one run give a window's tries. // one run give a window's tries.
@@ -1,4 +1,5 @@
{ {
"refusal-stall-floor-minutes": 10, "refusal-stall-floor-minutes": 10,
"author-new-head-minutes": 120 "author-new-head-minutes": 120,
"check-gates-merge": true
} }
+8 -1
View File
@@ -86,6 +86,12 @@
"maximum": 10080, "maximum": 10080,
"trusted": false, "trusted": false,
"default": 120 "default": 120
},
"check-gates-merge": {
"type": "boolean",
"description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready and its Approve asked at once, with no verdict waited on; no merge is refused for a check judged on an older trunk; a verdict that comes is shown on the pull request and the delivery, never waited on; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.",
"trusted": false,
"default": true
} }
}, },
"resources": [ "resources": [
@@ -103,7 +109,8 @@
"mode": "0600", "mode": "0600",
"takes-config": [ "takes-config": [
"refusal-stall-floor-minutes", "refusal-stall-floor-minutes",
"author-new-head-minutes" "author-new-head-minutes",
"check-gates-merge"
], ],
"merge": "json", "merge": "json",
"template": "files/settings.json" "template": "files/settings.json"