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:
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user