mesh-delivery-gate and gitea: a setting check-gates-merge; false asks a reviewed pull request's Approve and merges it without waiting for mesh/check
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check pass 32ad59fc6402 on 609013a2638f | unit 2010 run, 2322 kept, 5 skipped | integration 2032 run, 2341 kept, 9 skipped | over bound: unit, in…
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check pass 32ad59fc6402 on 609013a2638f | unit 2010 run, 2322 kept, 5 skipped | integration 2032 run, 2341 kept, 9 skipped | over bound: unit, in…
The operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses us too much time". Tonight each merge ask waited 3-7 minutes for mesh/check, and every merge into main refused the next approved merge for a check judged on the trunk before (mesh-delivery#52). Both settings default to true, so nothing changes until the operator sets them. False: the gate makes a reviewed pull request ready and asks its Approve with no verdict, a trunk's move puts nothing out of date, a verdict is kept and shown, and a group still merges in one pass; the merge tool merges a head the Approve names whatever its mesh/check says. delivery_status and show say the mode.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -742,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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user