From a00429bc80dbf3e739f45141f4f0c5c7f1066c50 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 11:36:37 +0100 Subject: [PATCH] GIT6, GIT9: merge-pull merges the head the caller names and asks no Approve; it refuses every merge until the first registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge-pull answers its refused-kind (head-moved, unprotected, git-refused, no-registrations); an already merged pull request at that head is answered performed. The failed-steps refusal (policy-failed) is GIT7, stopped on a design question. GIT9: registrations.ts learns the first registrations from the seat mesh-catalog's verb registrations at the start and its event registration-changed; the code artifact needs mesh-delivery-assigner:program. Builds: design 47 G §38, §129, §314, §95, §96, §130, §353, §432 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- modules/gitea/index.ts | 11 +++++-- modules/gitea/module.json | 7 ++++- modules/gitea/registrations.ts | 52 ++++++++++++++++++++++++++++++++ modules/gitea/seat.ts | 22 +++++++++----- modules/gitea/test/guard.test.ts | 43 ++++++++++++++++++++------ 5 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 modules/gitea/registrations.ts diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index 2f927b0c4..37c897c3a 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -42,11 +42,12 @@ import { state } from "@novox/mesh-sdk/state"; import { on } from "@novox/mesh-sdk/events"; import { broker } from "@novox/mesh-sdk/messaging"; import { STATUS_STATE, keepStatus, statusTools, viewWaitsFinding, type Follower, type Status } from "@novox/mesh-sdk/views"; -import type { CatalogRevisionEvent, RevisionInput } from "@novox/mesh-delivery-contracts-mesh-catalog"; +import type { CatalogRevisionEvent, RegistrationChangedEvent, RevisionInput } from "@novox/mesh-delivery-contracts-mesh-catalog"; import { CATALOG_REVISION_EVENT, CatalogView, findingValue, unansweredFinding } from "./catalog.js"; import { announce, keepWatermark, readWatermark } from "./announce.js"; import { SEAT_ONLY, sayOnSeat } from "./seatevents.js"; import { FORGE_CONTAINER, seatTools } from "./seat.js"; +import { REGISTRATION_CHANGED_EVENT, Registrations } from "./registrations.js"; import { run } from "./delivery.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js"; @@ -390,13 +391,15 @@ const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaA // module's state first-views, which gitea_first_views reads; gitea_first_view_again asks it again by hand (mesh-sdk views). const firstViews = state(STATUS_STATE); const followers: Follower[] = []; +// The first registrations switch merge-pull on (registrations.ts; design 47 §95). +const registrations = new Registrations((verb, input) => broker().request(verb, input), (line) => console.log(`[gitea] ${line}`)); // gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). The seat git's verbs are // served here, beside the one writer of a head's statuses: the seat's status takes its turn on the head's chain, after the // announcement's pending and a trunk's move, so a recheck's pending never lands over a later verdict (issue 456). registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...statusTools("gitea", "gitea_first_views", "gitea_first_view_again", followers, firstViews, async (act) => { await broker().request("seat:mesh-hand-acts.hand-act", act); }), - ...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates }) : [])]); + ...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates, registered: () => registrations.written }) : [])]); /** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */ const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise => actOn(event, { @@ -533,6 +536,10 @@ if (gitea) { // What is no catalog-revision event is said and taken: asked again, it would be no more readable. view.hear(event.body).catch((err) => console.error(`[gitea] ${err instanceof Error ? err.message : err}`)))); void view.follower.start().then(() => w.viewed()); + void bindUntilHeld(REGISTRATION_CHANGED_EVENT, () => on(REGISTRATION_CHANGED_EVENT, async (event) => { + try { registrations.hear(event.body); } catch (err) { console.error(`[gitea] ${err instanceof Error ? err.message : err}`); } + })); + void registrations.start(); } catch (err) { console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`); } diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 9af9a0c41..d6bdf1e8a 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -499,6 +499,9 @@ "name": "code", "kind": "bundle", "language": "typescript", + "needs": [ + "mesh-delivery-assigner:program" + ], "entrypoints": [ "index.js", "tools/index.js" @@ -644,10 +647,12 @@ ], "invokes": [ "seat:mesh-catalog.revision", + "seat:mesh-catalog.registrations", "seat:mesh-hand-acts.hand-act", "mesh-delivery-builder.builder_versions" ], "consumes": [ - "mesh-catalog.catalog-revision" + "mesh-catalog.catalog-revision", + "mesh-catalog.registration-changed" ] } diff --git a/modules/gitea/registrations.ts b/modules/gitea/registrations.ts new file mode 100644 index 000000000..82a46d634 --- /dev/null +++ b/modules/gitea/registrations.ts @@ -0,0 +1,52 @@ +// **The first registrations switch the merge on** (novox/hq design 47 §95, §353): until the catalog half has said its first +// `registration-changed`, the seat git's `merge-pull` refuses every merge naming the missing registrations, because a merge +// made with no Approve could reach the old assigner, which writes wants from the trunk with no pipeline. The module learns it +// two ways, both through the seat `mesh-catalog`: its verb `registrations` asked at the start (a restart after the first +// registrations were written), and its event `registration-changed` (the first being written, or heard again from the +// seat's kept stream). Neither is the holder's state or a module named. + +import { + seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS, + type RegistrationChangedEvent, type RegistrationsAnswer, type RegistrationsInput, +} from "@novox/mesh-delivery-contracts-mesh-catalog"; + +/** The seat verb asked, as the manifest's `invokes` names it. */ +export const REGISTRATIONS_VERB = `seat:${CATALOG}.${CATALOG_VERBS.registrations}`; +/** The seat event heard, as the manifest's `consumes` names it. */ +export const REGISTRATION_CHANGED_EVENT = `${CATALOG}.${CATALOG_EVENTS.registrationChanged}`; + +export class Registrations { + private first = false; + constructor(private readonly ask: (verb: string, input: RegistrationsInput) => Promise, + private readonly log: (line: string) => void = () => {}) {} + + /** Whether the first registrations are written: the one fact the merge switches on. */ + get written(): boolean { + return this.first; + } + + /** One `registration-changed`: the first registration written, however many were before this module started. */ + hear(ev: RegistrationChangedEvent | undefined): void { + if (!ev || typeof ev.module !== "string" || ev.module === "") { + throw new Error(`not a ${REGISTRATION_CHANGED_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`); + } + if (!this.first) this.log(`the first registration is written (${ev.module}); merge-pull merges from here`); + this.first = true; + } + + /** The start's first view: any registration held is the first written. A failed ask is said and left to the event. */ + async start(): Promise { + try { + let a: unknown = await this.ask(REGISTRATIONS_VERB, {}); + if (typeof a === "string") a = JSON.parse(a); + const r = (a as RegistrationsAnswer | undefined)?.registrations; + if (!Array.isArray(r)) throw new Error(`${REGISTRATIONS_VERB} answered no registrations: ${JSON.stringify(a ?? null).slice(0, 200)}`); + if (r.length > 0) { + this.first = true; + this.log(`${r.length} registrations are held; merge-pull merges`); + } + } catch (err) { + this.log(`the registrations could not be read: ${err instanceof Error ? err.message : String(err)}; merge-pull waits for registration-changed`); + } + } +} diff --git a/modules/gitea/seat.ts b/modules/gitea/seat.ts index 4fac593f2..33c56dbeb 100644 --- a/modules/gitea/seat.ts +++ b/modules/gitea/seat.ts @@ -55,6 +55,8 @@ export interface SeatDeps { run: Runner; container: string; statuses: HeadStatuses; + /** Whether the first registrations were written (registrations.ts): until then merge-pull refuses every merge (design 47 §95). */ + registered: () => boolean; /** The config value `callers` as the runtime answers it; the runtime's own config read by default. */ callers?: () => Promise; } @@ -277,7 +279,7 @@ export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[] return said.added ? { id, performed: true } : { id, held: true, performed: true }; }), verb(GIT_VERBS.mergePull, "The seat git's merge-pull: an act of the forge bridge, a pull request merged at the head the caller names; it judges no check.", - async (args): Promise => mergePull(gitea, args)), + async (args): Promise => mergePull(gitea, args, deps.registered)), ]; } @@ -291,31 +293,35 @@ function holds(comments: GiteaComment[], id: string): boolean { } /** merge-pull (design 47 §314): only the head the caller names, only into a base that takes no push, no check judged. */ -export async function mergePull(gitea: GiteaClient, args: Record): Promise { +export async function mergePull(gitea: GiteaClient, args: Record, registered: () => boolean): Promise { const id = idOf(args.id); const { owner, repo, full } = repositoryOf(args.repository); const number = numberOf(args.number); const head = commitOf("head", args.head); - const no = (why: string): MergePullAnswer => ({ id, refused: `${full}#${number} is not merged: ${why}` }); + const no = (why: string, kind: NonNullable): MergePullAnswer => + ({ id, refused: `${full}#${number} is not merged: ${why}`, "refused-kind": kind }); + // Until the first registrations are written no merge is made: the old assigner would write a want from the trunk with no + // pipeline (design 47 §95). It names what is missing. + if (!registered()) return no("no module is registered yet: the catalog has said no registration-changed, so no merge is made until the first registrations are written", "no-registrations"); const pull = await gitea.getPullRequest(owner, repo, number); if (pull.merged) { // Already merged at that head: performed (the act said again after its answer was lost); at another, refused. return pull.head_sha === head ? { id, performed: true, held: true, ...(pull.merge_commit_sha ? { "merge-commit": pull.merge_commit_sha } : {}) } - : no(`it was merged at ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`); + : no(`it was merged at ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`, "head-moved"); } - if (pull.state !== "open") return no("it is closed"); + if (pull.state !== "open") return no("it is closed", "git-refused"); if (pull.head_sha !== head) { - return no(`its head is ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`); + return no(`its head is ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`, "head-moved"); } const rule = await gitea.appliedProtection(owner, repo, String(pull.base ?? "")); if (!pushRefused(rule)) { - return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`); + return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`, "unprotected"); } try { await gitea.mergePullRequest(owner, repo, number, "merge", true, head); } catch (err) { const f = failure(err); - if (f["error-kind"] === "refused") return no(`the forge refused the merge: ${f.error}`); + if (f["error-kind"] === "refused") return no(`the forge refused the merge: ${f.error}`, "git-refused"); throw err; } const merged = await gitea.getPullRequest(owner, repo, number); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 833870a15..a066ee62d 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -439,6 +439,7 @@ test("the seat git's verbs judge their caller, and merge-pull merges the Approve const { seatTools, gitCallers } = await import("../dist/seat.js"); const { runAs } = await import("@novox/mesh-sdk/callers"); const bridge = "ace.mesh-delivery-forge-bridge"; + let registered = true; const list = JSON.stringify({ publish: ["mesh-build-go"], "merge-pull": ["mesh-delivery-forge-bridge"], pull: ["mesh-delivery-forge-bridge", "mesh-delivery-gate"], status: ["mesh-delivery-forge-bridge"] }); assert.deepEqual(Object.keys(gitCallers(list)).sort(), ["merge-pull", "pull", "status"], "only the seat git's lines are its"); @@ -456,29 +457,31 @@ test("the seat git's verbs judge their caller, and merge-pull merges the Approve }; const gates = new GateWriter(slow); const seat = seatTools(Object.assign(Object.create(client), { setCommitStatus: slow.setCommitStatus }), - { run: async () => ({ stdout: "", code: 0 }), container: "gitea", callers: async () => list, statuses: gates }); + { run: async () => ({ stdout: "", code: 0 }), container: "gitea", callers: async () => list, statuses: gates, registered: () => registered }); const call = (verb: string, args: Record, caller = bridge) => runAs({ "mesh/caller": caller }, args, () => seat.find((t: any) => t.name === `git.${verb}`)!.run(args)) as Promise; const merge = (extra: Record = {}, caller?: string) => call("merge-pull", { id: "act-1", repository: "novox/mesh-catalog", number: 7, head: REVIEWED, ...extra }, caller); - const rows: { name: string; set: () => void; caller?: string; extra?: Record; want: RegExp | "merged" | "held"; loud?: boolean }[] = [ + const rows: { name: string; set: () => void; caller?: string; extra?: Record; want: RegExp | "merged" | "held"; loud?: boolean; kind?: string }[] = [ + { name: "no registration written yet", set: () => { registered = false; }, want: /no module is registered yet/, kind: "no-registrations" }, { name: "a caller not on the list", set: () => {}, caller: "ace.mesh-delivery-checker", want: /refused for ace.mesh-delivery-checker/ }, { name: "an unproven caller", set: () => {}, caller: "", want: /no caller the tool runner proved/ }, - { name: "a head that moved", set: () => { state.head = MOVED; }, want: /its head is 3c4d5e6f7a8b, not the abc1234def56/ }, - { name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/ }, - { name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/ }, + { name: "a head that moved", set: () => { state.head = MOVED; }, want: /its head is 3c4d5e6f7a8b, not the abc1234def56/, kind: "head-moved" }, + { name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" }, + { name: "a base whose protection was removed after the last branch-moved", set: () => { state.rules = [protectedMain()]; state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" }, + { name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/, kind: "unprotected" }, { name: "a failed check", set: () => { state.statuses = [{ context: "mesh/check", state: "failure", description: "fail" }]; }, want: "merged" }, { name: "no status at all", set: () => { state.statuses = []; }, want: "merged" }, { name: "a check of another trunk", set: () => { state.statuses = judgedAt(MOVED); }, want: "merged" }, { name: "the id said again after the merge", set: () => { state.merged = true; }, want: "held" }, { name: "a pull request merged at another head", set: () => { state.merged = true; state.head = MOVED; }, want: /merged at 3c4d5e6f7a8b, not the abc1234def56/ }, - { name: "a closed pull request", set: () => { state.prState = "closed"; }, want: /it is closed/ }, + { name: "a closed pull request", set: () => { state.prState = "closed"; }, want: /it is closed/, kind: "git-refused" }, { name: "a fast-forward", set: () => { state.parents = [REVIEWED]; }, want: "merged" }, { name: "the merge read back fails", set: () => { state.commitsFail = 502; }, want: "merged" }, { name: "the forge merged another head", set: () => { state.landed = MOVED; }, want: /WAS merged .*the forge merged 3c4d5e6f7a8b, not the abc1234def56/, loud: true }, ]; for (const r of rows) { - state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = []; + registered = true; state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = []; state.rules = [protectedMain()]; state.prState = "open"; state.parents = null; state.commitsFail = 0; state.landed = ""; r.set(); const got = await merge(r.extra, r.caller); @@ -491,6 +494,7 @@ test("the seat git's verbs judge their caller, and merge-pull merges the Approve assert.deepEqual(writes(), [], `${r.name}: nothing merged again`); } else { assert.match(String(got.refused ?? got.error), r.want, r.name); + if (r.kind) assert.equal(got["refused-kind"], r.kind, r.name); assert.deepEqual(writes(), r.loud ? ["POST /repos/novox/mesh-catalog/pulls/7/merge"] : [], `${r.name}: what was merged`); } } @@ -550,11 +554,11 @@ test("every read and act of the seat git answers its contract, and an error is a }; const run = async (_f: string, args: string[]) => args.includes("show") || args.includes("--ref") && !args.includes("append") ? { stdout: noteShown, code: noteShown ? 0 : 1 } : (noteWritten++, { stdout: "", code: 0 }); - const seat = seatTools(forge as any, { run: run as any, container: "gitea", callers: async () => JSON.stringify({}), statuses: { status: async () => {} } }); + const seat = seatTools(forge as any, { run: run as any, container: "gitea", callers: async () => JSON.stringify({}), statuses: { status: async () => {} }, registered: () => true }); const everyone = JSON.stringify(Object.fromEntries(seat.map((t: any) => [t.name.slice(4), ["*"]]))); const call = (verb: string, args: Record, over: Record = {}) => { const t = seat.find((x: any) => x.name === `git.${verb}`)!; - const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", callers: async () => everyone, statuses: { status: async () => {} } }); + const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", callers: async () => everyone, statuses: { status: async () => {} }, registered: () => true }); return runAs({ "mesh/caller": "ace.x" }, args, () => wrapped.find((x: any) => x.name === t.name)!.run(args)) as Promise; }; const rows: [string, string, Record, Record, (a: any) => unknown, unknown][] = [ @@ -598,3 +602,24 @@ test("every read and act of the seat git answers its contract, and an error is a const note = await call("note", { id: "n1", repository: "novox/app", commit: SHA, line: "worded differently" }); assert.deepEqual([note.performed, note.held, noteWritten], [true, true, 0]); }); + +// The first registrations switch merge-pull on (design 47 §95, §353): none held and none heard, it waits; a registration held +// at the start, or the first registration-changed, switches it, and an event that is none is an error. +test("the first registrations are learned from the seat mesh-catalog's verb at the start and from its event", async () => { + const { Registrations } = await import("../dist/registrations.js"); + const said: string[] = []; + const reg = (answer: unknown) => new Registrations(async () => { if (answer instanceof Error) throw answer; return answer; }, (l) => said.push(l)); + const none = reg({ registrations: [] }); + await none.start(); + assert.equal(none.written, false, "an empty answer is no first registration"); + none.hear({ module: "mesh-sdk", moved: ["watch"], version: 1, warrant: "q-1" }); + assert.equal(none.written, true, "the event switches it"); + const held = reg(JSON.stringify({ registrations: [{ module: "mesh-sdk" }] })); + await held.start(); + assert.equal(held.written, true, "a registration held at the start"); + const down = reg(new Error("no responders")); + await down.start(); + assert.equal(down.written, false); + assert.match(said.join("\n"), /could not be read: no responders/, "a failed first view is said, never taken as none silently"); + assert.throws(() => down.hear(undefined as any), /not a mesh-catalog.registration-changed event/); +});