diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts index 0505aeb7..96f6d69a 100644 --- a/modules/gitea/merge.ts +++ b/modules/gitea/merge.ts @@ -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`); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 8b080200..3b93f56e 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -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/); diff --git a/modules/gitea/trunk.ts b/modules/gitea/trunk.ts index 608e5805..c1e2d98d 100644 --- a/modules/gitea/trunk.ts +++ b/modules/gitea/trunk.ts @@ -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 on "), 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 { - if (!file || !existsSync(file)) return {}; - try { - return JSON.parse(readFileSync(file, "utf8")) as Record; - } 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