gitea: the merge refusal no longer says how long the last check took; nothing writes that record (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-catalog@7f626147be0c
mesh/delivery-group group fix/consumer-remove-any merged: every member is merged
mesh/check being carried to 080aed68556a on 7f626147be0c | from 7a7db0166ad2, mesh-delivery dd90a26251b9

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
2026-11-04 11:06:48 +01:00
parent 4f67143a78
commit 080aed6855
3 changed files with 8 additions and 43 deletions
+4 -5
View File
@@ -20,7 +20,7 @@ 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";
import { announcementOf, askedWords, outOfDateWords, sameCommit, 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. */
const REVIEWED_HEAD = /^[0-9a-f]{12,64}$/;
@@ -47,7 +47,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
"optionally deleting the source branch afterwards. Refused " +
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
"the trunk's head is asked once, with how long the last check of the repository took: ask again at the same head " +
"the trunk's head is asked once: ask again at the same head " +
"once it passes — the tool never merges by itself.",
input: {
owner: { type: "string", description: "the repository owner" },
@@ -163,7 +163,6 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})`
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
const head = twelve(pull.head_sha), now = twelve(trunkHead);
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
@@ -176,7 +175,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
if (at.onItsWay) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
`${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` +
`asked again; ${took}. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
`asked again. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
`(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`);
}
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
@@ -203,7 +202,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
`${now}, and its check could not be asked: ${err instanceof Error ? err.message : err}. Nothing was merged`);
}
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
`${now}. The check of head ${head} merged into trunk ${now} was asked; ${took}. Ask again at ${head} once both ` +
`${now}. The check of head ${head} merged into trunk ${now} was asked. Ask again at ${head} once both ` +
`statuses pass: this tool never merges by itself (novox/hq ADR 0323). In a busy repository another merge may ` +
`move the trunk again first; this is then refused and asked once more. A check that never answers is asked ` +
`again with mesh-delivery.recheck. Nothing was merged`);
+3 -6
View File
@@ -7,7 +7,7 @@
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -298,18 +298,15 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su
// **The merge tool refuses a verdict whose trunk commit is not the trunk's head, and asks the check itself** (novox/hq
// ADR 0323 §5, issue 341): node-engine #66 merged at 00:54 on a verdict of 00:49 that judged its head alone, after #65
// had moved the trunk. Both commits are named; one check is asked, the announcement a new head makes, and not a second
// while it is on its way; the refusal says how long the last check of the repository took; and once the verdict judges
// while it is on its way; and once the verdict judges
// the trunk's head and passes, the merge goes through — asked again, never by itself.
test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk, and asks the check once", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
const { tookFile } = await import("../dist/trunk.js");
writeFileSync(tookFile(stateDir)!, JSON.stringify({ "novox/mesh-catalog": { took: "4m31s", at: "2026-10-12T14:49:00Z" } }));
state.trunk = MOVED; // another merge moved the trunk after the verdict
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /is not merged: its verdict was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b/);
assert.match(err.message, /check of head abc1234def56 merged into trunk 3c4d5e6f7a8b was asked/);
assert.match(err.message, /the last check of novox\/mesh-catalog took 4m31s/);
assert.match(err.message, /never merges by itself/);
return true;
});
@@ -329,7 +326,7 @@ test("gitea_merge_pull_request refuses a verdict judged against an earlier trunk
assert.equal(state.statuses.find((s) => s.context === "mesh/repo-check")!.state, "pending");
// Asked again while that check is on its way: refused, and nothing is asked a second time.
await assert.rejects(merge(), /already asked and not yet answered .*not asked again; the last check of novox\/mesh-catalog took 4m31s.*mesh-delivery\.recheck/);
await assert.rejects(merge(), /already asked and not yet answered .*not asked again.*mesh-delivery\.recheck/);
assert.equal(state.emitted.length, 1, "no second check while one is on its way");
state.statuses = [{ ...gate, description: "building on ace since 15:02" }, ...state.statuses.slice(1)];
await assert.rejects(merge(), /not asked again/);
+1 -32
View File
@@ -10,7 +10,7 @@
// Nothing is run for it: a recheck, a new head, or the merge tool asks the check again;
// - **the merge tool** (tools/index.ts) compares the trunk commit the head's verdict names with the trunk's head at
// the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a
// new head makes — and says how long the last check of that repository took. It never merges by itself after.
// new head makes — and never merges by itself after.
//
// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
@@ -19,7 +19,6 @@
//
// Pure functions and two small files of the module's own state, so this is tested without a forge.
import { existsSync, readFileSync } from "node:fs";
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
@@ -172,36 +171,6 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru
return changed;
}
// --- how long the last check of a repository took -------------------------------------------------------------
/** How long one repository's last check took, and when it was heard: kept by the watcher, read by the merge tool. */
export interface Took {
took: string;
at: string;
head?: string;
}
/** The file the watcher keeps each repository's last check's duration in, beside its other records. */
export function tookFile(stateDir: string | undefined): string | null {
return stateDir ? `${stateDir.replace(/\/+$/, "")}/checks-took.json` : null;
}
function readTookAll(file: string | null): Record<string, Took> {
if (!file || !existsSync(file)) return {};
try {
return JSON.parse(readFileSync(file, "utf8")) as Record<string, Took>;
} catch {
return {};
}
}
/** How long a repository's last check took, in words for the merge tool's refusal. */
export function tookWords(file: string | null, repository: string): string {
const t = readTookAll(file)[repository.toLowerCase()];
if (!t) return `how long a check of ${repository} takes is not known yet: no verdict of it was heard since this was kept`;
return `the last check of ${repository} took ${t.took} (heard ${t.at})`;
}
// --- the announcement of a head, shared by the watcher and the merge tool ---------------------------------------
/** `pull.updated` for one open pull request's head: what the controller asks the build seat to check (novox/hq to-be