diff --git a/merge-check.sh b/merge-check.sh index 45d4a1ec..a466457d 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -17,8 +17,7 @@ # toolchain has a C compiler. A requirement of the mesh's own forge the module cache does not hold (a # pinned SDK commit) is taken from its repository's clone beside the check (checks/deps); a module whose # dependencies still cannot be had **fails the check**, by name and why — its tests could not run, and a -# test that could not run is a failure, never a note (novox/hq issue 385). The integration tests (`//go:build -# integration`) run here, with -tags integration, and only here. A module written in +# test that could not run is a failure, never a note (novox/hq issue 385). A module written in # TypeScript is type-checked and tested by # merge-check-typescript.sh, declared above, in the TypeScript toolchain (novox/hq issues 302, 311); # 3. the words (novox/hq ADR 0244): no word the glossary retired for the tools, as copied in @@ -184,10 +183,8 @@ for m in $touched; do fi cgo=0 [ -n "$race" ] && cgo=1 - # The integration tests (a file whose first line is `//go:build integration`: a test that starts a bus, a store or - # another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07). - (cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... && - CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...) + (cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet ${modfile:+"-modfile=$modfile"} ./... && + CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test ${modfile:+"-modfile=$modfile"} $race -count=1 ./...) done if [ -n "$untestable" ]; then echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)" diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 8ab610fd..0d591a56 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -42,7 +42,7 @@ import { commitStatusTool } from "./delivery.js"; import { announce, keepWatermark, readWatermark } from "./announce.js"; import { mergeTool } from "./merge.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; -import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, groupGateStatus, headsToAnnounce, statusesFor, type Announced, type Checked, +import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, statusesFor, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js"; import { GateWriter, clonedAs, type BuildStarted } from "./gate.js"; import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; @@ -397,23 +397,24 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined); if (head && !sameCommit(c.trunk, head)) { const description = outOfDateWords(c.trunk, head); - for (const context of [CHECK_CONTEXT, ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) { + for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) { await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description, ...(target ? { target_url: target } : {}) }); } // Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date. const said = commentFor(c, await repoCheckFacts(client, c, trunk)); - if (c.number) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description)); + if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description)); console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` + `${description}`); return; } } const facts = await repoCheckFacts(client, c, base ?? c.plan?.base); - for (const status of statusesFor(c, facts)) { + const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record)))[CHECK_CONTEXT]?.description : undefined; + for (const status of statusesFor(c, facts, gateNow)) { await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status); } - const comment = commentFor(c, facts); + const comment = composedGate(c) ? null : commentFor(c, facts); if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment); console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`); } diff --git a/modules/gitea/pulls.ts b/modules/gitea/pulls.ts index 6dbf2040..4e01f415 100644 --- a/modules/gitea/pulls.ts +++ b/modules/gitea/pulls.ts @@ -248,8 +248,24 @@ export function groupGateStatus(c: Checked, m: NonNullable[n return { state: stateOf(c.verdict), context: CHECK_CONTEXT, description: clipped(first + said) }; } +/** Whether a verdict's gate layer is the group's composed check's (ADR 0403 §1): no gate was run for the head alone. */ +export function composedGate(c: Checked): boolean { + return c.gate?.verdict === "composed"; +} + /** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */ -export function statusesFor(c: Checked, facts: RepoCheckFacts = {}): CommitStatus[] { +export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] { + if (composedGate(c)) { + // A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to + // set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it. + const out: CommitStatus[] = []; + if (!(gateNow ?? "").includes(" composed: ")) { + out.push({ state: "pending", context: CHECK_CONTEXT, description: "waits for its group's composed check" }); + } + const repo = c["repo-check"]; + if (repo) out.push(judgedFirst(c, repoStatus(repo, facts))); + return out; + } const out = [statusFor(c)]; const repo = c["repo-check"]; if (repo) out.push(judgedFirst(c, repoStatus(repo, facts))); diff --git a/modules/gitea/test/pulls.test.ts b/modules/gitea/test/pulls.test.ts index dc775e11..3ebabf47 100644 --- a/modules/gitea/test/pulls.test.ts +++ b/modules/gitea/test/pulls.test.ts @@ -172,3 +172,23 @@ test("a composed verdict sets every member's gate at its own head, naming its tr assert.equal(groupGateStatus({ ...c, verdict: "fail", summary: "a and b do not compose" }, c.members[0]).state, "failure"); assert.equal(groupGateStatus({ ...c, verdict: "error", summary: "could not run" }, c.members[0]).state, "error"); }); + +// A member's own verdict whose gate layer is the group's composed check's sets its repository check only, and never the +// gate as an error: the gate stays pending until the composed verdict lands, in either order (ADR 0403 §1, issue 978). +test("a member's own verdict with a composed gate layer leaves the gate to the composed verdict, in either order", async () => { + const { statusesFor, composedGate, groupGateStatus, CHECK_CONTEXT, REPO_CHECK_CONTEXT } = await import("../pulls.ts"); + const c = { owner: "novox", repo: "mesh-catalog", number: 2, commit: "d".repeat(40), id: "build-1", verdict: "pass", summary: "x", + gate: { verdict: "composed", summary: "judged by its group" }, "repo-check": { verdict: "pass", summary: "suite passed" } }; + assert.ok(composedGate(c)); + // Before the composed verdict: the repository check, and the gate pending, saying it waits. + const before = statusesFor(c, {}, undefined); + assert.deepEqual(before.map((s) => [s.context, s.state]), [[CHECK_CONTEXT, "pending"], [REPO_CHECK_CONTEXT, "success"]]); + assert.match(before[0].description, /waits for its group's composed check/); + assert.ok(!before.some((s) => s.state === "error")); + // After it: the standing composed gate is left alone. + const standing = groupGateStatus({ ...c, group: "feat/x", members: [] }, { owner: "novox", repo: "mesh-catalog", commit: c.commit }).description; + const after = statusesFor(c, {}, standing); + assert.deepEqual(after.map((s) => [s.context, s.state]), [[REPO_CHECK_CONTEXT, "success"]]); + // A verdict with an ordinary gate is as before. + assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false); +}); diff --git a/modules/mesh-delivery/cmd/mesh-delivery/react.go b/modules/mesh-delivery/cmd/mesh-delivery/react.go index 79da6816..a1edeff8 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/react.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/react.go @@ -56,7 +56,7 @@ func (h *Holder) Work() { } // React takes every step the facts allow, after an event that can change what they are: the forge's open pull requests -// read when a ready mark may have been made, the steps that need another's word (look), what is owed (Flush), and then +// read when a check may have answered, the steps that need another's word (look), what is owed (Flush), and then // the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself. func (h *Holder) React() { h.reactMu.Lock() @@ -70,7 +70,7 @@ func (h *Holder) React() { if h.takeForgeWanted() { if err := h.SeeOpenPulls(); err != nil { h.wantForge() - h.again("the forge's open pull requests, read when a ready mark or a check may have changed") + h.again("the forge's open pull requests, read when a check may have changed") } } h.look() diff --git a/modules/mesh-delivery/cmd/mesh-delivery/table.go b/modules/mesh-delivery/cmd/mesh-delivery/table.go index 08edff7f..8f2da771 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/table.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/table.go @@ -500,7 +500,7 @@ func Apply(d *Delivery, ev Event, act bool, f Facts) (Transition, error) { } by := f.By if by == "" && (ev == EvMerged || ev == EvDone) && d.MergedAs != "" { - // A merge this owner made on a ready mark names it as its author (novox/hq issue 722). + // A merge this owner made on the operator's Approve names it as its author (novox/hq issue 722). by = d.Ready.mergedBy() } t := Transition{At: f.Now, From: d.State, To: r.To, Event: ev, Why: why, By: by} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go b/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go index 92f37462..bbcbf204 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/unchecked.go @@ -12,7 +12,7 @@ import ( // The forge's announcer is the only way the mesh hears of a pull request's head, and on 2026-10-09 it missed one: a // pull request opened after its push moved nothing the announcer read, and waited unchecked, unable to merge, with // nothing saying so. This owner asks the forge itself for every open pull request when it starts and when an event says -// what the forge shows may have changed — a merge check's status, a new head, a ready mark (events.go), not a poll +// what the forge shows may have changed — a merge check's status, a new head (events.go), not a poll // (novox/hq ADR 0406 §1, issue 975) — and once more at a head's bound, and one whose // base requires the merge check — mesh/merge-gate or mesh/repo-check — with neither on its head for UncheckedAfter // since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the