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

Merged
jschoubben merged 2 commits from fix/merge-without-check-gate into main 2026-10-25 22:18:42 +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; }
}
/** 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. */
export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> {
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 { 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 { checkGatesMerge } from "./mainrules.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. */
@@ -31,11 +32,13 @@ export interface MergeDeps {
emit?: (type: string, body: unknown) => Promise<void>;
stateDir?: string;
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 {
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 {
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, " +
@@ -126,7 +129,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
}
const kept = maintained(required);
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
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
const check = checks[CHECK_STATUS];
+8 -1
View File
@@ -181,6 +181,12 @@
"trusted": true,
"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": {
"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.",
@@ -316,7 +322,8 @@
"takes-config": [
"url",
"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);
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 () => {
@@ -36,6 +36,15 @@ func TestTheBoundsAreSettingsWithTheirDefaults(t *testing.T) {
if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour {
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 {
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)
}
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
}
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")
}
// 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 {
w.forge.mu.Lock()
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.
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.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.
if v := decidedOnHead(p.HeadChecks, now); v != nil {
d.Check = v
ts = append(ts, settle(d, Facts{Now: now})...)
ts = append(ts, settle(d, h.factsAt(now))...)
}
h.moved(d, ts)
if !h.takePlaceOf(replaced, d) {
@@ -700,7 +700,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
}
d, ts = fresh, []Transition{t}
if d.Check != nil {
ts = append(ts, settle(d, Facts{Now: now})...)
ts = append(ts, settle(d, h.factsAt(now))...)
}
} else if d == nil {
head := p.HeadSHA
@@ -726,6 +726,13 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
d.MergedAs = p.MergeCommit
if t, err := time.Parse(time.RFC3339, p.MergedAt); err == nil {
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 {
d.MergedAt = now
}
@@ -735,7 +742,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
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 == "" {
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:])
}
}},
// 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
// minutes: the gate asks itself for a look then.
{"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",
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 " +
"and tried again, given up) and when the last was, whether its events arrive, and the last refusals.",
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
"and tried again, given up) and when the last was, whether its events arrive, the last refusals, and " +
"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).
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)
}
// 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.
func (h *Holder) approvedOf(heads map[string]string) 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
}
// 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
// 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
@@ -447,7 +479,7 @@ func (h *Holder) settleForgeMerges() []func() {
again = h.newestInSource()
}
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
}
if d.Base != MergeBranch || d.replayed() || h.newerMergeOf(d) {
@@ -564,7 +596,7 @@ func (h *Holder) SourceCatchUp() {
func (h *Holder) newestInSource() map[*Delivery]bool {
newest := map[string]*Delivery{}
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
}
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})
}
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 {
h.keep(d)
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() {
var asks []func()
h.endStaleAsks()
gates := h.checkGates()
ids := make([]string, 0, len(h.deliveries))
for id := range h.deliveries {
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 != "" {
continue // not of a pull request, or past its merge
}
if !gates {
h.ungate(d, now)
}
if d.Ready == nil && d.State == Ready {
h.runOf(d, now)
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
}
// 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
// (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.
@@ -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
// Approve waits for that check first.
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 {
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)
}
// 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.
const waitsForReview = "waits for a review at "
@@ -64,6 +64,43 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
if len(w.forge.merges) != 1 {
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
@@ -207,6 +244,22 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
if w.op.count() != 1 {
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
@@ -25,6 +25,10 @@ const (
type Settings struct {
RefusalFloorMinutes *int `json:"refusal-stall-floor-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.
@@ -55,9 +59,30 @@ func readSettings(path string) (Settings, error) {
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
}
// 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.
func minutesOr(n *int, def time.Duration) time.Duration {
if n == nil {
@@ -84,6 +84,9 @@ type Facts struct {
Now time.Time
By 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.
@@ -132,8 +135,11 @@ var Table = []Row{
// cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again.
{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 " +
"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 {
if f.Ungated {
return nil
}
if d.Check == nil {
return errors.New("no verdict")
}
@@ -144,8 +150,11 @@ var Table = []Row{
}},
{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, " +
"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 {
if f.Ungated {
return errors.New("the check gates no merge (check-gates-merge false)")
}
if d.Check == nil {
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
// 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,
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 {
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")
},
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 {
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.
d := &Delivery{ID: "novox/app@c0", State: Ready}
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()
defer h.mu.Unlock()
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 {
state, why, _ := h.groupState(g, h.membersOf(g))
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 {
members := h.membersOf(g)
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)
}
@@ -286,7 +286,7 @@ func (h *Holder) Status(listening string) map[string]any {
byState[string(s)] = n
}
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,
// 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.
@@ -1,4 +1,5 @@
{
"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,
"trusted": false,
"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": [
@@ -103,7 +109,8 @@
"mode": "0600",
"takes-config": [
"refusal-stall-floor-minutes",
"author-new-head-minutes"
"author-new-head-minutes",
"check-gates-merge"
],
"merge": "json",
"template": "files/settings.json"