diff --git a/modules/gitea/README.md b/modules/gitea/README.md index de98b90ed..119e7c92d 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -67,8 +67,8 @@ refuses the commit that would make a branch a rule guards. A rule covers a branc forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob (`*`, `**`, `?`, `[abc]`, `[!abc]`, `{a,b}`, `\` escaping) covers case-sensitively with `/` as separator; a glob the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts` hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the -commit. Nothing about merging changes: a merge still needs an independent review at the head and both -required checks green, through `gitea_merge_pull_request`. +commit. Nothing about merging changes: a merge is the seat git's `merge-pull`, at the head the operator's +Approve names. **A patch too large to type** (novox/hq issue 852: a feature's diff is megabytes) goes as `patch_file`, with `patch_sha256`. The caller writes the patch into the module's spool directory `forge-patches` diff --git a/modules/gitea/announce.ts b/modules/gitea/announce.ts index 19293af54..42fe46fd1 100644 --- a/modules/gitea/announce.ts +++ b/modules/gitea/announce.ts @@ -5,13 +5,12 @@ // the bus's message id (mesh-tools node-tools/internal/bus/bus.go, `nats.MsgId`), and the bus stores a message it has // seen the id of, within its de-duplication window, once. // -// The SDK's `emit` takes no id, so this publishes through the broker with the headers `emit` stamps and the id given. -// It returns to `emit` once the SDK takes an id. +// They are the seat git's events (design 47 *The seat git*), said through the runtime's seat publish with that id. // // The ids: a merge by its merge commit, a head by the head and the trunk commit it was announced against (the check asked // again against a new trunk commit is another announcement), a close by its head. -import { broker } from "@novox/mesh-sdk/messaging"; +import { sayOnSeat } from "./seatevents.js"; import { state as keptState } from "@novox/mesh-sdk/state"; type Said = { owner?: string; repo?: string; number?: number; head_sha?: string; base_sha?: string; merge_commit_sha?: string }; @@ -32,23 +31,11 @@ export function announcementId(type: string, body: unknown): string | undefined } } -/** Announce a merge, a head or a close: published with its message id. */ +/** Announce a merge, a head or a close: said on the seat git (seatevents.ts) with its message id. */ export async function announce(type: string, body: unknown): Promise { const id = announcementId(type, body); if (!id) throw new Error(`${type} is not an announcement with a message id`); - const node = process.env.MESH_NODE ?? "unknown"; - await broker().publish({ - key: type, - node, - body, - headers: { - "x-event-id": id, - "x-source": process.env.MESH_MODULE ?? "unknown", - "x-node": node, - "x-time": new Date().toISOString(), - "content-type": "application/json", - }, - }); + if (!(await sayOnSeat(type, (body ?? {}) as Record, id))) throw new Error(`the seat git says no ${type}`); } // --- the watermark: the one value of the old files that is not the bus's to remember -------------------------------- diff --git a/modules/gitea/catalog.ts b/modules/gitea/catalog.ts new file mode 100644 index 000000000..a45517c5e --- /dev/null +++ b/modules/gitea/catalog.ts @@ -0,0 +1,145 @@ +// **The forge module's view of the catalog half** (novox/hq design 47 *Every part takes a first view, then follows the +// events*, ADR 0656 §6): which repositories build a registered module is the catalog half's word, asked of the seat +// mesh-catalog's verb `revision` through the seat's generated TypeScript contract and then kept from its event +// `catalog-revision`, never the holder's state nor a module named. The picture is mesh-sdk's views Follower, so the +// module's first-view tools (statusTools) read and steer it as every part's. +// +// The readers' rule: a candidate's revision moves nothing; a trunk revision heard that the view does not hold yet is +// waited for (said in the status) and asked, and past mesh-sdk's WAITS_BOUND_MS the module writes view-waits; until the +// first view is held the module acts on nothing (writes.ts grants no write). + +import { Follower, type Answer, type Finding, type FollowerOptions, type Status } from "@novox/mesh-sdk/views"; +import { + seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS, states as CATALOG_STATES, + type CatalogEntry, type CatalogRevisionEvent, type RevisionAnswer, type RevisionInput, +} from "@novox/mesh-delivery-contracts-mesh-catalog"; + +/** The seat verb the view asks, as the manifest's `invokes` names it. */ +export const REVISION_VERB = `seat:${CATALOG}.${CATALOG_VERBS.revision}`; +/** The seat event the view hears, as the manifest's `consumes` names it. */ +export const CATALOG_REVISION_EVENT = `${CATALOG}.${CATALOG_EVENTS.catalogRevision}`; +/** A trunk revision; a candidate's moves no running part (ADR 0656 §6). */ +export const TRUNK = "trunk"; + +/** A revision view is never made again: one fixed creation time lets the follower tell a revision heard from one held. */ +const CREATED = new Date(0).toISOString(); + +/** Asks one seat verb over the bus and answers what the holder said. */ +export type Ask = (verb: string, input: RevisionInput) => Promise; + +/** The hooks a part hands the view: everything of mesh-sdk's FollowerOptions but what the view sets itself. */ +export type CatalogHooks = Omit; + +/** The revision's answer, or the holder's refusal as an error: never an empty view taken for the trunk. */ +export function revisionAnswer(raw: unknown): RevisionAnswer { + let a: unknown = raw; + if (typeof raw === "string") { + try { + a = JSON.parse(raw); + } catch { + throw new Error(`${REVISION_VERB} answered: ${raw.slice(0, 200)}`); + } + } + const r = a as Partial & { error?: unknown }; + if (!r || typeof r !== "object" || typeof r.revision?.revision !== "number" || r.revision.revision <= 0 || !Array.isArray(r.entries)) { + const said = r && typeof r === "object" && typeof r.error === "string" ? r.error : JSON.stringify(a ?? null).slice(0, 200); + throw new Error(`${REVISION_VERB} answered no revision: ${said}`); + } + return r as RevisionAnswer; +} + +export class CatalogView { + readonly follower: Follower; + /** The trunk revision whose pages are being read; 0 between asks. */ + private paging = 0; + /** The trunk revision the view holds whole; 0 before the first view. */ + private heldRevision = 0; + + private readonly ask: Ask; + + constructor(ask: Ask, hooks: CatalogHooks) { + this.ask = ask; + this.follower = new Follower({ ...hooks, owner: CATALOG, tool: REVISION_VERB, state: CATALOG_STATES.catalogRevisions, + ask: (after) => this.page(after) }); + } + + /** The ask the last event began, not waited on by hear: a test awaits it. */ + asked: Promise = Promise.resolve(); + + /** The trunk revision the view holds, 0 before the first view. */ + get held(): number { + return this.heldRevision; + } + + /** One module's entry in the view, as the catalog half says it. */ + entry(module: string): CatalogEntry | undefined { + return this.follower.get(module) as CatalogEntry | undefined; + } + + /** The Follower's ask: the trunk's newest revision, then the next pages of the same one. */ + private async page(after: string): Promise { + const input: RevisionInput = after && this.paging ? { after, revision: this.paging } : {}; + const a = revisionAnswer(await this.ask(REVISION_VERB, input)); + const rev = a.revision.revision; + if (a.revision.kind !== TRUNK) throw new Error(`${REVISION_VERB} answered a ${a.revision.kind} revision for the trunk`); + if (after && this.paging && rev !== this.paging) { + const was = this.paging; + this.paging = 0; + throw new Error(`${REVISION_VERB} answered revision ${rev} for a page of revision ${was}`); + } + this.paging = rev; + const values: Record = {}; + for (const e of a.entries) values[e.module] = e; + if (!a.next) { + this.paging = 0; + this.heldRevision = rev; + const waits = Number(this.follower.status()["waits-for"] ?? 0); + if (waits <= rev) await this.follower.holds(String(rev)); + } + return { v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, revision: rev, created: CREATED, values, + ...(a.next ? { next: a.next } : {}), at: new Date().toISOString() }; + } + + /** + * One `catalog-revision` event. A candidate's moves nothing; a trunk revision the view holds, or an older one, is passed + * over; a newer one is waited for and asked, not waited on here. Data that is no such event is an error. + */ + async hear(ev: CatalogRevisionEvent | undefined): Promise { + if (!ev || typeof ev.revision !== "number" || ev.revision <= 0) { + throw new Error(`not a ${CATALOG_REVISION_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`); + } + if (ev.kind !== TRUNK || ev.revision <= this.heldRevision) return; + const held = this.heldRevision; + // The follower keeps the newest revision heard, so an ask running when it was heard is asked once more. + await this.follower.apply({ v: 1, owner: CATALOG, state: CATALOG_STATES.catalogRevisions, key: CATALOG_REVISION_EVENT, + revision: ev.revision, created: CREATED, gap: true }); + await this.follower.waitFor(String(ev.revision)); + this.asked = this.follower.gap(`catalog revision ${ev.revision} said, the view holds ${held}`); + } +} + +/** The kind of a finding of a first view unanswered past its bound (ADR 0615 §6), as mesh-sdk's Go views writes it. */ +export const UNANSWERED_KIND = "first-view-unanswered"; + +/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */ +export function findingKey(scope: string, id: string, kind: string): string { + return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join("."); +} + +/** A finding as the condition keeper reads it from the module's state `findings`: its key and its value. */ +export function findingValue(f: Finding): { key: string; value: Record } { + const key = findingKey("module", f.id, f.kind); + return { key, value: { + v: 1, key, kind: f.kind, subject: { scope: "module", id: f.id }, severity: "warning", summary: f.summary, headline: f.headline, + explanation: f.explanation, resolved: "", evidence: [{ at: f.at, said: f.summary }], source: `${f.observer}.${f.kind}`, + observer: f.observer, at: f.at, + } }; +} + +/** A part's finding of a first view unanswered past its bound, from the follower's status. */ +export function unansweredFinding(part: string, s: Status, at: Date): Finding { + const summary = `${part} has no first view of ${s.owner}'s ${s.state}: ${s.tool} has not answered since ${s.asked ?? "its start"} (${(s.error ?? "").trim()})`; + return { observer: part, kind: UNANSWERED_KIND, id: `${part}.${s.owner}.${s.state}`, summary, headline: `${part} waits for ${s.owner}`, + explanation: `${part} reads ${s.owner}'s ${s.state} only through ${s.tool}, and keeps asking it; it never reads the state itself. The answer ` + + `clears this. Look at ${s.owner}'s holder: is it running and connected?`, at: at.toISOString() }; +} diff --git a/modules/gitea/check.ts b/modules/gitea/check.ts deleted file mode 100644 index 3af490492..000000000 --- a/modules/gitea/check.ts +++ /dev/null @@ -1,225 +0,0 @@ -// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1 -// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone). -// -// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools) -// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue -// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the -// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module -// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces -// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of -// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is -// written on its head, once per revision; a revision older than one already written on the key is dropped. -// -// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses -// a status is asked again on a bound, until it writes or a newer revision of the key comes. - -import type { CommitStatus } from "./client.js"; -import { bindUntilHeld, bindWait } from "./bind.js"; -import { checkName } from "./guard.js"; -import { CHECK_STATUS } from "./pulls.js"; - -/** The checker's state this module reads, and the act of its keys this module writes. */ -export const CHECKER_OWED = "mesh-delivery-checker.owed"; -export const CHECK_ACT = `status=${CHECK_STATUS}`; - -const states = new Set(["pending", "success", "error", "failure", "warning"]); - -/** What writing the check needs of the forge. */ -export interface CheckForge { - setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; -} - -/** One change of the checker's owed, as the SDK's state watch hands it. */ -export interface OwedChange { - key: string; - op: "put" | "delete"; - value?: unknown; - revision: number; - current?: boolean; -} - -/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */ -function unescapeName(part: string): string { - if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`); - return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8"); -} - -/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey): - * `/...status=mesh/check`, owner and name escaped. */ -export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } { - const parts = String(key ?? "").split("."); - if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, ...`); - const [repository, number, sha, act] = parts; - if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`); - const m = /^([^/]+)\/([^/]+)$/.exec(repository); - if (!m) throw new Error(`${JSON.stringify(key)} names no /`); - if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`); - if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`); - return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha }; -} - -/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */ -export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } { - if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`); - const { owner, repo, sha } = owedKeyOf(change.key); - const revision = change.revision; - if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) { - throw new Error(`${JSON.stringify(revision)} is not a revision`); - } - const v = change.value; - if (!v || typeof v !== "object") throw new Error("the value is no owed act"); - const o = v as Record; - if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`); - if (o.act !== "status" || o.context !== CHECK_STATUS) { - throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`); - } - const state = String(o.state ?? ""); - if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`); - let description = String(o.text ?? "").replace(/\s+/g, " ").trim(); - if (description.length > 140) description = description.slice(0, 139) + "…"; - const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined; - const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description, - ...(target ? { target_url: target } : {}) }; - return { owner, repo, sha, revision, status }; -} - -/** Whether the forge's refusal will not change by asking again: it forbids the write (403) or knows no such repository - * or commit (404), as the client words it ("Gitea API : ..."). */ -export function refusedForGood(err: unknown): boolean { - return /Gitea API \S+: (403|404) /.test(err instanceof Error ? err.message : String(err)); -} - -/** How many mesh/check writes this module has at the forge at once. */ -export const WRITES_AT_ONCE = 4; - -/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */ -export class CheckStatuses { - /** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */ - private written = new Map(); - private seen = new Map(); - /** The turn each key's writes wait on, and the asks again still running. */ - private turns = new Map>(); - private again = new Set>(); - /** Writes at the forge at once, at most WRITES_AT_ONCE: a watch's start hands over every key together (issue 1726). */ - private writing = 0; - private queued: (() => void)[] = []; - private forge: CheckForge; - private say: (line: string) => void; - private sleep: (ms: number) => Promise; - - constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l), - sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms))) { - this.forge = forge; - this.say = say; - this.sleep = sleep; - } - - /** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A - * forge that refuses throws. */ - async apply(change: OwedChange): Promise<"written" | "dropped"> { - let asked; - try { - asked = checkStatusOf(change); - } catch (err) { - // A deleted key is owed no more: its revision is seen, so a write of it still asked again ends (askAgain). - if (change?.op === "delete" && typeof change.key === "string" && Number.isInteger(change.revision)) { - const seen = this.seen.get(change.key); - if (seen === undefined || change.revision > seen) this.remember(this.seen, change.key, change.revision); - return "dropped"; - } - this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`); - return "dropped"; - } - const key = change.key; - const seen = this.seen.get(key); - if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision); - // One key's writes take turns, so two at once are weighed one after the other. - const before = this.turns.get(key) ?? Promise.resolve(); - const turn = before.then(async (): Promise<"written" | "dropped"> => { - const newest = this.written.get(key); - if (newest !== undefined && asked.revision < newest) { - this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`); - return "dropped"; - } - await this.atTheForge(() => this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status)); - this.remember(this.written, key, asked.revision); - return "written"; - }); - const settled = turn.catch(() => {}); - this.turns.set(key, settled); - void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); }); - return turn; - } - - /** What the watch hands over: taken at once and written in the background, or when the forge refuses, asked again on a - * bound until it is written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses - * the change. It never waits on the forge (novox/hq issue 1726): the runtime hands a watch's current values one at a - * time, waits for each to be taken and gives the whole set 10 s; a forge write per key made a large owed outlast that - * bound at every attempt, so the watch never held and no verdict reached mesh/check. Each key's writes still take - * turns in revision order (apply). */ - async take(change: OwedChange): Promise { - const run = this.apply(change).then(() => {}, (err) => this.askAgain(change, err)); - this.again.add(run); - void run.finally(() => this.again.delete(run)); - } - - /** Runs one forge write when fewer than WRITES_AT_ONCE are running, else after one ends. */ - private async atTheForge(write: () => Promise): Promise { - if (this.writing >= WRITES_AT_ONCE) await new Promise((r) => this.queued.push(r)); - else this.writing++; - try { - return await write(); - } finally { - const next = this.queued.shift(); - if (next) next(); - else this.writing--; - } - } - - /** Resolves when no write or ask again is running. */ - async settled(): Promise { - while (this.again.size) await Promise.all([...this.again]); - } - - private async askAgain(change: OwedChange, first: unknown): Promise { - let err = first; - for (let attempt = 1; ; attempt++) { - if (refusedForGood(err)) { - // A repository the forge forbids or does not know answers the same at every ask (the sweep of 2026-10-30, issue - // 774: hal and novadock, 119 asks each): said once, never asked again; a newer revision of the key asks anew. - this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} is refused for good (${err instanceof Error ? err.message : err}); not asked again`); - return; - } - const wait = bindWait(attempt); - this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`); - await this.sleep(wait); - const newest = this.seen.get(change.key); - if (newest !== undefined && newest > change.revision) return; - try { - await this.apply(change); - return; - } catch (e) { - err = e; - } - } - } - - private remember(map: Map, key: string, revision: number): void { - map.delete(key); - map.set(key, revision); - if (map.size > 4000) map.delete(map.keys().next().value as string); - } -} - -/** - * Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch - * refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects. - */ -export async function watchOwed(watch: (handler: (change: OwedChange) => Promise) => Promise, checks: CheckStatuses, - sleep?: (ms: number) => Promise, say: (line: string) => void = (l) => console.error(l)): Promise { - let refusals = 0; - await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => { - if (/could not be bound/.test(line) && refusals++ > 0) return; - say(line); - }); -} diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 8a8079a89..42898d71c 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -481,6 +481,21 @@ export class GiteaClient { return GiteaClient.mapPull(await this.request(`${repoPath(owner, repo)}/pulls/${index}`)); } + /** The pull request a commit was merged by (the forge's own lookup of a merge commit): what the seat git's `pull` + * answers by merge commit. A commit no pull request merged is the forge's 404. */ + async pullOfCommit(owner: string, repo: string, sha: string): Promise { + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error(`${sha} is not a commit's full 40-hex sha`); + return GiteaClient.mapPull(await this.request(`${repoPath(owner, repo)}/commits/${sha}/pull`)); + } + + /** One page of a repository's tags, newest first, by name: what the seat git's `tags` answers. */ + async tags(owner: string, repo: string, page: number, limit: number): Promise { + if (!Number.isInteger(page) || page < 1) throw new Error(`${page} is not a page, from 1`); + const list = await this.request(`${repoPath(owner, repo)}/tags?page=${page}&limit=${limit}`); + if (!Array.isArray(list)) throw new Error(`the forge answered no list of the tags of ${owner}/${repo}`); + return list.map((t) => String(t?.name ?? "")).filter((n) => n !== ""); + } + /** A commit's parents, by sha, in order: what a merge commit says it joined (novox/hq issue 381). An answer * that names no parents is no answer, never an empty list read as a root commit. */ async commitParents(owner: string, repo: string, sha: string): Promise { diff --git a/modules/gitea/cmd/forge/commit.go b/modules/gitea/cmd/forge/commit.go index a2c0245e2..bb911820a 100644 --- a/modules/gitea/cmd/forge/commit.go +++ b/modules/gitea/cmd/forge/commit.go @@ -16,8 +16,7 @@ import ( // gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull // request / approval rule without anyone pushing by hand. By the rule gitea_api already enforces for a file // written (novox/hq ADR 0266): never the repository's default branch, never a branch a protection rule names. -// Nothing about merging changes — that still needs a review at the head and both required checks green -// (gitea_merge_pull_request). +// Nothing about merging changes — a merge is the seat git's merge-pull, at the head the operator's Approve names. // FileChange is one file of a commit: its new content, as text or base64, or its deletion. type FileChange struct { diff --git a/modules/gitea/cmd/forge/main.go b/modules/gitea/cmd/forge/main.go index 89900a64e..fadedecdd 100644 --- a/modules/gitea/cmd/forge/main.go +++ b/modules/gitea/cmd/forge/main.go @@ -49,8 +49,8 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool { Name: "gitea_commit", Description: "Put one commit on a branch of its own in a forge repository, creating the branch from base " + "when it does not exist. Refused on the repository's default branch and on any branch a protection " + - "rule names (novox/hq ADR 0266): a change reaches those only by a pull request whose head an " + - "independent review approved and whose required checks passed (gitea_merge_pull_request). Carries " + + "rule names (novox/hq ADR 0266): a change reaches those only by a pull request merged at the head the " + + "operator's Approve names (the seat git's merge-pull). Carries " + "either files — each {path, content (text) | content_base64 | delete: true | copy_from {owner, repo, ref, path}: a file or directory of another repository, copied by the module}, created or updated as " + "the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " + "At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " + diff --git a/modules/gitea/cmd/package-registry/retention_test.go b/modules/gitea/cmd/package-registry/retention_test.go index 7145bdddf..93d77ace9 100644 --- a/modules/gitea/cmd/package-registry/retention_test.go +++ b/modules/gitea/cmd/package-registry/retention_test.go @@ -487,8 +487,8 @@ func TestToolsAreTheOnesTheReadmeNamesAndSayWhatTheyDo(t *testing.T) { if strings.Contains(string(req), `"name":"transfers","options"`) { t.Errorf("transfers has options (a lifetime): %s", req) } - // The retention asks the builder's versions; the forge's other bundles ask the indexer's modules and record a re-read. - if strings.Join(m.Invokes, ",") != "mesh-delivery-indexer.indexer_read_modules,seat:mesh-hand-acts.hand-act,mesh-delivery-builder.builder_versions" { + // The retention asks the builder's versions; the forge's other bundles ask the catalog half and record a re-read. + if strings.Join(m.Invokes, ",") != "seat:mesh-catalog.revision,seat:mesh-hand-acts.hand-act,mesh-delivery-builder.builder_versions" { t.Errorf("the manifest invokes %v", m.Invokes) } if len(m.Tools) != 0 { diff --git a/modules/gitea/delivery.ts b/modules/gitea/delivery.ts index 050f60678..ed65da6f4 100644 --- a/modules/gitea/delivery.ts +++ b/modules/gitea/delivery.ts @@ -1,7 +1,6 @@ // What the forge's holder does for a delivery (novox/hq ADR 0239): the commit's note under -// refs/notes/mesh-plan, the delivery's view on its pull request, and its statuses — asked by mesh-delivery, -// the delivery's owner, through this module's tools. The forge is this module's; mesh-delivery never writes -// to it itself. +// refs/notes/mesh-plan and the delivery's view on its pull request, asked through the seat git's verbs `note` and +// `view` (seat.ts). The forge is this module's; no delivery part writes to it itself. // // **The note is written in the forge's own repository, as the forge's own user.** The forge's API reads a // note and writes none, and a push needs a clone and a credential nobody else should hold; the forge's @@ -13,13 +12,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { ToolDefinition } from "@novox/mesh-sdk/tools"; -import type { CommitStatus } from "./client.js"; -import { checkName } from "./guard.js"; -import { CHECK_STATUS } from "./pulls.js"; - -export { CHECK_STATUS }; - /** How a command is run: docker on the forge's machine, or a test's. */ export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>; @@ -64,12 +56,13 @@ export function noteArgs(container: string, owner: string, repo: string, commit: /** Append a line to a commit's note, unless the note already holds it. Answers whether it was added. */ export async function appendNote(runner: Runner, container: string, owner: string, repo: string, commit: string, - notesRef: string, line: string): Promise<{ added: boolean; lines: number }> { + notesRef: string, line: string, heldId?: string): Promise<{ added: boolean; lines: number }> { const wanted = noteLine(line); const shown = await runner("docker", noteArgs(container, owner, repo, commit, notesRef)); // No note yet is git's exit 1 with nothing on stdout; anything else unreadable is said. const lines = shown.code === 0 ? shown.stdout.split("\n").filter((l) => l.trim() !== "") : []; - if (lines.includes(wanted)) return { added: false, lines: lines.length }; + // A line that carries the act's id is the act already done, whatever else it says. + if (lines.includes(wanted) || (heldId && lines.some((l) => l.includes(`[${heldId}]`)))) return { added: false, lines: lines.length }; const appended = await runner("docker", noteArgs(container, owner, repo, commit, notesRef, wanted)); if (appended.code !== 0) throw new Error(`the note on ${commit.slice(0, 8)} could not be appended (git exited ${appended.code})`); return { added: true, lines: lines.length + 1 }; @@ -89,77 +82,3 @@ export function viewComment(comments: T[ return comments.find((c) => c.body.includes(VIEW_MARKER)); } -const states = new Set(["pending", "success", "error", "failure", "warning"]); - -/** The merge check's contexts (pulls.ts): a branch's protection may require them. */ -const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]); - - -/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a - * bounded description. */ -export function deliveryStatus(context: string, state: string, description: string, target?: string) { - if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`); - if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`); - // mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any - // state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1): - // check.ts writes it from the checker's owed state (ADR 0521). - if (context === CHECK_STATUS) { - throw new Error(`${CHECK_STATUS} is the checker's status: it is written from mesh-delivery-checker.owed alone, never by a tool`); - } - // The merge check's statuses are its verdict's, set when the controller says it, and a branch may require - // them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq - // issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes, - // and what a recheck of the same head says, so the old verdict does not let it merge while the fresh check - // runs. - if (mergeCheckContexts.has(context) && state !== "pending") { - throw new Error(`${context} is the merge check's status, set by its verdict only (pending, for a check asked again, is the one state it takes by hand)`); - } - let d = String(description ?? "").replace(/\s+/g, " ").trim(); - if (d.length > 140) d = d.slice(0, 139) + "…"; - return { state: state as "pending" | "success" | "error" | "failure" | "warning", context, description: d, - ...(target && /^https?:\/\//.test(target) ? { target_url: target } : {}) }; -} - -/** What the status tool writes with: the forge, and the gate writer the merge check's statuses go through. */ -export interface StatusWriters { - setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; - gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise }; -} - -/** - * The `gitea_commit_status` tool. **The merge check's pending goes through the module's one writer** (novox/hq issue - * 456): a recheck's pending on mesh/merge-gate or mesh/repo-check takes its turn on the head's chain (gate.ts), so an - * old run's verdict in flight lands before it and a redelivered one is dropped. Served from the watcher's bundle - * (index.ts), the process that holds that writer: each bundle the runtime serves is its own process. The mesh's other - * statuses (mesh/delivery, mesh/delivery-group) are written as they were. - */ -export function commitStatusTool(writers: StatusWriters): ToolDefinition { - return { - name: "gitea_commit_status", - description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: no tool writes it: it is written from the checker's own state. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - sha: { type: "string", description: "the commit" }, - context: { type: "string", description: "the status's name, mesh/…" }, - state: { type: "string", description: "pending | success | error | failure | warning" }, - description: { type: "string", description: "one line" }, - target_url: { type: "string", description: "the page it links to (the pull request)" }, - }, - run: async (args) => { - const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? ""); - const status = deliveryStatus(String(args.context), String(args.state), String(args.description ?? ""), - args.target_url ? String(args.target_url) : undefined); - if (mergeCheckContexts.has(status.context)) { - // The writer keeps a head by its whole sha, as the forge announced it: a short one would be another head. - if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { - throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: the merge check's status names the head whole. Nothing was sent`); - } - await writers.gates.rechecked(owner, repo, sha, status); - } else { - await writers.setCommitStatus(owner, repo, sha, status); - } - return { set: status }; - }, - }; -} diff --git a/modules/gitea/gate.ts b/modules/gitea/gate.ts index e1c36bb56..e738bc70e 100644 --- a/modules/gitea/gate.ts +++ b/modules/gitea/gate.ts @@ -73,11 +73,11 @@ export class GateWriter { } /** - * A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): it takes its turn after every - * write already asked for, so it lands over them. Any other status of the merge check takes its turn on the same - * chain. A write that fails fails the caller. + * A status the seat git's `status` verb sets (seat.ts), a recheck's pending among them (novox/hq issue 456): it takes + * its turn after every write of the head already asked for, so it lands over them, and a later write lands over it. A + * write that fails fails the caller. */ - rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise { + status(owner: string, repo: string, sha: string, status: CommitStatus): Promise { return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status)); } diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts index e285ab907..372f112ad 100644 --- a/modules/gitea/guard.ts +++ b/modules/gitea/guard.ts @@ -14,9 +14,8 @@ // the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5); // - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that // exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422); -// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed -// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only -// that head; +// - the seat git's `merge-pull` (seat.ts) merges only into a branch that refuses its push, and only the head the +// operator's Approve names (ADR 0661); // - `gitea_delete_repo` refuses a repository any protection rule guards. // // Pure, so it is tested without a forge; the tools do the asking (tools/index.ts). @@ -165,7 +164,7 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap `writes — issues, comments, labels, pull requests (never their merge), branches, and files on a branch ` + `that is not the default branch and that no protection rule names. The forge's administration, a ` + `branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` + - `are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` + + `are the operator's at the forge, or have their own tool (the seat git's merge-pull and status, ` + `gitea_branch_protection_set, which never lets a person push). Nothing was sent`, }; } diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index bb15ebc64..2f927b0c4 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -13,9 +13,8 @@ // statuses, `mesh/merge-gate` and `mesh/repo-check`, are written for no base that main requires (issue 1301); main // requires `mesh/check`, the checker's, everywhere, hq included since ADR 0527 §2. // -// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and -// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands -// over the latest value of every key whenever this module starts. +// It reads no other module's state: mesh/check, and every act of a delivery part on the forge, arrive as verbs of the seat +// git (seat.ts), asked by the forge bridge (novox/hq design 47 *The seat git*). // // This module is the one writer of mesh/merge-gate. It hears no build seat's event: since R4g a check's steps are asks // on the build seats' streams, whose backlog the delivery view shows (novox/hq ADR 0525 §9; issue 1887). @@ -25,9 +24,6 @@ // request into it go back to pending with the reason. Nothing is run for it. A verdict that lands judged against a trunk // commit that is no longer the trunk's head is said out of date in place of its pass or failure. // -// Serves gitea_commit_status and gitea_merge_pull_request from this bundle, not the tools', so a recheck's pending and -// the merge tool's ask of a check reach that one writer (novox/hq issue 456, ADR 0323). -// // Every pull request the forge holds is announced, whatever its repository: the controller holds the // module graph and decides what is checked — a repository is never asked to opt in. // @@ -42,14 +38,16 @@ import { emit } from "@novox/mesh-sdk/events"; import { registerModuleTools } from "@novox/mesh-sdk/tools"; import { statesAnnouncement } from "./kept.js"; -import { commitStatusTool } from "./delivery.js"; -import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check.js"; import { state } from "@novox/mesh-sdk/state"; import { on } from "@novox/mesh-sdk/events"; import { broker } from "@novox/mesh-sdk/messaging"; -import { ViewFollower, unansweredFinding, viewTools, type ViewAnswer, type ViewEvent } from "./views.js"; +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 { CATALOG_REVISION_EVENT, CatalogView, findingValue, unansweredFinding } from "./catalog.js"; import { announce, keepWatermark, readWatermark } from "./announce.js"; -import { mergeTool } from "./merge.js"; +import { SEAT_ONLY, sayOnSeat } from "./seatevents.js"; +import { FORGE_CONTAINER, seatTools } from "./seat.js"; +import { run } from "./delivery.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, headKey, requiredOf, writesGate, type Announced } from "./pulls.js"; @@ -60,7 +58,7 @@ import { keepSiblings, readSiblings, siblingsFile, weighSiblings } from "./sibli import { bindInBackground, bindUntilHeld } from "./bind.js"; import { announcementOf, weighTrunks } from "./trunk.js"; import { ReadAll, readAllTools } from "./readall.js"; -import { DeliveredWrites, INDEXER_MODULES_EVENTS, INDEXER_MODULES_TOOL, INDEXER_OWNER, writeTools, type ModulesChange } from "./writes.js"; +import { DeliveredWrites, writeTools } from "./writes.js"; import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js"; import { PACKAGES_TOKEN_REFUSED, keepPackagesToken, packagesToken, renewOnRefusal } from "./packages-account.js"; @@ -363,14 +361,6 @@ const gates = new GateWriter({ headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha), }); -// The one writer of mesh/check, from the checker's owed (check.ts; ADR 0521). -const checks = new CheckStatuses({ - setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), -}); - -// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own -// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to -// reach, nothing is served, as the tools' bundle serves nothing then. /** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what * gitea_events says of them (webhook.ts). */ const pace = new Pace(); @@ -391,19 +381,22 @@ let readAllOnce: ReadAll | null = null; const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT))); // **The merging account writes every repository that builds a registered module** (writes.ts; the operator, -// 2026-10-26; issue 1945): which those are is the indexer's state, watched below. Made on first use, as readAll. +// 2026-10-26; issue 1945): which those are is the catalog half's word, followed below. Made on first use, as readAll. let writesOnce: DeliveredWrites | null = null; const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(), - process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body))); + process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), async (type, body) => { await sayOnSeat(type, body as Record); })); -// The first view of the indexer's modules, once the module follows it (below); gitea_first_views says where it stands. -let modulesView: { follower: ViewFollower; owner: string; tool: string; state: string } | undefined; +// The first view of the catalog half (catalog.ts), made once the module follows it (below). Its status is kept in the +// 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[] = []; -// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). -registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...viewTools(() => modulesView, (act) => broker().request("seat:mesh-hand-acts.hand-act", act)), ...(gitea ? [commitStatusTool({ - setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), - gates, -}), mergeTool(gitea, { gates })] : [])]); +// 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 }) : [])]); /** 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, { @@ -434,7 +427,11 @@ async function listenForForge(client: GiteaClient): Promise { const host = process.env.MESH_GITEA_HOOK_HOST ?? "0.0.0.0"; const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery), refused: (reason, from) => pace.refused(reason, from), - publish: (event) => emit(event.name, event.body), + // The seat git's forge events are said on the seat (seatevents.ts); the forge's others stay the module's own. + publish: async (event) => { + if (!SEAT_ONLY.has(event.name)) await emit(event.name, event.body); + await sayOnSeat(event.name, event.body as Record); + }, act: async (event) => { // A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts). await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) => @@ -493,7 +490,7 @@ if (gitea) { // polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s. // **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle // whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle - // of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below + // of the node starts left gitea_events served nowhere. The pass below // runs again once it is listening, so nothing said before is missed; listenForForge says its own failure. const listenOk = listenForForge(client); // The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet @@ -513,26 +510,29 @@ if (gitea) { } catch (err) { console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`); } - // The indexer's modules, watched: the watch hands every key's latest value at its start and then each change, so a - // repository that builds a registered module is made writable at the start and when its first module is registered - // (writes.ts). A read not granted yet is said and asked again until it holds. + // The catalog half, followed: its first view hands every registered module's entry at the start and each newer trunk + // revision what changed, so a repository that builds a registered module is made writable at the start and when its + // first module is registered (writes.ts); a candidate's revision moves nothing, and nothing is granted before the first view. try { const w = writes(); - // ADR 0615: asked once of the indexer's read tool, then kept by its events; the indexer's state itself is not read. - const modules = new ViewFollower({ owner: INDEXER_OWNER, state: "modules", tool: INDEXER_MODULES_TOOL, - ask: (after) => broker().request(INDEXER_MODULES_TOOL, after ? { values: true, after } : { values: true }) as Promise, - take: (change) => w.take(change as ModulesChange), - // Unanswered past its bound is a finding of the module's own, which the condition keeper raises (ADR 0615 §6). - raise: async (asked, error) => { - const f = unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, asked, error, new Date()); - await state("findings").put(f.key, f.value); - }, - clear: async () => { - await state("findings").delete(unansweredFinding("gitea", INDEXER_OWNER, "modules", INDEXER_MODULES_TOOL, new Date(), "", new Date()).key); - } }); - void bindUntilHeld(INDEXER_MODULES_EVENTS, () => on(INDEXER_MODULES_EVENTS, (event) => modules.hear(event.body))); - modulesView = { follower: modules, owner: INDEXER_OWNER, tool: INDEXER_MODULES_TOOL, state: "modules" }; - void modules.start(); + const findings = state("findings"); + const put = async (f: { key: string; value: unknown }) => { await findings.put(f.key, f.value); }; + const view = new CatalogView((verb, input: RevisionInput) => broker().request(verb, input), { + said: keepStatus(firstViews), + changed: (key, value, deleted) => w.take({ key, op: deleted ? "delete" : "put", value }), + // Unanswered past its bound, or a revision waited for past mesh-sdk's WAITS_BOUND_MS, is a finding of the module's + // own, which the condition keeper raises (ADR 0615 §6; design 47 *A part with no view says it waits*). + raise: (s) => put(findingValue(unansweredFinding("gitea", s, new Date()))), + clear: async (s) => { await findings.delete(findingValue(unansweredFinding("gitea", s, new Date())).key); }, + raiseWaits: (s) => put(findingValue(viewWaitsFinding("gitea", s, new Date()))), + clearWaits: async (s) => { await findings.delete(findingValue(viewWaitsFinding("gitea", s, new Date())).key); }, + log: (line) => console.log(`[gitea] ${line}`), + }); + followers.push(view.follower); + void bindUntilHeld(CATALOG_REVISION_EVENT, () => on(CATALOG_REVISION_EVENT, (event) => + // 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()); } 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}`); } @@ -570,7 +570,4 @@ if (gitea) { // No consumer is bound since ADR 0571 clause 13 took the controller's checked out of this module; a consumer added later // is bound again until it holds, and not awaited (bind.ts, issue 1793). bindInBackground([], () => console.log("[gitea] watching for new repositories and merged pull requests")); - // The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again, - // apart from the binds above, so neither waits for the other. - void watchOwed((handler) => state(CHECKER_OWED).watch((change) => handler(change)), checks); } diff --git a/modules/gitea/internal/packageseats/holder.go b/modules/gitea/internal/packageseats/holder.go index ea56b67f0..70c98c080 100644 --- a/modules/gitea/internal/packageseats/holder.go +++ b/modules/gitea/internal/packageseats/holder.go @@ -7,9 +7,11 @@ package packageseats import ( + "encoding/json" "errors" "fmt" "strings" + "sync" "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" @@ -77,13 +79,91 @@ func DefaultCallers() Callers { VerbVersions: {stdio.AnyCaller}} } -// configured holds the config value `callers`: asked of the runtime once, by a watch, and replaced on each change of -// the value, so a call never asks (issue 1966). -var configured = stdio.HoldVerbCallers("callers", Verbs, DefaultCallers()) - -// ConfiguredCallers is the config value `callers` as mesh-config resolved it, held from its watch; unset is DefaultCallers. +// ConfiguredCallers is the config value `callers` as mesh-config resolved it, its lines of the package seats' verbs; unset, +// or with none of them, is DefaultCallers. It is held from a watch of the value and replaced on each change, so a call +// never asks the runtime (issue 1966). The same value carries the lines of the other seat the module holds (git, served +// by its TypeScript side), so each holder takes its own verbs' lines and neither refuses the other's. func ConfiguredCallers() (Callers, error) { return configured() } +var ( + watchMu sync.Mutex + heldMu sync.Mutex + heldList Callers + heldBad error +) + +// configured reads the held list; the first call starts the watch, and a change that cannot be read keeps the list last +// read, said and never widened. +func configured() (Callers, error) { + if l, err := read(); l != nil || err != nil { + return l, err + } + watchMu.Lock() + defer watchMu.Unlock() + if l, err := read(); l != nil || err != nil { + return l, err + } + if err := stdio.WatchConfig("callers", func(c stdio.ProvisionChange) error { + heldMu.Lock() + defer heldMu.Unlock() + if c.Refused != "" { + heldBad = fmt.Errorf("the config value callers is not answered: %s", c.Refused) + return nil + } + l, err := OwnCallers(c.Value) + if err != nil { + heldBad = fmt.Errorf("the config value callers cannot be read: %w", err) + return nil + } + heldList, heldBad = l, nil + return nil + }); err != nil { + return nil, fmt.Errorf("the config value callers cannot be read: %w", err) + } + l, err := read() + if l == nil && err == nil { + err = errors.New("the config value callers cannot be read: the runtime handed over no value") + } + return l, err +} + +func read() (Callers, error) { + heldMu.Lock() + defer heldMu.Unlock() + if heldList != nil { + return heldList, nil + } + return nil, heldBad +} + +// OwnCallers reads the package seats' lines of the config value as the runtime answered it: a JSON object of verb to +// callers, or that object written as a string. A value that cannot be read is an error, never the defaults. +func OwnCallers(raw json.RawMessage) (Callers, error) { + text := strings.TrimSpace(string(raw)) + var quoted string + if json.Unmarshal(raw, "ed) == nil { + text = strings.TrimSpace(quoted) + } + if text == "" || text == "null" { + return DefaultCallers(), nil + } + var all map[string]json.RawMessage + if err := json.Unmarshal([]byte(text), &all); err != nil { + return nil, fmt.Errorf("callers is not a JSON object of verb to callers: %w", err) + } + own := map[string]json.RawMessage{} + for _, verb := range Verbs { + if line, ok := all[verb]; ok { + own[verb] = line + } + } + if len(own) == 0 { + return DefaultCallers(), nil + } + b, _ := json.Marshal(own) + return stdio.ParseVerbCallers(string(b), Verbs, DefaultCallers()) +} + // Publish is a publish as the seats' contract names it (mesh-contracts npmpackageregistry and gopackageregistry share // one shape): what the tools bundle reads from either seat's PublishInput. type Publish struct { diff --git a/modules/gitea/internal/packageseats/holder_test.go b/modules/gitea/internal/packageseats/holder_test.go index e8e9bdc14..91e062cdc 100644 --- a/modules/gitea/internal/packageseats/holder_test.go +++ b/modules/gitea/internal/packageseats/holder_test.go @@ -303,4 +303,30 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) { t.Errorf("removed %v, said %+v", b.forge.removed, b.said) } }) + + t.Run("the callers value: its own verbs' lines, the other seat's left to it, unset or none of its own the defaults", func(t *testing.T) { + manifest := `"{\"publish\":[\"mesh-build-go\"],\"remove\":[\"mesh-delivery-builder\"],\"pull\":[\"mesh-delivery-gate\"]}"` + for _, row := range []struct { + raw string + publish []string + fails bool + }{ + {raw: manifest, publish: []string{"mesh-build-go"}}, + {raw: `{"pull":["mesh-delivery-gate"]}`, publish: DefaultCallers()[VerbPublish]}, + {raw: `""`, publish: DefaultCallers()[VerbPublish]}, + {raw: `{"publish":"mesh-build-go"}`, fails: true}, + {raw: `"not json"`, fails: true}, + } { + got, err := OwnCallers([]byte(row.raw)) + if row.fails != (err != nil) { + t.Fatalf("%s: err %v", row.raw, err) + } + if !row.fails && strings.Join(got[VerbPublish], ",") != strings.Join(row.publish, ",") { + t.Errorf("%s: publish %v, want %v", row.raw, got[VerbPublish], row.publish) + } + if _, theirs := got["pull"]; theirs { + t.Errorf("%s: the seat git's line is not the package seats'", row.raw) + } + } + }) } diff --git a/modules/gitea/kept.ts b/modules/gitea/kept.ts index a591b88f0..eb170dfa7 100644 --- a/modules/gitea/kept.ts +++ b/modules/gitea/kept.ts @@ -7,6 +7,7 @@ export const KEPT: readonly { readonly name: string; readonly readable?: boolean { name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }, + { name: "first-views" }, ]; /** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1, diff --git a/modules/gitea/mainrules.ts b/modules/gitea/mainrules.ts index 6137559e4..e8ac249f7 100644 --- a/modules/gitea/mainrules.ts +++ b/modules/gitea/mainrules.ts @@ -15,19 +15,6 @@ 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)["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, say: (line: string) => void): () => Promise { let said = false; diff --git a/modules/gitea/merge.ts b/modules/gitea/merge.ts deleted file mode 100644 index 96f6d69a2..000000000 --- a/modules/gitea/merge.ts +++ /dev/null @@ -1,279 +0,0 @@ -// The forge's merge tool, `gitea_merge_pull_request` (novox/hq ADR 0266, issue 381, ADR 0323). -// -// **Served from the watcher's bundle, beside the merge check's one writer** (index.ts, gate.ts; novox/hq issue 456): -// when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and -// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked. -// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain. -// -// **It judges mesh/check whether or not the base requires it** (issue 1301; ADR 0447 §1, §4; ADR 0534 §4): the checker's -// verdict, whose line names the trunk it judged ("pass on "), must be a pass against the trunk's head. -// The forge requires no status to merge (ADR 0534 §1); where a base still requires one of the controller's old statuses, -// until the forge's holder takes it off, that status judges, as before. A mesh/check of another trunk is refused and -// nothing is asked: the checker sets it out of date on the trunk's move and checks it again. Where the base requires no -// status, only a head the operator's Approve names (approved_heads) merges (ADR 0534 §4, ADR 0421 §9). - -import { announce } from "./announce.js"; -import type { ToolDefinition } from "@novox/mesh-sdk/tools"; -import type { CommitStatus, GiteaClient } from "./client.js"; -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, 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}$/; - -/** What the merge tool reaches beside the forge: the merge check's one writer, how an event is said, the module's - * state directory, and the clock. */ -export interface MergeDeps { - gates: { rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise }; - emit?: (type: string, body: unknown) => Promise; - 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()), 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, " + - "its base refuses this account's push, its mesh/check passed judged against the trunk's head as it is now, and, " + - "where the base requires no status (novox/hq ADR 0534), the operator's Approve names it in approved_heads; " + - "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: ask again at the same head " + - "once it passes — the tool never merges by itself.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - number: { type: "number", description: "the PR number" }, - head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 12 characters: " + - "refused unless it is the pull request's head" }, - method: { type: "string", description: "merge (default) | fast-forward-only | squash | rebase | rebase-merge. " + - "Only merge and fast-forward-only can be read back to prove the head you named is what landed" }, - delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" }, - approved_heads: { type: "array", items: { type: "string" }, description: "the heads the operator's Approve covers, " + - "each owner/repo@, one per member of the group (novox/hq ADR 0421 §9): refused unless this pull " + - "request's repository is named at the head it has now. Required when the pull request's verdict read siblings, " + - "where each sibling's head must be named too; there the Approve takes the place of an approving review" }, - }, - run: async (args) => { - const owner = checkName("owner", args.owner); - const repo = checkName("name", args.repo); - const number = Number(args.number); - const method = args.method ? String(args.method) : "merge"; - if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`); - const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch); - // **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here as well as by the - // forge, which holds the tools' own account to it (novox/hq ADR 0365 §2). The protection is the one the forge - // applies to the base, read from the branch: the rules' own list is a repository administrator's. A rule that - // cannot be read is no rule passed. - // **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d, - // force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the - // head was and nothing told it which head the review had approved. The caller names it; a head that moved - // since is refused, and the forge is handed the same head, so a push between this read and the merge is - // refused by the forge too. - const named = args.head === undefined ? "" : String(args.head).trim().toLowerCase(); - if (!named) { - throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` + - `that commit can land (novox/hq issue 381). Nothing was merged`); - } - // Twelve characters, not seven: an author who force-pushes can make a commit whose first seven match the - // reviewed one in seconds, and the review would then merge it. Twelve cannot be matched that way. - if (!REVIEWED_HEAD.test(named)) { - throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} does not name one commit — give its ` + - `sha, or at least its first 12 characters. Nothing was merged`); - } - const pull = await gitea.getPullRequest(owner, repo, number); - if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`); - if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`); - if (!pull.head_sha.toLowerCase().startsWith(named)) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` + - `${named} you named. If you reviewed an earlier head, review the one it has now, then name it. Nothing was merged`); - } - // **Only a head the operator's Approve covers** (novox/hq ADR 0421 §9): mesh-delivery hands the heads its Approve - // named; a pull request whose head differs is refused, so a push between the Approve and this call merges nothing. - const approved = approvedHeadsOf(args.approved_heads); - if (approved) { - const why = notApproved(approved, `${owner}/${repo}`, number, String(pull.head_sha)); - if (why) throw new Refused(`${owner}/${repo}#${number} is not merged: ${why}. A merge lands the heads the operator ` + - `approved (novox/hq ADR 0421). Nothing was merged`); - } - const rule = await gitea.appliedProtection(owner, repo, pull.base); - // Only into a branch whose protection refuses this account's push: a pull request whose base was moved to a - // branch nothing guards would land where anyone pushes (novox/hq ADR 0266, ADR 0534 §4). - if (!rule || rule.enable_push !== false) { - throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule refusing ` + - `a push guards: a tool merges only into such a branch (novox/hq ADR 0266, ADR 0534). Nothing was merged`); - } - // **The verdict must be judged against the trunk's head** (novox/hq ADR 0323 §5, issue 341): a check judges the - // head merged into a named trunk commit, and a merge of another trunk is not the one that check judged. Compared - // here at the moment the merge is asked, not left to the statuses the trunk's move resets a pass later. When they - // differ nothing is merged, and the head's check against the trunk's head is asked once — the announcement a new - // head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes. - const trunkHead = await gitea.branchHead(owner, repo, pull.base); - const checks = await gitea.headChecks(owner, repo, pull.head_sha); - const required = requiredOf(rule) ?? []; - // **The forge requires no status; the operator's Approve and mesh/check do** (ADR 0534 §4): where the base requires - // no status, only a head the Approve names merges, and mesh/check judges it as where it was required. - if (required.length === 0 && !approved) { - throw new Refused(`${owner}/${repo}#${number} is not merged: ${pull.base} requires no status, so a merge into it ` + - `lands only a head the operator's Approve names: give approved_heads with ${owner}/${repo}@ ` + - `(novox/hq ADR 0534, ADR 0421 §9). Nothing was merged`); - } - const kept = maintained(required); - const byCheck = required.includes(CHECK_STATUS) || kept.length === 0; - // **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]; - const at = verdictAtTrunk(checks, trunkHead, CHECK_STATUS); - if (!check || check.state === "pending") { - throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ` + - `${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` + - `${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`); - } - if (check.state !== "success") { - throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ${check.state} ` + - `(${check.description ?? ""}). A pull request merges once its check passes (novox/hq ADR 0534). Nothing was merged`); - } - if (!at.current) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` + - `${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` + - `${pull.base} is now ${twelve(trunkHead)}. The checker checks it again against the trunk's head (novox/hq ` + - `ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`); - } - } - // Where the base still requires the controller's status (mesh/merge-gate, only on a - // revert), the first it requires judges, as before (ADR 0323 §5). - const judge = kept[0] ?? CHECK_CONTEXT; - const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false }; - if (!at.current) { - // A verdict put out of date by a sibling's move says so in its own words (ADR 0382 §5); it is asked again as a - // trunk's move is, and a failed ask puts those words back, not the trunk's. - 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 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 - // refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited. - if (waitsForComposed(checks[judge])) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[judge]?.description}; the ` + - `verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` + - `(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`); - } - 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. 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 - // announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather - // than wait for ever on a check nobody asked. - // The statuses the base requires of the controller's (issue 1301): the one that judges, and the repository's own - // check beside it where a revert has the base require both. - const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT])); - // Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new - // check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older. - const say = async (description: string) => { - for (const context of contexts) { - await deps.gates.rechecked(owner, repo, String(pull.head_sha), { state: "pending", context, description, - ...(pull.html_url ? { target_url: pull.html_url } : {}) }); - } - }; - await say(askedWords(deps.now(), trunkHead)); - try { - const cloneURL = (await gitea.getRepo(owner, repo)).clone_url; - await deps.emit("pull.updated", await announcementOf(gitea, owner, repo, cloneURL, pull)); - } catch (err) { - await say(siblingWords ?? outOfDateWords(at.judged, trunkHead)).catch(() => {}); - throw new Error(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` + - `${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. 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`); - } - const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses); - const verdict = mergeableUnder(pull.base, rule, statuses); - if (verdict.mergeable !== true) { - throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` + - `its checks succeed; no tool merges past them (novox/hq ADR 0266)`); - } - // **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6): the - // siblings its verdict names are the holder's record (siblings.ts); a status that says it read siblings with no record - // of them is no verdict to merge on, never read as none. - // Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the - // Approve's heads above; a record the controller's verdicts left before the switch judges nothing there. - const heard = byCheck ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)]; - const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : []; - if (!siblings.length && !byCheck && - namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` + - `here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`); - } - if (siblings.length) { - const missing = await groupRefusal({ headChecks: (o, r, sha) => gitea.headChecks(o, r, sha) }, - { owner, repo, number, head: String(pull.head_sha) }, siblings, approved); - if (missing) { - throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict was judged beside ${siblings.length} ` + - `sibling(s), and ${missing}. A member judged beside its siblings merges once its group passed at the heads that ` + - `verdict read and the operator's Approve covers each of them (novox/hq ADR 0382, 0421). Nothing was merged`); - } - } - await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha); - // The merge commit only exists now; answered so the caller can follow what is built from it. - // pull.merged is the events entrypoint's to announce (index.ts), once, within its poll. - // From here the merge has happened. Nothing below may read as if it had not: a read-back that fails or cannot - // be done is said beside merged: true, and only a proven other head is an error — one that says the merge happened. - const answer: Record = { merged: true, number, method, head: pull.head_sha, deleted_branch: deleteBranch }; - let landed: string | undefined; - try { - const merged = await gitea.getPullRequest(owner, repo, number); - answer.merge_commit_sha = merged.merge_commit_sha; - // What landed, read back from the commit the merge made rather than from the forge's record of the pull - // request, which is the very thing a race would leave behind. A merge commit's second parent is the head it - // joined; a fast-forward lands the head itself. A squash or a rebase makes new commits that name no head. - if (!merged.merge_commit_sha) { - answer.read_back = "the forge named no merge commit, so what landed cannot be read back"; - } else if (method === "merge") { - const parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha); - if (parents.length === 2) landed = parents[1]; - else answer.read_back = `the merge commit has ${parents.length} parent(s), not two: what landed cannot be read back`; - } else if (method === "fast-forward-only") { - // That the forge names the head itself as a fast-forward's merge commit is assumed, not seen on this forge: - // a different sha is said, not raised as another head having landed. - if (merged.merge_commit_sha === pull.head_sha) landed = merged.merge_commit_sha; - else answer.read_back = `the forge named ${merged.merge_commit_sha.slice(0, 12)} as this fast-forward's commit, ` + - `not the head: that the head landed could not be confirmed`; - } else { - answer.read_back = `a ${method} makes new commits that name no head: what landed cannot be read back`; - } - } catch (err) { - answer.read_back = `the merge happened, but reading back what landed failed: ${(err as Error).message}`; - } - // Loud, not a field beside merged: true that a caller reading only that would miss. The merge cannot be taken - // back by this tool; what must not happen is that it reads as the merge that was asked for. - if (landed !== undefined && landed !== pull.head_sha) { - throw new Error(`${owner}/${repo}#${number} WAS merged (${answer.merge_commit_sha}), but the forge merged ` + - `${landed.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} it was handed and you reviewed: a commit nobody ` + - `reviewed has landed. Read it before anything is built from it (novox/hq issue 381)`); - } - if (landed !== undefined) answer.landed = landed; - return answer; - }, - }; -} diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 7d803c79c..7d95544e4 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -64,16 +64,10 @@ ], "emits": [ "repo.created", - "repo.writable", "issue.opened", - "pull.merged", - "pull.updated", - "pull.closed", "forge.push", "forge.create", - "forge.delete", "forge.fork", - "forge.status", "forge.pull-request.opened", "forge.pull-request.reopened", "forge.pull-request.synchronized", @@ -173,7 +167,21 @@ }, { "name": "git", - "scope": "mesh" + "scope": "mesh", + "serves": [ + "pull", + "open-pulls", + "tags", + "trees", + "file", + "contains", + "statuses", + "status", + "comment", + "view", + "note", + "merge-pull" + ] } ], "data": { @@ -231,12 +239,6 @@ "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 \u00a74). 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 \u00a72): 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.", @@ -249,8 +251,8 @@ "type": "string", "trusted": true, "runtime": true, - "default": "", - "description": "per verb of the two package seats, the callers that may call it (JSON object of verb to callers; * every proved caller, operator the operator's session); empty is design 51's list: publish and publish-part mesh-build-go and mesh-build-node, remove mesh-delivery-builder, versions anyone" + "default": "{\"publish\":[\"mesh-build-go\",\"mesh-build-node\"],\"publish-part\":[\"mesh-build-go\",\"mesh-build-node\"],\"remove\":[\"mesh-delivery-builder\"],\"versions\":[\"*\"],\"pull\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-gate\"],\"open-pulls\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"tags\":[\"mesh-delivery-forge-bridge\"],\"trees\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"file\":[\"mesh-delivery-forge-bridge\",\"mesh-delivery-checker\",\"mesh-delivery-indexer\"],\"contains\":[\"mesh-delivery-indexer\",\"mesh-delivery-gate\"],\"statuses\":[\"mesh-delivery-gate\"],\"status\":[\"mesh-delivery-forge-bridge\"],\"comment\":[\"mesh-delivery-forge-bridge\"],\"view\":[\"mesh-delivery-forge-bridge\"],\"note\":[\"mesh-delivery-forge-bridge\"],\"merge-pull\":[\"mesh-delivery-forge-bridge\"]}", + "description": "per verb of the seats the module holds, the callers that may call it (JSON object of verb to callers; * every proved caller, operator the operator's session); each holder reads the lines of its own verbs. The package seats' lines are design 51's (empty is that list for them); the seat git's are design 47's: its reads the parts that declare them in invokes, its acts (status, comment, view, note, merge-pull) the forge bridge alone, and a verb of git with no line is called by no one" } }, "facts": { @@ -379,8 +381,7 @@ "takes-config": [ "url", "keep-main-rules", - "main-mergers", - "check-gates-merge" + "main-mergers" ] }, { @@ -629,7 +630,8 @@ "state": [ "bound", "announced", - "findings" + "findings", + { "name": "first-views", "history": 1 } ], "reads": [ "mesh-provisioner.plans", @@ -637,16 +639,14 @@ "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", - "mesh-build-image.keys", - "mesh-delivery-checker.owed", - "mesh-delivery-gate.source" + "mesh-build-image.keys" ], "invokes": [ - "mesh-delivery-indexer.indexer_read_modules", + "seat:mesh-catalog.revision", "seat:mesh-hand-acts.hand-act", "mesh-delivery-builder.builder_versions" ], "consumes": [ - "mesh-delivery-indexer.views.modules" + "mesh-catalog.catalog-revision" ] } diff --git a/modules/gitea/package.json b/modules/gitea/package.json index 8e33075b9..e5bdab04d 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -9,7 +9,9 @@ "test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { - "@novox/mesh-sdk": "^0.1.1" + "@novox/mesh-delivery-contracts-git": "^0.1.0", + "@novox/mesh-delivery-contracts-mesh-catalog": "^0.1.0", + "@novox/mesh-sdk": "^0.1.19" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/gitea/protectfull.ts b/modules/gitea/protectfull.ts index e3c836b7a..ddb173163 100644 --- a/modules/gitea/protectfull.ts +++ b/modules/gitea/protectfull.ts @@ -206,40 +206,16 @@ function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: bo return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` }; } -/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */ -export type SourceRead = () => Promise>; +/** Where a repository's trunk is said to come from: the mesh's one trunk per repository (novox/hq design 47, *The trunk + * is what runs*), the branch main. No other module's state is read for it (ADR 0596 §2): the catalog's trunk heads name + * a repository and its commit, not a branch, so a second trunk would first be a branch in the seat mesh-catalog's + * revision. */ +export const TRUNK_FROM = "the mesh's one trunk of every repository (design 47)"; -/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each - * repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */ -export const TRUNK_SOURCE = "mesh-delivery-gate.source"; - -/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's - * source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and - * `from` says which. The one place trunks are read: several trunks later change only this. */ -export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> { - const full = `${owner}/${repo}`; - let src: Record; - try { - src = await read(); - } catch (err) { - return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` }; - } - const names = Object.keys(src).filter((k) => { - const heads = (src[k] as { heads?: Record } | null)?.heads; - return !!heads && typeof heads === "object" && full in heads; - }).sort(); - if (names.length) return { names, from: TRUNK_SOURCE }; - return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` }; -} - -/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */ -export function sourceFromBus(get: (name: string) => { keys(): Promise; get(k: string): Promise<{ value: unknown } | null> }): SourceRead { - return async () => { - const st = get(TRUNK_SOURCE); - const out: Record = {}; - for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null; - return out; - }; +/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): main, and `from` says + * why. The one place trunks are named: a later trunk changes only this. */ +export function trunksOf(): { names: string[]; from: string } { + return { names: [MAIN_BRANCH], from: TRUNK_FROM }; } /** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28 diff --git a/modules/gitea/seat.ts b/modules/gitea/seat.ts new file mode 100644 index 000000000..4078216b8 --- /dev/null +++ b/modules/gitea/seat.ts @@ -0,0 +1,323 @@ +// The forge module holds the seat `git` (novox/hq design 47 *The seat git*, ADR 0596 §1, §2): every read and act a +// delivery part makes of the forge is a verb of the seat, served here as a tool `git.` against the seat's generated +// contract, and no part names this module's own tools. +// +// **Who may call which verb** is the holder's trusted config value `callers` (ADR 0627 §2), the one value the module's +// package seats read too: each holder takes the lines of the verbs it serves, so neither refuses the other's. Unset, no +// one may call a verb of `git`: the list is the manifest's, never a list in this code. The caller is the one the tool +// runner proved in the call's `_meta` (mesh-sdk `callers.callerOf`), never an argument. +// +// **A failed read answers an error, never an empty answer**: the forge's 404 is the kind `not-found`, its refusal (another +// 4xx, or a refusal of this holder) the kind `refused`, a forge that failed or did not answer the kind `failed`; an empty list or an absent file is answered +// only when the forge said so. +// +// **The acts carry the caller's id** (status, comment, view, note, merge-pull): an id the pull request or the note already +// holds is answered `held` and nothing is done again; `merge-pull` merges the head the caller names and judges no check +// (design 47 §314): only into a base whose protection refuses a push, and only that head. + +import type { ToolDefinition } from "@novox/mesh-sdk/tools"; +import { callerOf, refusal, parseVerbCallers, type VerbCallers } from "@novox/mesh-sdk/callers"; +import { config } from "@novox/mesh-sdk/runtime"; +import { + seat as GIT, verbs as GIT_VERBS, + type ContainsAnswer, type CommentAnswer, type ErrorKind, type FileAnswer, type MergePullAnswer, type Merging, type NoteAnswer, + type OpenPull, type OpenPullsAnswer, type Pull, type PullAnswer, type Status, type StatusAnswer, type StatusesAnswer, + type TagsAnswer, type TreesAnswer, type ViewAnswer, +} from "@novox/mesh-delivery-contracts-git"; +import type { CommitStatus, GiteaClient, GiteaComment, GiteaPull } from "./client.js"; +import { MAX_FILE_BYTES } from "./client.js"; +import { containedFrom, newestByContext } from "./checks.js"; +import { Refused, checkName } from "./guard.js"; +import { appendNote, viewBody, viewComment, type Runner } from "./delivery.js"; + +/** The verbs of the seat, in its definition's order. */ +export const VERBS: readonly string[] = Object.values(GIT_VERBS); + +/** The config value whose lines per verb say who may call it. */ +export const CALLERS_KEY = "callers"; + +/** How many tags a page of `tags` holds. */ +export const TAGS_PAGE = 50; + +/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). Its name + * is the manifest's container's; no variable names it (a MESH_* word nothing sets is refused, checks/environment). */ +export const FORGE_CONTAINER = "gitea"; + +/** The writer every status of a head takes its turn on (gate.ts): the seat's status lands after every write of the head + * already asked for, so a recheck's pending never lands over a later verdict (novox/hq issue 456). */ +export interface HeadStatuses { + status(owner: string, repo: string, sha: string, status: CommitStatus): Promise; +} + +/** What the holder reaches beside the forge's API: git in the forge's container (the note), the head's one writer of its + * statuses, and the callers' list. */ +export interface SeatDeps { + run: Runner; + container: string; + statuses: HeadStatuses; + /** The config value `callers` as the runtime answers it; the runtime's own config read by default. */ + callers?: () => Promise; +} + +/** The lines of `raw` (the config value, an object or that object as a string) for the seat's verbs; unset is none. */ +export function gitCallers(raw: unknown): VerbCallers { + if (raw === undefined || raw === null || raw === "") return {}; + let all: unknown = raw; + if (typeof raw === "string") { + try { + all = JSON.parse(raw); + } catch (err) { + throw new Error(`callers is not a JSON object of verb to callers: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (!all || typeof all !== "object" || Array.isArray(all)) throw new Error("callers is not a JSON object of verb to callers"); + const mine = Object.fromEntries(Object.entries(all as Record).filter(([verb]) => VERBS.includes(verb))); + return parseVerbCallers(JSON.stringify(mine), VERBS, {}); +} + +/** A failure as the contract says it: the forge's or this holder's refusal, or a forge that failed. */ +export function failure(err: unknown): { error: string; "error-kind": ErrorKind } { + const text = err instanceof Error ? err.message : String(err); + // The forge's 404: what was asked about (a repository, a commit, a pull request, a branch, a tag) does not exist. + if (/^Gitea API [^:]*: 404 /.test(text)) return { error: text, "error-kind": "not-found" }; + const refused = err instanceof Refused || /^Gitea API [^:]*: 4\d\d /.test(text) || /^Gitea API: /.test(text); + return { error: text, "error-kind": refused ? "refused" : "failed" }; +} + +/** owner/name of a `repository` argument, each checked as a name (guard.ts). */ +export function repositoryOf(raw: unknown): { owner: string; repo: string; full: string } { + const parts = String(raw ?? "").split("/"); + if (parts.length !== 2) throw new Refused(`repository ${JSON.stringify(raw)} is not /`); + const owner = checkName("owner", parts[0]), repo = checkName("name", parts[1]); + return { owner, repo, full: `${owner}/${repo}` }; +} + +const SHA = /^[0-9a-f]{40}$/; +const STATES = new Set(["pending", "success", "failure", "error", "warning"]); +function commitOf(name: string, raw: unknown): string { + const sha = String(raw ?? "").trim(); + if (!SHA.test(sha)) throw new Refused(`${name} is a commit's full 40-hex sha`); + return sha; +} +function numberOf(raw: unknown): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) throw new Refused(`${JSON.stringify(raw)} is not a pull request's number`); + return n; +} +function idOf(raw: unknown): string { + const id = String(raw ?? "").trim(); + if (!id || /[\s<>]/.test(id)) throw new Refused("id is the act's id: one word"); + return id; +} + +/** The marker an act's id leaves in a comment, so an id said again is found and skipped. */ +export function actMarker(id: string): string { + return ``; +} + +/** A pull request as the contract says it; never its description (ADR 0659 §1). */ +export function pullOf(full: string, p: GiteaPull): Pull { + const state = p.merged ? "merged" : p.state === "open" ? "open" : "closed"; + return { + repository: full, number: p.number, base: String(p.base ?? ""), branch: String(p.head ?? ""), head: String(p.head_sha ?? ""), + state, ...(p.user ? { author: p.user } : {}), ...(p.merge_commit_sha ? { "merge-commit": p.merge_commit_sha } : {}), + ...(p.merged_at ? { "merged-at": p.merged_at } : {}), ...(p.title ? { title: p.title } : {}), + ...(p.html_url ? { url: p.html_url } : {}), + }; +} + +function statusOf(s: { context: string; state: string; description?: string; target_url?: string; updated_at?: string; creator?: string }): Status { + return { context: s.context, state: s.state, ...(s.description ? { description: s.description } : {}), + ...(s.target_url ? { target: s.target_url } : {}), ...(s.updated_at ? { at: s.updated_at } : {}), + ...(s.creator ? { by: s.creator } : {}) }; +} + +/** What the forge would merge now, for an open pull request: whole, or the verb's error. */ +async function mergingOf(gitea: GiteaClient, owner: string, repo: string, p: GiteaPull): Promise { + if (p.mergeable === undefined) throw new Error(`the forge did not say whether ${owner}/${repo}#${p.number} merges now`); + const rule = await gitea.appliedProtection(owner, repo, String(p.base ?? "")); + const writes = (await gitea.getRepo(owner, repo)).writes; + if (writes === undefined) throw new Error(`the forge did not say whether its account writes ${owner}/${repo}`); + const required = rule?.enable_status_check ? [...(rule.status_check_contexts ?? [])] : []; + return { mergeable: p.mergeable, protected: !!rule, required, writes }; +} + +/** The seat's verbs as tools `git.`, each judging its caller first and answering in the seat's contract. */ +export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[] { + const listOf = deps.callers ?? (() => config(CALLERS_KEY)); + const verb = (name: string, description: string, act: (args: Record) => Promise): ToolDefinition => ({ + name: `${GIT}.${name}`, + description, + input: {}, + run: async (args: Record) => { + try { + const why = refusal(gitCallers(await listOf()), name, callerOf(args)); + if (why) return failure(new Refused(why.message)); + return await act(args); + } catch (err) { + return failure(err); + } + }, + }); + + return [ + verb(GIT_VERBS.pull, "The seat git's pull: one pull request by its repository and number or by its merge commit, and for an open one what the forge would merge now.", + async (args): Promise => { + const { owner, repo, full } = repositoryOf(args.repository); + const p = args["merge-commit"] !== undefined + ? await gitea.pullOfCommit(owner, repo, commitOf("merge-commit", args["merge-commit"])) + : await gitea.getPullRequest(owner, repo, numberOf(args.number)); + const pull = pullOf(full, p); + return pull.state === "open" ? { pull, merging: await mergingOf(gitea, owner, repo, p) } : { pull }; + }), + verb(GIT_VERBS.openPulls, "The seat git's open-pulls: every open pull request of every repository, with the statuses its base requires and those its head has.", + async (): Promise => { + const pulls: OpenPull[] = (await gitea.openPulls()).map((o) => ({ + pull: { repository: `${o.owner}/${o.repo}`, number: o.number, base: o.base, branch: String(o.head ?? ""), head: o.head_sha, + state: "open", ...(o.html_url ? { url: o.html_url } : {}) }, + required: [...o.required], + checks: Object.fromEntries(Object.entries(o.checks ?? {}).map(([context, c]) => + [context, statusOf({ context, state: c.state, description: c.description, updated_at: c.updated_at })])), + })); + return { pulls }; + }), + verb(GIT_VERBS.tags, "The seat git's tags: a repository's tags, a page at a time, newest first; more when another page exists.", + async (args): Promise => { + const { owner, repo, full } = repositoryOf(args.repository); + const page = args.page === undefined ? 1 : Number(args.page); + const prefix = String(args.prefix ?? ""); + // One more than the page is asked, so whether another page exists is the forge's answer, not a guess. + const got = await gitea.tags(owner, repo, page, TAGS_PAGE + 1); + const more = got.length > TAGS_PAGE; + const tags = got.slice(0, TAGS_PAGE).filter((t) => t.startsWith(prefix)); + return { repository: full, page, bound: TAGS_PAGE, tags, ...(more ? { more } : {}) }; + }), + verb(GIT_VERBS.trees, "The seat git's trees: the trees of one commit, with files and paths when asked; a listing the forge cut is refused.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const got = await gitea.commitTrees(owner, repo, commit, args.paths as Record | undefined, + args.files as string[] | undefined); + return { commit, ...got }; + }), + verb(GIT_VERBS.file, "The seat git's file: one file at one commit, branch or tag, decoded; over 512 KiB refused; absent only when the forge said so.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const path = String(args.path ?? ""); + const ref = args.ref === undefined ? undefined : String(args.ref); + try { + const f = await gitea.getFile(owner, repo, path, ref); + if (f.size > MAX_FILE_BYTES) throw new Refused(`${path} is ${f.size} bytes, over ${MAX_FILE_BYTES / 1024} KiB`); + return { path: f.path, ...(ref ? { ref } : {}), blob: f.sha, size: f.size, content: f.content }; + } catch (err) { + if (err instanceof Error && /^Gitea API [^:]*\/contents\/[^:]*: 404 /.test(err.message)) return { path, ...(ref ? { ref } : {}), absent: true }; + throw err; + } + }), + verb(GIT_VERBS.contains, "The seat git's contains: whether one commit contains another, an ancestor of it or the same.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit), within = commitOf("in", args.in); + const { contains, ahead } = containedFrom(await gitea.compare(owner, repo, within, commit)); + return { commit, in: within, contains, ahead }; + }), + verb(GIT_VERBS.statuses, "The seat git's statuses: the statuses a commit carries, the newest of each context.", + async (args): Promise => { + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const combined = await gitea.combinedStatus(owner, repo, commit); + return { commit, combined: combined.state, statuses: newestByContext(combined.statuses).map(statusOf) }; + }), + verb(GIT_VERBS.status, "The seat git's status: an act of the forge bridge, a commit status set in its turn after every write of the head already asked for; the same id said again sets it again to the same.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const state = String(args.state ?? ""); + if (!STATES.has(state)) throw new Refused(`state ${JSON.stringify(state)} is none of ${[...STATES].join(", ")}`); + await deps.statuses.status(owner, repo, commit, { state: state as CommitStatus["state"], context: String(args.context ?? ""), + description: String(args.description ?? ""), ...(args.target ? { target_url: String(args.target) } : {}) }); + return { id, performed: true }; + }), + verb(GIT_VERBS.comment, "The seat git's comment: an act of the forge bridge, a comment on a pull request carrying the act's id; an id it already holds is held.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const number = numberOf(args.number); + if (holds(await gitea.listComments(owner, repo, number), id)) return { id, held: true, performed: true }; + await gitea.addComment(owner, repo, number, `${String(args.text ?? "")}\n\n${actMarker(id)}`); + return { id, performed: true }; + }), + verb(GIT_VERBS.view, "The seat git's view: an act of the forge bridge, the delivery's one view comment kept on a pull request; an id it already holds is held.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const number = numberOf(args.number); + const existing = viewComment(await gitea.listComments(owner, repo, number)); + if (existing && existing.body.includes(actMarker(id))) return { id, held: true, performed: true }; + const body = `${viewBody(String(args.text ?? ""))}\n${actMarker(id)}`; + if (existing) await gitea.editComment(owner, repo, existing.id, body); + else await gitea.addComment(owner, repo, number, body); + return { id, performed: true }; + }), + verb(GIT_VERBS.note, "The seat git's note: an act of the forge bridge, one line on a commit's note under refs/notes/mesh-plan carrying the act's id; an id it already holds is held.", + async (args): Promise => { + const id = idOf(args.id); + const { owner, repo } = repositoryOf(args.repository); + const commit = commitOf("commit", args.commit); + const said = await appendNote(deps.run, deps.container, owner, repo, commit, "mesh-plan", `${String(args.line ?? "")} [${id}]`, id); + 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)), + ]; +} + +function holds(comments: GiteaComment[], id: string): boolean { + return comments.some((c) => String(c.body ?? "").includes(actMarker(id))); +} + +/** 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 { + 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 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`); + } + if (pull.state !== "open") return no("it is closed"); + 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`); + } + const rule = await gitea.appliedProtection(owner, repo, String(pull.base ?? "")); + if (!rule || rule.enable_push !== false) { + return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`); + } + 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}`); + throw err; + } + const merged = await gitea.getPullRequest(owner, repo, number); + if (!merged.merge_commit_sha) throw new Error(`${full}#${number} WAS merged, but the forge named no merge commit`); + // The merge is done once the forge named its commit: a read-back that fails is said, never answered as a failed act. + let parents: string[]; + try { + parents = await gitea.commitParents(owner, repo, merged.merge_commit_sha); + } catch (err) { + console.error(`[gitea] ${full}#${number} was merged (${merged.merge_commit_sha}); which head it joined could not be read back — ${err instanceof Error ? err.message : err}`); + return { id, performed: true, "merge-commit": merged.merge_commit_sha }; + } + if (parents.length === 2 && parents[1] !== head) { + throw new Error(`${full}#${number} WAS merged (${merged.merge_commit_sha}), but the forge merged ${parents[1].slice(0, 12)}, ` + + `not the ${head.slice(0, 12)} the caller names (novox/hq issue 381)`); + } + return { id, performed: true, "merge-commit": merged.merge_commit_sha }; +} diff --git a/modules/gitea/seatevents.ts b/modules/gitea/seatevents.ts new file mode 100644 index 000000000..8b859f846 --- /dev/null +++ b/modules/gitea/seatevents.ts @@ -0,0 +1,96 @@ +// The seat git's events, said by its holder (novox/hq design 47 *The seat git*): a pull request's new head, its merge and +// its close (pull.updated, pull.merged, pull.closed), the forge's own webhook of a pull request, a status and a deleted +// branch (forge.pull, forge.status, forge.delete), and a repository the forge can write again (repo.writable). Each is +// said on the seat's subject through the runtime (mesh-sdk `runtime.seatPublish`, `mesh/seat.publish`), in the body the +// seat's contract types, never as this module's own event: its hearers consume the seat, not the forge module. +// +// What the module's paths make (the poll, the webhook, the account's writes) is read here once and turned into the +// contract's body; a pull request's description is never carried (ADR 0659 §1). + +import { seatPublish } from "@novox/mesh-sdk/runtime"; +import { + forgeDeleteSubject, forgePullSubject, forgeStatusSubject, pullClosedSubject, pullMergedSubject, pullUpdatedSubject, + repoWritableSubject, + type Change, type ForgeDeleteEvent, type ForgePullEvent, type ForgeStatusEvent, type PullClosedEvent, type PullMergedEvent, + type PullUpdatedEvent, type RepoWritableEvent, +} from "@novox/mesh-delivery-contracts-git"; + +/** The forge's pull request actions the seat's forge.pull says (the contract's word list). */ +export const PULL_ACTIONS = ["opened", "reopened", "synchronized", "edited", "closed", "merged"]; + +type Body = Record; +const s = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined); +const opt = (k: K, v: unknown) => (v === undefined || v === null || v === "" ? {} : { [k]: v } as Record); + +function repositoryOf(b: Body): string { + const full = s(b.full_name) ?? (s(b.owner) && s(b.repo) ? `${b.owner}/${b.repo}` : undefined); + if (!full) throw new Error("the event names no repository"); + return full; +} + +function changeOf(b: Body): Change | undefined { + if (!Array.isArray(b.paths)) return undefined; + return { paths: b.paths.map(String), ...opt("paths-truncated", b.paths_truncated === true ? true : undefined), + ...opt("removed", Array.isArray(b.removed) && b.removed.length ? b.removed.map(String) : undefined), + ...opt("module-dirs", Array.isArray(b.module_dirs) ? b.module_dirs.map(String) : undefined) }; +} + +/** The seat's subject and body for one of the module's announcements, or undefined when the seat says no such event. */ +export function seatEventOf(type: string, b: Body): { subject: string; body: unknown } | undefined { + switch (type) { + case "pull.updated": { + const body: PullUpdatedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""), + branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), ...opt("base-head", s(b.base_sha)), + ...opt("author", s(b.author)), ...opt("title", s(b.title)), ...opt("url", s(b.html_url)), ...opt("change", changeOf(b)) }; + return { subject: pullUpdatedSubject, body }; + } + case "pull.merged": { + const body: PullMergedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""), + branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), "merge-commit": String(b.merge_commit_sha ?? ""), + ...opt("merged-at", s(b.merged_at)), ...opt("head-checks", b.head_checks), ...opt("author", s(b.author)), + ...opt("title", s(b.title)), ...opt("url", s(b.html_url)), ...opt("change", changeOf(b)) }; + return { subject: pullMergedSubject, body }; + } + case "pull.closed": { + const body: PullClosedEvent = { repository: repositoryOf(b), number: Number(b.number), base: String(b.base ?? ""), + branch: String(b.head ?? ""), head: String(b.head_sha ?? ""), ...opt("author", s(b.author)), ...opt("title", s(b.title)), + ...opt("url", s(b.html_url)) }; + return { subject: pullClosedSubject, body }; + } + case "forge.status": { + const body: ForgeStatusEvent = { repository: repositoryOf(b), commit: String(b.sha ?? ""), context: String(b.context ?? ""), + state: String(b.state ?? ""), ...opt("description", s(b.description)), ...opt("sender", s(b.sender)) }; + return { subject: forgeStatusSubject, body }; + } + case "forge.delete": { + const body: ForgeDeleteEvent = { repository: repositoryOf(b), ref: String(b.ref ?? ""), ...opt("ref-type", s(b.ref_type)), + ...opt("sender", s(b.sender)) }; + return { subject: forgeDeleteSubject, body }; + } + case "repo.writable": { + const body: RepoWritableEvent = { repository: repositoryOf(b), account: String(b.account ?? "") }; + return { subject: repoWritableSubject, body }; + } + } + const action = type.startsWith("forge.pull-request.") ? type.slice("forge.pull-request.".length) : ""; + if (PULL_ACTIONS.includes(action)) { + const body: ForgePullEvent = { repository: repositoryOf(b), number: Number(b.number), action, + ...opt("base", s(b.base)), ...opt("branch", s(b.head)), ...opt("head", s(b.head_sha)), ...opt("author", s(b.author)), + ...opt("merge-commit", s(b.merge_commit_sha)), ...(b.merged === true ? { merged: true } : {}), ...opt("sender", s(b.sender)) }; + return { subject: forgePullSubject, body }; + } + return undefined; +} + +/** The module's own events the seat says instead: never said as the module's (its `emits` names none of them). */ +export const SEAT_ONLY = new Set(["pull.updated", "pull.merged", "pull.closed", "forge.status", "forge.delete", "repo.writable"]); + +export type Say = (subject: string, body: unknown, id?: string) => Promise; + +/** Say an announcement on the seat, de-duplicated by `id` (the same announcement by two paths is one message). */ +export async function sayOnSeat(type: string, body: Body, id = "", say: Say = seatPublish): Promise { + const e = seatEventOf(type, body); + if (!e) return false; + await say(e.subject, e.body, id); + return true; +} diff --git a/modules/gitea/test/announce.test.ts b/modules/gitea/test/announce.test.ts index 222fee212..7a3c95eb6 100644 --- a/modules/gitea/test/announce.test.ts +++ b/modules/gitea/test/announce.test.ts @@ -22,25 +22,37 @@ test("the same merge, head or close announced twice has the same id, and another assert.equal(announcementId("pull.closed", closed), "closed:novox/app#7@aaa111"); }); -test("an announcement is published through the broker with its id as the event id, and the provenance emit stamps", async () => { - const { useBroker } = await import("@novox/mesh-sdk/messaging"); - const published: any[] = []; - useBroker(() => ({ publish: async (env: any) => { published.push(env); } }) as any); - process.env.MESH_MODULE = "gitea"; - process.env.MESH_NODE = "anchor"; +test("an announcement is said on the seat git with its id, in the contract's body, never the description", async () => { + const { useRuntime } = await import("@novox/mesh-sdk/runtime"); + const said: any[] = []; + useRuntime(async (method, params) => { said.push({ method, ...(params as object) }); return null; }); const { announce } = await import("../announce.ts"); - await announce("pull.merged", merged); + const { seatEventOf, SEAT_ONLY } = await import("../seatevents.ts"); + await announce("pull.merged", { ...merged, body: "a description", head: "feat/x", paths: ["a/b.go"], removed: [] }); await announce("pull.merged", { ...merged }); - assert.equal(published.length, 2); - assert.equal(published[0].key, "pull.merged"); - assert.equal(published[0].headers["x-event-id"], "merged:novox/app#7@mmm222"); - assert.equal(published[1].headers["x-event-id"], published[0].headers["x-event-id"], "one id for one announcement"); - assert.equal(published[0].headers["x-source"], "gitea"); - assert.equal(published[0].headers["x-node"], "anchor"); - assert.equal(published[0].headers["content-type"], "application/json"); - assert.ok(published[0].headers["x-time"]); - assert.equal(published[0].node, "anchor"); - assert.deepEqual(published[0].body, merged); + useRuntime(undefined); + assert.equal(said.length, 2); + assert.deepEqual([said[0].method, said[0].subject, said[0].id], ["mesh/seat.publish", "mesh.seat.git.event.pull-merged", "merged:novox/app#7@mmm222"]); + assert.equal(said[1].id, said[0].id, "one id for one announcement"); + assert.deepEqual(said[0].body, { repository: "novox/app", number: 7, base: "main", branch: "feat/x", head: "aaa111", + "merge-commit": "mmm222", change: { paths: ["a/b.go"] } }); + // Every event the seat says, from what the module's paths make; the others are none of the seat's. + const rows: [string, Record, string | undefined][] = [ + ["pull.updated", updated, "mesh.seat.git.event.pull-updated"], + ["pull.closed", closed, "mesh.seat.git.event.pull-closed"], + ["forge.status", { full_name: "novox/app", sha: "aaa111", context: "mesh/check", state: "success" }, "mesh.seat.git.event.forge-status"], + ["forge.delete", { owner: "novox", repo: "app", ref: "feat/x", ref_type: "branch" }, "mesh.seat.git.event.forge-delete"], + ["repo.writable", { owner: "novox", repo: "app", account: "mesh-tools" }, "mesh.seat.git.event.repo-writable"], + ["forge.pull-request.synchronized", { full_name: "novox/app", number: 7, head_sha: "aaa111" }, "mesh.seat.git.event.forge-pull"], + ["forge.pull-request.labeled", { full_name: "novox/app", number: 7 }, undefined], + ["forge.push", { full_name: "novox/app" }, undefined], + ]; + for (const [type, body, subject] of rows) assert.equal(seatEventOf(type, body)?.subject, subject, type); + assert.deepEqual(seatEventOf("pull.updated", updated)?.body, { repository: "novox/app", number: 7, base: "main", branch: "", + head: "aaa111", "base-head": "ttt333" }); + assert.equal(seatEventOf("forge.pull-request.edited", { full_name: "novox/app", number: 7, body: "x" })?.body && + "body" in (seatEventOf("forge.pull-request.edited", { full_name: "novox/app", number: 7, body: "x" })!.body as object), false); + assert.deepEqual([...SEAT_ONLY].sort(), ["forge.delete", "forge.status", "pull.closed", "pull.merged", "pull.updated", "repo.writable"]); }); test("an announcement of a kind it has no id for is refused, never published with a made-up one", async () => { diff --git a/modules/gitea/test/bundle-import.test.ts b/modules/gitea/test/bundle-import.test.ts index 14f7786cb..c14f47726 100644 --- a/modules/gitea/test/bundle-import.test.ts +++ b/modules/gitea/test/bundle-import.test.ts @@ -37,85 +37,12 @@ test("importing the bundle resolves while the broker keeps refusing the binds", assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`); }); -// **The bundle writes mesh/check from the checker's owed state, and serves no tool that writes it** (ADR 0521; R4e round 2 -// finding R2-1; finding K1). The bundle is imported whole, as the runtime imports it, while its read of the checker's owed -// is refused (the grant not yet composed): the watch is asked again, and once granted it is handed the bucket as the bus -// keeps it, the latest revision of each key, then an older revision delivered late. The forge stand-in sees the latest -// written, and nothing after it. -test("a bundle whose read of the checker's owed is granted late writes the latest mesh/check, and serves no gitea_check_status", async () => { - const index = pathToFileURL(join(here, "..", "dist", "index.js")).href; - const sha = "0123456789abcdef0123456789abcdef01234567"; - const script = ` - const http = await import("node:http"); - const statuses = []; - const forge = http.createServer((req, res) => { - let b = ""; req.on("data", (d) => (b += d)); req.on("end", () => { - if (req.method === "POST" && /\\/statuses\\//.test(req.url)) statuses.push(req.url + " " + b); - res.setHeader("content-type", "application/json"); res.end(req.method === "GET" ? "[]" : "{}"); - }); - }); - await new Promise((r) => forge.listen(0, "127.0.0.1", r)); - process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port; - const bound = []; - const { useBroker } = await import("@novox/mesh-sdk/messaging"); - useBroker(() => ({ - subscribe: async (pattern) => { bound.push(pattern); return () => {}; }, - publish: async () => {}, request: async () => { throw new Error("no responders"); }, - handle: async () => () => {}, close: async () => {}, - })); - const key = "novox/mesh-sdk.12.${sha}.status=mesh/check"; - const owed = (state, text) => ({ v: 1, act: "status", context: "mesh/check", state, text, target: "https://view.example/c" }); - const watched = []; - let granted = 0; - const { useStateChannel } = await import("@novox/mesh-sdk/state"); - useStateChannel({ - ask: async () => null, - watch: async (params, deliver) => { - watched.push(params.state); - if (watched.length === 1) throw new Error('gitea keeps and reads no state called "mesh-delivery-checker.owed"'); - granted++; - // Two revisions were written while the read was refused; the bucket hands the latest, then a late older one. - await deliver({ state: params.state, key, op: "put", value: owed("success", "pass at 4"), revision: 4, current: true }); - await deliver({ state: params.state, key, op: "put", value: owed("pending", "pending at 3"), revision: 3, current: false }); - }, - }); - await import(${JSON.stringify(index)}); - const { collectTools } = await import("@novox/mesh-sdk/tools"); - const tools = collectTools().flatMap((r) => r.tools.map((t) => t.name)); - for (let i = 0; i < 300 && !(granted && statuses.length); i++) await new Promise((r) => setTimeout(r, 20)); - await new Promise((r) => setTimeout(r, 200)); - console.log(JSON.stringify({ tools, bound, watched, statuses })); - process.exit(0);`; - const env = { ...process.env, GITEA_TOKEN: "x" }; - for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) delete env[k]; - const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] }); - let out = ""; - let err = ""; - child.stdout.on("data", (d) => (out += d)); - child.stderr.on("data", (d) => (err += d)); - const ended = new Promise((r) => child.on("exit", () => r())); - const timer = setTimeout(() => child.kill("SIGKILL"), 15000); - await ended; - clearTimeout(timer); - const line = out.split("\n").find((l) => l.startsWith("{")); - assert.ok(line, `the bundle said nothing; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`); - const got = JSON.parse(line!) as { tools: string[]; bound: string[]; watched: string[]; statuses: string[] }; - assert.ok(got.tools.includes("gitea_commit_status"), `the bundle's tools: ${got.tools}`); - assert.ok(!got.tools.includes("gitea_check_status"), "no tool writes mesh/check"); - assert.ok(!got.bound.some((p) => p.includes("check.status")), `bound: ${got.bound}`); - assert.deepEqual(got.watched, ["mesh-delivery-checker.owed", "mesh-delivery-checker.owed"], "refused, then asked again"); - assert.equal(got.statuses.length, 1, `statuses written: ${got.statuses}`); - assert.match(got.statuses[0], new RegExp(`/repos/novox/mesh-sdk/statuses/${sha} .*"context":"mesh/check"`)); - assert.match(got.statuses[0], /"state":"success".*pass at 4/); - assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said"); -}); - // **The bundle's import does not wait on the forge** (novox/hq issue 1726, 2026-10-24 12:37). The webhook receiver's set-up // asks the forge's admin API for its system webhook; awaited at the top of index.ts, a forge slow to answer (busy while // every bundle of the node starts) held the import past node-tools' 10 s handshake, the bundle was killed and never -// started again, and gitea_commit_status, gitea_merge_pull_request and gitea_events were served nowhere: the forge -// bridge's calls found no responders and no new pull request was announced. Here the forge stand-in takes every -// request and never answers: the import resolves at once all the same, with the bundle's tools registered. +// started again, and gitea_events was served nowhere and no new pull request was announced. Here the forge stand-in takes +// every request and never answers: the import resolves at once all the same, with the bundle's tools registered. The +// bundle serves no merge and no status of its own: those are the seat git's acts (design 47 *The seat git*). test("importing the bundle resolves, its tools registered, while the forge never answers the webhook's set-up", async () => { const index = pathToFileURL(join(here, "..", "dist", "index.js")).href; const script = ` @@ -155,7 +82,8 @@ test("importing the bundle resolves, its tools registered, while the forge never const line = out.split("\n").find((l) => l.startsWith("{")); assert.ok(line, `the import did not resolve within 3 s while the forge did not answer; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`); const got = JSON.parse(line!) as { tools: string[] }; - for (const t of ["gitea_events", "gitea_commit_status", "gitea_merge_pull_request"]) { - assert.ok(got.tools.includes(t), `${t} is registered: ${got.tools}`); + assert.ok(got.tools.includes("gitea_events"), `gitea_events is registered: ${got.tools}`); + for (const t of ["gitea_commit_status", "gitea_merge_pull_request"]) { + assert.ok(!got.tools.includes(t), `${t} is retired: ${got.tools}`); } }); diff --git a/modules/gitea/test/check-owed.test.ts b/modules/gitea/test/check-owed.test.ts deleted file mode 100644 index d3184a145..000000000 --- a/modules/gitea/test/check-owed.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1 -// before it). No tool writes mesh/check, since a tool is told no caller and node-tools invokes every tool. Nor does an event: -// nothing in the delivery plan orders this module before the forge bridge, and a durable consumer whose filters change keeps -// its deliver policy, so an event said before this module consumed it was lost. A watch of the checker's state hands over -// the latest value of every key when it starts, whenever that is. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; - -const sha = "0123456789abcdef0123456789abcdef01234567"; -const key = (s = sha, repo = "novox/mesh-sdk", n = 12) => `${repo}.${n}.${s}.status=mesh/check`; -const owed = (over: Record = {}) => ({ v: 1, act: "status", context: "mesh/check", state: "success", - text: "pass 0123456789ab on aaaaaaaaaaaa", target: "https://view.example/checks/g.1", ...over }); -const put = (k: string, value: unknown, revision: number, current = false) => - ({ state: "mesh-delivery-checker.owed", key: k, op: "put" as const, value, revision, current }); - -function forge() { - const sent: string[] = []; - let failing = 0; - return { - sent, - fail: (n: number) => { failing = n; }, - setCommitStatus: async (o: string, r: string, s: string, st: any) => { - if (failing > 0) { failing--; throw new Error("Gitea API /repos/x/statuses: 502 bad gateway"); } - sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`); - }, - }; -} -const noSleep = async () => {}; - -test("the checker's owed is what this module reads, and it consumes no check.status", async () => { - const { CHECKER_OWED } = await import("../check.ts"); - assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed"); - const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8")); - assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed"); - assert.ok(!(manifest.consumes ?? []).some((c: string) => c.includes("check.status")), "no check.status is consumed"); -}); - -test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const checks = new CheckStatuses(f, () => {}, noSleep); - assert.equal(await checks.apply(put(key(), owed(), 7, true)), "written"); - const other = sha.replace(/0/g, "f"); - assert.equal(await checks.apply(put(key(other, "novox/my=2erepo"), owed({ state: "pending", text: "y".repeat(300), target: "javascript:x" }), 8)), "written"); - assert.equal(f.sent[0], "novox/mesh-sdk@01234567 mesh/check success pass 0123456789ab on aaaaaaaaaaaa https://view.example/checks/g.1"); - assert.ok(f.sent[1].startsWith("novox/my.repo@f1234567 mesh/check pending yyyy"), f.sent[1]); - assert.ok(f.sent[1].length < 200, "the line is cut as every status's is"); - assert.ok(!f.sent[1].includes("javascript"), "a link that is no web page is not written"); -}); - -test("a key or value that is no owed mesh/check, and a delete, write nothing", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const checks = new CheckStatuses(f, () => {}, noSleep); - const cases = [ - put(key(sha.slice(0, 12)), owed(), 1), put(`novox/mesh-sdk.12.${sha}.status=mesh/merge-gate`, owed(), 1), - put(`novox/mesh-sdk.12.${sha}.view`, owed(), 1), put(`nokey`, owed(), 1), put(`../x/y.12.${sha}.status=mesh/check`, owed(), 1), - put(key(), owed({ context: "mesh/delivery" }), 1), put(key(), owed({ act: "view" }), 1), put(key(), owed({ state: "green" }), 1), - put(key(), owed({ v: 2 }), 1), put(key(), null, 1), put(key(), "text", 1), put(key(), owed(), -1), - { state: "mesh-delivery-checker.owed", key: key(), op: "delete" as const, revision: 2, current: false }, - ]; - for (const c of cases) assert.equal(await checks.apply(c as any), "dropped", JSON.stringify(c)); - assert.deepEqual(f.sent, []); -}); - -test("a revision older than one written on the key is dropped; the same one is written again", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const checks = new CheckStatuses(f, () => {}, noSleep); - await checks.apply(put(key(), owed({ state: "success" }), 10)); - assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 9)), "dropped", "a stale pending never hides the verdict"); - assert.equal(await checks.apply(put(key(), owed({ state: "success" }), 10)), "written"); - assert.equal(await checks.apply(put(key(sha.replace(/1/g, "2")), owed({ state: "pending" }), 3)), "written", "another key is its own"); - assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 11)), "written"); - assert.deepEqual(f.sent.map((s) => s.split(" ").slice(0, 3).join(" ")), ["novox/mesh-sdk@01234567 mesh/check success", - "novox/mesh-sdk@01234567 mesh/check success", "novox/mesh-sdk@02234567 mesh/check pending", "novox/mesh-sdk@01234567 mesh/check pending"]); -}); - -test("a forge that refuses is asked again until it writes, unless a newer revision of the key came meanwhile", async () => { - const { CheckStatuses } = await import("../check.ts"); - const f = forge(); - const said: string[] = []; - const waits: number[] = []; - const checks = new CheckStatuses(f, (l) => said.push(l), async (ms) => { waits.push(ms); }); - f.fail(2); - await checks.take(put(key(), owed({ state: "success" }), 5)); - await checks.settled(); - assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "written on the third ask"); - assert.deepEqual(waits, [2_000, 5_000]); - assert.match(said[0], /502.*asked again/); - // A refusal whose key moved on meanwhile is not asked again: the newer revision is written, the older never after it. - const g = forge(); - let release: () => void = () => {}; - const gate = new Promise((r) => { release = r; }); - const later = new CheckStatuses(g, () => {}, () => gate); - g.fail(1); - await later.take(put(key(), owed({ state: "pending" }), 6)); - await later.take(put(key(), owed({ state: "success" }), 7)); - release(); - await later.settled(); - assert.deepEqual(g.sent.map((s) => s.split(" ")[2]), ["success"]); -}); - -test("a watch refused at first is asked again, said once, and once granted writes the latest revision of each key", async () => { - const { CheckStatuses, watchOwed } = await import("../check.ts"); - const f = forge(); - const said: string[] = []; - const checks = new CheckStatuses(f, () => {}, noSleep); - // The checker's bucket as the bus keeps it: two revisions of one key written while the read was refused. - const kept = new Map(); - kept.set(key(), { value: owed({ state: "pending" }), revision: 3 }); - kept.set(key(), { value: owed({ state: "success" }), revision: 4 }); - let asked = 0; - const watch = async (handler: (c: any) => Promise) => { - asked++; - if (asked < 3) throw new Error("gitea keeps and reads no state called \"mesh-delivery-checker.owed\""); - for (const [k, e] of kept) await handler(put(k, e.value, e.revision, true)); - }; - await watchOwed(watch, checks, noSleep, (l) => said.push(l)); - await checks.settled(); - assert.equal(asked, 3); - assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "only the latest revision is written"); - assert.equal(said.filter((l) => /could not be bound/.test(l)).length, 1, `said once: ${said.join(" | ")}`); - assert.ok(said.some((l) => /bound after 3 attempts/.test(l)), said.join(" | ")); -}); - -test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_status refuses it", async () => { - const delivery = await import("../delivery.ts"); - assert.equal((delivery as Record).checkStatusTool, undefined, "delivery.ts serves no gitea_check_status"); - assert.throws(() => delivery.deliveryStatus("mesh/check", "success", "x"), /mesh-delivery-checker\.owed/); - const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); - assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status"); -}); - -test("the watch's current values are taken without waiting on the forge, so a slow forge never times the watch out (issue 1726)", async () => { - // The tool runtime hands a watch's current values one at a time and waits for each to be taken; it gives the whole set - // 10 s (mesh-tools StateTimeout) and otherwise ends the watch. On 2026-10-23 the owed held ~200 keys, each written to the - // forge before the next was handed over: the set took longer than 10 s, the watch ended at every attempt, and no - // verdict reached the forge's mesh/check again. - const { CheckStatuses, watchOwed } = await import("../check.ts"); - const sent: string[] = []; - let release: () => void = () => {}; - const slow = new Promise((r) => { release = r; }); - const f = { setCommitStatus: async (_o: string, _r: string, s: string, st: any) => { await slow; sent.push(`${s.slice(0, 4)} ${st.state}`); } }; - const checks = new CheckStatuses(f, () => {}, noSleep); - const keys = Array.from({ length: 200 }, (_, i) => key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1)); - let handedOver = 0; - let attempts = 0; - const watch = async (handler: (c: any) => Promise) => { - if (++attempts > 1) return; // a second attempt is not what this test waits for: the first must hold - // The runtime's bound on the current values: the forge has answered none of them yet. - const timedOut = new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 200)); - const delivered = (async () => { for (const [i, k] of keys.entries()) { await handler(put(k, owed(), i + 1, true)); handedOver++; } return "delivered" as const; })(); - if (await Promise.race([delivered, timedOut]) === "timeout") throw new Error("the current values of mesh-delivery-checker.owed did not arrive in 10s"); - }; - const said: string[] = []; - await watchOwed(watch, checks, noSleep, (l) => said.push(l)); - assert.equal(handedOver, 200, "every current value was taken before the forge answered"); - assert.ok(!said.some((l) => /could not be bound/.test(l)), said.join(" | ")); - assert.equal(sent.length, 0, "nothing is written before the forge answers"); - release(); - await checks.settled(); - assert.equal(sent.length, 200, "every owed status is written once the forge answers"); -}); - -test("the writes a watch's start hands over go to the forge a few at a time, never all at once (issue 1726)", async () => { - const { CheckStatuses, WRITES_AT_ONCE } = await import("../check.ts"); - let running = 0, most = 0, done = 0; - const f = { setCommitStatus: async () => { running++; most = Math.max(most, running); await new Promise((r) => setTimeout(r, 1)); running--; done++; } }; - const checks = new CheckStatuses(f, () => {}, noSleep); - for (let i = 0; i < 50; i++) await checks.take(put(key(i.toString(16).padStart(4, "0") + sha.slice(4), "novox/mesh-sdk", i + 1), owed(), i + 1, true)); - await checks.settled(); - assert.equal(done, 50); - assert.equal(most, WRITES_AT_ONCE); -}); - -// The sweep of 2026-10-30 (issue 774): a status the forge refuses with 403 or 404 was asked again every 30 s forever (hal -// and novadock, 119 times each), and a key the checker deleted kept its loop running. Now a 403 or 404 is said once and -// never asked again, a 5xx is still asked again, and a delete ends a loop that is running. -test("a refusal for good is said once and not asked again; a delete ends a running ask again", async () => { - const { CheckStatuses, refusedForGood } = await import("../check.ts"); - for (const [err, forGood] of [["Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden", true], - ["Gitea API /repos/jschoubben/novadock/statuses/x: 404 not found", true], ["Gitea API /repos/x/statuses: 502 bad gateway", false], - ["Gitea API /repos/x: no answer in 10 s, the request was cut", false]] as [string, boolean][]) { - assert.equal(refusedForGood(new Error(err)), forGood, err); - } - const said: string[] = []; - const forbidden = { setCommitStatus: async () => { throw new Error("Gitea API /repos/jschoubben/hal/statuses/x: 403 forbidden"); } }; - const checks = new CheckStatuses(forbidden, (l) => said.push(l), async () => { throw new Error("asked again"); }); - await checks.take(put(key(sha, "jschoubben/hal"), owed(), 3)); - await checks.settled(); - assert.equal(said.length, 1); - assert.match(said[0], /refused for good.*403.*not asked again/); - // A 502 asks again; the key's delete meanwhile ends the loop before its next ask. - const g = forge(); - let release: () => void = () => {}; - const gate = new Promise((r) => { release = r; }); - const later = new CheckStatuses(g, () => {}, () => gate); - g.fail(5); - await later.take(put(key(), owed(), 8)); - await later.take({ state: "mesh-delivery-checker.owed", key: key(), op: "delete", revision: 9, current: false } as any); - release(); - await later.settled(); - assert.deepEqual(g.sent, [], "a deleted key is not written"); -}); diff --git a/modules/gitea/test/delivery.test.ts b/modules/gitea/test/delivery.test.ts index 724660283..b134f8696 100644 --- a/modules/gitea/test/delivery.test.ts +++ b/modules/gitea/test/delivery.test.ts @@ -41,41 +41,12 @@ test("a note that cannot be written is said, never read as written", async () => await assert.rejects(appendNote(runner, "gitea", "novox", "x", "abcdef1234567", "mesh-plan", "l"), /could not be appended/); }); -test("one view per pull request, found by its marker; only the mesh's statuses", async () => { - const { viewBody, viewComment, deliveryStatus, VIEW_MARKER } = await import("../delivery.ts"); +test("one view per pull request, found by its marker", async () => { + const { viewBody, viewComment, VIEW_MARKER } = await import("../delivery.ts"); assert.ok(viewBody("**Delivery**").startsWith(VIEW_MARKER)); assert.equal(viewBody(`${VIEW_MARKER}\nx`), `${VIEW_MARKER}\nx`, "a marked body is kept as it is"); const comments = [{ id: 1, body: "a review" }, { id: 2, body: `${VIEW_MARKER}\nold` }, { id: 3, body: `${VIEW_MARKER}\nlater` }]; assert.equal(viewComment(comments)?.id, 2); assert.equal(viewComment([{ id: 1, body: "x" }]), undefined); - const s = deliveryStatus("mesh/delivery", "pending", "y".repeat(300), "https://forge.invalid/novox/x/pulls/1"); - assert.ok(s.description.length <= 140 && s.target_url); - assert.throws(() => deliveryStatus("ci/other", "success", "x"), /mesh's own/); - assert.throws(() => deliveryStatus("mesh/delivery", "green", "x"), /not a status/); - // The merge check's statuses are its verdict's alone: required by a branch, they are never set by hand. - assert.throws(() => deliveryStatus("mesh/merge-gate", "success", "x"), /merge check's status/); - assert.throws(() => deliveryStatus("mesh/repo-check", "warning", "x"), /merge check's status/); - assert.throws(() => deliveryStatus("mesh/merge-gate", "failure", "x"), /merge check's status/); - // Pending is no verdict: a recheck says it so the old verdict does not let the head merge meanwhile. - assert.equal(deliveryStatus("mesh/merge-gate", "pending", "checking again: main moved on").state, "pending"); - assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check"); - assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined); }); -// Issue 1301-S4 (ADR 0447 §1; R4e findings K1 and R2-1): main requires mesh/check, so no hand may set it. The status tool -// every caller may use refuses it in every state; no tool writes it at all: it is written from the checker's owed state -// (check.ts, test/check-owed.test.ts; ADR 0521). -test("mesh/check is refused by the status tool in every state", async () => { - const { commitStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts"); - assert.equal(CHECK_STATUS, "mesh/check"); - const sha = "0123456789abcdef0123456789abcdef01234567"; - const sent: string[] = []; - const setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`); - const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } }; - for (const state of ["pending", "success", "failure", "error", "warning"]) { - assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/); - await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha, - context: "mesh/check", state, description: "by hand" }), /mesh-delivery-checker\.owed/); - } - assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check"); -}); diff --git a/modules/gitea/test/gate.test.ts b/modules/gitea/test/gate.test.ts index eee5342b6..404452c84 100644 --- a/modules/gitea/test/gate.test.ts +++ b/modules/gitea/test/gate.test.ts @@ -42,8 +42,7 @@ function aForge() { }; } -// **A recheck's pending goes through the one writer** (novox/hq issue 456). mesh-delivery's recheck puts the gate back -// to pending through the gitea_commit_status tool. +// **A recheck's pending goes through the one writer** (novox/hq issue 456). const recheckAt = new Date("2026-10-11T01:30:00Z"); const checkingAgain = { state: "pending" as const, context: "mesh/merge-gate", description: "checking again: main moved on" }; @@ -63,30 +62,8 @@ test("a recheck the forge does not take fails its caller, and the next write sti const gates = new GateWriter(forge, () => recheckAt); await gates.pending("novox", "hq", SHA); forge.failWrites(); - await assert.rejects(gates.rechecked("novox", "hq", SHA, checkingAgain)); - await gates.rechecked("novox", "hq", SHA, checkingAgain); + await assert.rejects(gates.status("novox", "hq", SHA, checkingAgain)); + await gates.status("novox", "hq", SHA, checkingAgain); assert.equal(gateOf(forge), "pending checking again: main moved on"); }); -test("the status tool writes the merge check's pending through the writer, and the mesh's other statuses as they were", async () => { - const { GateWriter } = await import("../gate.ts"); - const { commitStatusTool } = await import("../delivery.ts"); - const forge = aForge(); - const direct: string[] = []; - const gates = new GateWriter(forge, () => recheckAt); - const tool = commitStatusTool({ - setCommitStatus: async (_o, _r, _sha, s) => void direct.push(`${s.context} ${s.state}`), - gates, - }); - await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "pending", description: "checking again: x" }); - await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/repo-check", state: "pending", description: "checking again: x" }); - await tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/delivery", state: "success", description: "delivered" }); - assert.equal(gateOf(forge), "pending checking again: x"); - assert.equal(forge.kept["mesh/repo-check"].state, "pending"); - assert.deepEqual(direct, ["mesh/delivery success"]); - // A head named short is another head to the writer: refused, nothing sent. - await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA.slice(0, 12), context: "mesh/merge-gate", state: "pending" }), - /whole sha/); - await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: SHA, context: "mesh/merge-gate", state: "success" }), - /set by its verdict only/); -}); diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts index 3b93f56e7..df2cfa1fb 100644 --- a/modules/gitea/test/guard.test.ts +++ b/modules/gitea/test/guard.test.ts @@ -1,22 +1,17 @@ // What the forge's tools refuse (guard.ts; novox/hq ADR 0266): an agent reaches the forge through these tools // with a token minted for the forge's administrator, so the tools — not the forge — are what keeps it from -// dropping a branch's protection, writing to main, setting the merge check's status, or merging past the -// checks. Each route a review found is reproduced here against a fake forge, and each is refused with nothing -// sent; the operator-approved merge of a pull request whose checks passed still goes through. +// dropping a branch's protection, writing to main, setting the merge check's status, or merging. Each route a review +// found is reproduced here against a fake forge, and each is refused with nothing sent; a merge is the seat git's +// merge-pull alone, at the head the caller names (the last test's rows). 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 } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { readFileSync } from "node:fs"; import { GiteaClient } from "../dist/client.js"; import { ConfiguredToken } from "../dist/token.js"; import { getGiteaTools } from "../dist/tools/index.js"; -import { mergeTool } from "../dist/merge.js"; -import { commitStatusTool } from "../dist/delivery.js"; -import { GateWriter } from "../dist/gate.js"; import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js"; interface Sent { method: string; path: string; body: any } @@ -54,6 +49,8 @@ const state = { groupStatus: "", siblingGroup: "", reviews: [] as { state: string; commit_id: string; dismissed?: boolean }[], + // The forge's own mergeable of pull request 7; unset, the forge does not say. + mergeable: undefined as boolean | undefined, }; const REVIEWED = "abc1234def5678901234567890abcdef12345678"; const SIBLING_HEAD = "bbbbbbbbbbbb5678901234567890abcdef12345678".slice(0, 40); @@ -107,7 +104,7 @@ const server = createServer(async (req, res) => { state.asked.push(`${method} ${path}${url.search}`); if (method !== "GET") state.sent.push({ method, path, body }); const repo = "/repos/novox/mesh-catalog"; - if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch }); + if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch, permissions: { push: true } }); if (method === "PATCH" && path === repo) { if (body?.default_branch) state.defaultBranch = body.default_branch; return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch }); @@ -146,7 +143,7 @@ const server = createServer(async (req, res) => { if (method === "GET" && path === `${repo}/pulls/7`) { return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged, head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7", - merge_commit_sha: state.merged ? state.mergeCommit : null }); + merge_commit_sha: state.merged ? state.mergeCommit : null, ...(state.mergeable === undefined ? {} : { mergeable: state.mergeable }) }); } if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) { if (state.commitsFail) return json(res, state.commitsFail, { message: "the forge is not well" }); @@ -165,6 +162,7 @@ const server = createServer(async (req, res) => { return json(res, 200, url.searchParams.get("page") === "1" ? state.reviews : []); } if (method === "GET" && path === "/repos/novox/mesh-host/issues/114/labels") return json(res, 200, []); + if (method === "GET" && path === `${repo}/pulls/404`) return json(res, 404, { message: "pull request does not exist" }); if (method === "POST" && path === `${repo}/pulls/7/merge`) { state.merged = true; return json(res, 200, null); @@ -175,20 +173,7 @@ await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; const client = new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")); -// gitea_commit_status is served from the watcher's bundle, beside the merge check's one writer (novox/hq issue 456). -const stateDir = mkdtempSync(join(tmpdir(), "gitea-tools-")); -// The merge check's one writer, as the watcher's bundle holds it: what the merge tool's pending goes through. -const writer = new GateWriter({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), - headChecks: async () => ({}) }); -const throughTheWriter: string[] = []; -const gates = { rechecked: (o: string, r: string, sha: string, s: any) => { - throughTheWriter.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`); - return writer.rechecked(o, r, sha, s); -} }; -const tools = [...getGiteaTools(client), commitStatusTool({ - setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer, -}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); }, - now: () => new Date(2026, 9, 12, 15, 1) })]; +const tools = getGiteaTools(client); const tool = (name: string) => tools.find((t) => t.name === name)!; const writes = () => state.sent.map((s) => `${s.method} ${s.path}`); @@ -276,203 +261,6 @@ test("gitea_branch_protection_set writes no required status, and refuses a push assert.equal(sent.enable_push, false); }); -test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => { - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED }); - state.statuses = [judgedAt(TRUNK)[0], judgedAt(TRUNK, "failure")[1]]; - await assert.rejects(merge(), /not merged: .*mesh\/repo-check \(failure\)/); - state.statuses = [judgedAt(TRUNK)[0]]; - await assert.rejects(merge(), /mesh\/repo-check \(missing\)/); - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method: "manually-merged" }), /not a merge method/); - assert.deepEqual(writes(), []); - // The operator said yes and the checks passed: the flow that stays. - state.statuses = judgedAt(TRUNK); - await merge(); - const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!; - assert.equal(sent.body.force_merge, false); - assert.equal(sent.body.head_commit_id, REVIEWED); -}); - - -// **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; 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 }); - - 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, /never merges by itself/); - return true; - }); - assert.equal(state.merged, false); - assert.equal(state.emitted.length, 1, "one check asked"); - assert.equal(state.emitted[0].type, "pull.updated"); - assert.equal(state.emitted[0].body.head_sha, REVIEWED); - assert.equal(state.emitted[0].body.base, "main"); - assert.deepEqual(state.emitted[0].body.paths, ["modules/gitea/x.ts"]); - // The announcement names the trunk's head, for a verdict no build judges (ADR 0323 §3). - assert.equal(state.emitted[0].body.base_sha, MOVED); - // Its pending went through the merge check's one writer (novox/hq issue 456), not around it. - assert.deepEqual(throughTheWriter.splice(0), ["abc1234def56 mesh/merge-gate pending", "abc1234def56 mesh/repo-check pending"]); - const gate = state.statuses.find((s) => s.context === "mesh/merge-gate")!; - assert.equal(gate.state, "pending"); - assert.match(gate.description!, /^waiting in the build queue since 15:01: asked by the merge tool against trunk 3c4d5e6f7a8b/); - 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.*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/); - assert.equal(state.emitted.length, 1); - - // A verdict put out of date by the trunk's move is asked too, once. - state.statuses = judgedAt(TRUNK).map((s) => ({ ...s, state: "pending", - description: "out of date: judged against trunk 7a8b9c0d1e2f, the trunk is now 3c4d5e6f7a8b; judged again on a recheck or when it is merged" })); - await assert.rejects(merge(), /was judged against trunk 7a8b9c0d1e2f, and main is now 3c4d5e6f7a8b\. The check .* was asked/); - assert.equal(state.emitted.length, 2); - - // A verdict that names no trunk commit — a build seat from before the rule — is not taken as the trunk's head. - state.statuses = [{ context: "mesh/merge-gate", state: "success", description: "pass: x" }, - { context: "mesh/repo-check", state: "success", description: "pass: y" }]; - await assert.rejects(merge(), /names no trunk commit it was judged against, and main is now 3c4d5e6f7a8b/); - assert.equal(state.emitted.length, 3); - assert.equal(state.merged, false); - - // An ask that cannot be announced puts the statuses back to out of date, so the next ask asks again. - state.statuses = judgedAt(TRUNK); - const failing = mergeTool(client, { gates, stateDir, emit: async () => { throw new Error("the bus is away"); } }); - await assert.rejects(failing.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }), - /its check could not be asked: the bus is away\. Nothing was merged/); - assert.match(state.statuses.find((s) => s.context === "mesh/merge-gate")!.description!, /^out of date: /); - await assert.rejects(merge(), /was asked/); - assert.equal(state.emitted.length, 4, "asked again after the announcement failed"); - - // The check judged the merge into the trunk's head and passed: asked again at the reviewed head, it merges. - state.statuses = judgedAt(MOVED); - const done = (await merge()) as Record; - assert.equal(done.merged, true); - assert.equal(state.emitted.length, 4, "a merge asks no check"); -}); - -// **A group member whose gate waits for its group's composed check is not announced again by the merge tool** (novox/hq issue -// 1106): the member's own check can only say "composed" again, so each refusal's ask brought a verdict that names no trunk and -// the next merge was refused the same way, for ever. The tool says whose verdict is awaited and asks nothing. -test("gitea_merge_pull_request asks no check of a member that waits for its group's composed check", async () => { - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - state.trunk = TRUNK; - const before = state.emitted.length; - state.statuses = [{ context: "mesh/merge-gate", state: "pending", description: "waits for its group's composed check" }, - { context: "mesh/repo-check", state: "success", description: "pass: y" }]; - for (let i = 0; i < 3; i++) { - await assert.rejects(merge(), (err: Error) => { - assert.match(err.message, /waits for its group's composed check/); - assert.match(err.message, /mesh-delivery asks it, this tool asks no check/); - assert.match(err.message, /Nothing was merged/); - return true; - }); - } - assert.equal(state.emitted.length, before, "no check is announced again and again"); - assert.equal(state.merged, false); - // Once the composed verdict names the member's unmoved trunk, it merges. - state.statuses = judgedAt(TRUNK); - assert.equal(((await merge()) as Record).merged, true); -}); - -// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head, -// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was. -test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => { - const merge = (head?: string) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - ...(head === undefined ? {} : { head }) }); - - // No head named: there is no review to hold the merge to. - await assert.rejects(merge(), /name the head you reviewed/); - await assert.rejects(merge(" "), /name the head you reviewed/); - await assert.rejects(merge("main"), /does not name one commit/); - // Seven characters can be matched by a commit made to match them: twelve are asked for. - await assert.rejects(merge(REVIEWED.slice(0, 7)), /does not name one commit — give its sha, or at least its first 12/); - await assert.rejects(merge(REVIEWED.slice(0, 11)), /does not name one commit/); - - // The head moved after the review, and its checks passed where it is now: the case of mesh-controller#197. - state.head = "3ced96fc6f8ad6cc7b93698912cb08a71635158a"; - await assert.rejects(merge(REVIEWED), /its head is 3ced96fc6f8a, not the abc1234def5678901234567890abcdef12345678 you named/); - await assert.rejects(merge(REVIEWED.slice(0, 12)), /not the abc1234def56 you named\. If you reviewed an earlier head/); - assert.deepEqual(writes(), [], "a refused merge sends nothing"); - - // Named as reviewed, by its first 12 characters, in either case: the forge is handed the whole head, and the merge - // commit's second parent says that head landed. - state.head = REVIEWED; - const done = (await merge(REVIEWED.slice(0, 12).toUpperCase())) as Record; - const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!; - assert.equal(sent.body.head_commit_id, REVIEWED); - assert.equal(done.head, REVIEWED); - assert.equal(done.landed, REVIEWED); - assert.equal(done.read_back, undefined); -}); - -// The forge is handed the head, and what landed is read from the merge commit, not from the forge's record of the -// pull request (which here still says the head it was handed). Another head landing is an error that says the merge -// happened, never a field beside merged: true. -test("gitea_merge_pull_request is loud when the forge merged a head other than the one it was handed", async () => { - state.landed = "3ced96fc6f8ad6cc7b93698912cb08a71635158a"; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED }), /WAS merged \(feedface0000.*\), but the forge merged 3ced96fc6f8a, not the abc1234def56 it was handed/); - assert.equal(state.merged, true, "the merge happened: the error is what says so"); -}); - -// The merge has happened once the forge says so: a read-back that fails after it must not read as nothing merged. -test("gitea_merge_pull_request says the merge happened when reading back what landed fails", async () => { - state.commitsFail = 500; - const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED })) as Record; - assert.equal(state.merged, true); - assert.equal(done.merged, true); - assert.equal(done.landed, undefined); - assert.match(String(done.read_back), /the merge happened, but reading back what landed failed/); - - // A parent the forge names without its sha is a failed read-back, never an empty head said to have landed. - state.merged = false; - state.commitsFail = 0; - state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b", ""]; - const blank = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED })) as Record; - assert.equal(blank.merged, true); - assert.match(String(blank.read_back), /without its sha/); -}); - -test("gitea_merge_pull_request reads back a fast-forward, and says it cannot read back a squash or a rebase", async () => { - state.mergeCommit = REVIEWED; // a fast-forward lands the head itself - // Its parents name another commit second, so reading a fast-forward as a merge's second parent would fail here. - state.parents = ["8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b"]; - const ff = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method: "fast-forward-only" })) as Record; - assert.equal(ff.landed, REVIEWED); - assert.equal(ff.read_back, undefined); - - // A fast-forward whose commit the forge names otherwise is said, not raised: that it names the head is assumed. - state.merged = false; - state.mergeCommit = "feedface00000000000000000000000000000000"; - const odd = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method: "fast-forward-only" })) as Record; - assert.equal(odd.merged, true); - assert.equal(odd.landed, undefined); - assert.match(String(odd.read_back), /as this fast-forward's commit, not the head/); - - for (const method of ["squash", "rebase", "rebase-merge"]) { - state.merged = false; - state.mergeCommit = "feedface00000000000000000000000000000000"; - const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, method })) as Record; - assert.equal(done.landed, undefined, method); - assert.match(String(done.read_back), new RegExp(`a ${method} makes new commits that name no head`)); - } -}); test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => { state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) @@ -496,65 +284,6 @@ test("gitea_api sends the path it judged, and refuses moving a pull request's ba assert.equal(writes().length, 2); }); -test("gitea_merge_pull_request merges only into a branch whose protection refuses its push", async () => { - const approved_heads = [`novox/mesh-catalog@${REVIEWED}`]; - state.statuses = [{ context: "mesh/check", state: "success", description: `pass ${REVIEWED.slice(0, 12)} on ${TRUNK.slice(0, 12)}` }]; - state.rules = []; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, - approved_heads }), /no protection rule refusing/); - state.rules = [{ rule_name: "main", enable_push: true, enable_status_check: false, status_check_contexts: [] }]; - await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, - approved_heads }), /no protection rule refusing/); - assert.deepEqual(writes(), []); -}); - -// **The forge requires no status to merge; the merge tool still judges mesh/check, and asks the operator's Approve** -// (novox/hq ADR 0534 §4): on a main whose rule takes no push and requires no status, a head merges only with -// approved_heads naming it and a mesh/check that passed against the trunk's head at the merge. -test("gitea_merge_pull_request judges mesh/check on a main requiring no status, and merges only an approved head", async () => { - const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); - keepSiblings(siblingsFile(stateDir), {}); - const approved_heads = [`novox/mesh-catalog@${REVIEWED}`]; - const merge = (args: Record = { approved_heads }) => - tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args }); - state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [], - block_admin_merge_override: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }]; - const line = (trunk: string, s = "success", words = "pass") => - [{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }]; - const before = state.emitted.length; - - state.statuses = line(TRUNK); - await assert.rejects(merge({}), /Approve/); - state.statuses = []; - await assert.rejects(merge(), /mesh\/check is not set yet/); - state.statuses = line(TRUNK, "pending", "checking"); - await assert.rejects(merge(), /mesh\/check is pending/); - state.statuses = line(TRUNK, "failure", "fail"); - await assert.rejects(merge(), /mesh\/check is failure/); - state.statuses = line(MOVED); - await assert.rejects(merge(), new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`)); - state.statuses = judgedAt(TRUNK); // the old two, current: not mesh/check - await assert.rejects(merge(), /mesh\/check/); - assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's"); - assert.equal(state.emitted.length, before, "no check is asked"); - assert.equal(state.merged, false); - - state.statuses = line(TRUNK); - assert.equal(((await merge()) as Record).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 = { 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).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 () => { state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365) state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true, @@ -635,9 +364,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud ["gitea_delete_branch", { ...repo, branch: "%2e%2e" }], ["gitea_get_file", { ...repo, path: "../../../../version" }], ["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }], - ["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }], - ["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }], - ["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }], ["gitea_commit_statuses", { ...repo, sha: ".." }], ["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }], ["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }], @@ -661,172 +387,6 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud }); -// **A member judged beside its siblings merges only with its group passed and the Approve covering each head** (novox/hq -// ADR 0382 §6 as ADR 0421 §9 changes it, issues 867 and 1022): the verdict names the sibling; mesh/delivery-group must -// be a success at the member's head and the sibling's head that verdict read, and mesh-delivery hands the heads the -// operator's Approve covers: every head read must be among them. No forge review is asked. A verdict that read none -// merges as before. -test("gitea_merge_pull_request merges a verdict that read siblings only with its group passed and each head approved", async () => { - const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); - const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD]; - const merge = (approved_heads?: string[]) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, ...(approved_heads ? { approved_heads } : {}) }); - const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${SIBLING_HEAD.slice(0, 8)}`; - const named = (siblings: unknown[]) => keepSiblings(siblingsFile(stateDir), - { "novox/mesh-catalog#7": { head: REVIEWED, siblings: siblings as any } }); - state.statuses = [{ context: "mesh/merge-gate", state: "success", description: `${beside} — pass: x` }, - { context: "mesh/repo-check", state: "success", description: `${beside} — pass: y` }]; - named([{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: "c".repeat(40) }]); - - // Nothing passed and no approved heads handed: every missing thing is named, nothing merged. - await assert.rejects(merge(), (err: Error) => { - assert.match(err.message, /is not merged: its verdict was judged beside 1 sibling\(s\), and mesh\/delivery-group is missing at mesh-catalog#7@abc1234def56; mesh-catalog#7@abc1234def56 is covered by no Approve: the merge was handed no approved heads; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb is covered by no Approve/); - return true; - }); - assert.equal(state.merged, false); - - // The group passed at the member's head only: the sibling's head still lacks it. - state.groupStatus = "success"; - await assert.rejects(merge(both), /mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb\. A member judged beside/); - // Passed at both heads, but the Approve covers the sibling at another head, or does not name it: not an approved head. - state.siblingGroup = "success"; - await assert.rejects(merge(["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + "d".repeat(40)]), - /mesh-host#114@bbbbbbbbbbbb is not the head the Approve covers \(dddddddddddd\)\. A member judged beside/); - await assert.rejects(merge(["novox/mesh-catalog@" + REVIEWED]), /mesh-host#114@bbbbbbbbbbbb is not among the heads the Approve covers/); - assert.equal(state.merged, false); - - // Passed and approved at the very heads read, with no forge review of the sibling: it merges. - assert.deepEqual(state.reviews, [], "no review is asked of the forge"); - assert.equal(((await merge(both)) as Record).merged, true); - - // A status that says it read siblings, with no record of them, is no verdict to merge on. - state.merged = false; - keepSiblings(siblingsFile(stateDir), {}); - await assert.rejects(merge(both), /its verdict read siblings, and which ones is not held here/); - assert.equal(state.merged, false); - - // A verdict that read none (an older controller, or a group of one) merges exactly as before. - state.statuses = judgedAt(TRUNK); - state.groupStatus = ""; - assert.equal(((await merge()) as Record).merged, true); -}); - -// **The merge lands only the head the Approve named** (novox/hq ADR 0421 §9, issue 1022): a pull request whose head -// differs from the approved one, or whose repository the Approve does not name, is refused with nothing merged. -test("gitea_merge_pull_request refuses a head the Approve did not name", async () => { - state.merged = false; - state.statuses = judgedAt(TRUNK); - const merge = (approved_heads: unknown) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, - head: REVIEWED, approved_heads }); - await assert.rejects(merge(["novox/mesh-catalog@" + "e".repeat(40)]), /is not the head the Approve covers \(eeeeeeeeeeee\)/); - await assert.rejects(merge(["novox/mesh-host@" + REVIEWED]), /is not among the heads the Approve covers/); - await assert.rejects(merge(["novox/mesh-catalog@abc123"]), /which is not owner\/repo@/); - await assert.rejects(merge("novox/mesh-catalog@" + REVIEWED), /must be a list/); - assert.equal(state.merged, false); - assert.equal(((await merge(["novox/mesh-catalog@" + REVIEWED.toUpperCase()])) as Record).merged, true); -}); - -// **A group approved together merges in one pass, on the real merge tool** (novox/hq ADR 0418, issue 1049): the member's -// own verdict named its sibling at an old head; the sibling got a new head, the group's composed verdict passed for both, -// the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its -// base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does. -test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => { - const { keepSiblings, siblingsFile, weighSiblings } = await import("../dist/siblings.js"); - const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD]; - const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40); - const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`; - state.statuses = [{ context: "mesh/merge-gate", state: "success", - description: `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} \u2014 pass: group g composed: ok` }, - { context: "mesh/repo-check", state: "success", description: `${beside} \u2014 pass: y` }]; - state.groupStatus = "success"; - state.siblingGroup = "success"; - // The member's own verdict named the sibling at its old head. - const record: Record = { "novox/mesh-catalog#7": { head: REVIEWED, - siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } }; - // The composed verdict then judged both new heads. - Object.assign(record, { - "novox/mesh-catalog#7": { head: REVIEWED, siblings: [{ repository: "novox/mesh-host", number: 114, head: SIBLING_HEAD, base: "main", base_commit: HOST_BASE.slice(0, 12) }] }, - "novox/mesh-host#114": { head: SIBLING_HEAD, siblings: [{ repository: "novox/mesh-catalog", number: 7, head: REVIEWED, base: "main", base_commit: TRUNK.slice(0, 12) }] } }); - // The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what - // that weighing decides, through the writer. - const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE }; - const forge = { - async getPullRequest(_o: string, repo: string, n: number) { - return repo === "mesh-host" ? pulled : { number: n, title: "", html_url: "", merged: false, state: "open", head_sha: REVIEWED }; - }, - async branchHead() { return HOST_MERGE; }, - async commitParents() { return [HOST_BASE, SIBLING_HEAD]; }, - }; - const wrote: string[] = []; - const gatesOut = { async outOfDate(_o: string, _r: string, sha: string, decide: (c: any) => any[]) { - const put = decide({ "mesh/merge-gate": { state: "success", description: state.statuses[0].description }, - "mesh/repo-check": { state: "success", description: "x" } }); - for (const s of put) { - wrote.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`); - state.statuses = [{ ...s }, ...state.statuses.filter((x) => x.context !== s.context)]; - } - return put; - } }; - await weighSiblings(forge as any, gatesOut as any, record, "novox/mesh-host", 114); - assert.deepEqual(wrote, [], "the member's statuses were left alone"); - keepSiblings(siblingsFile(stateDir), record); - // The member merges on its first ask, with the one Approve's heads. - const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, - approved_heads: both })) as Record; - assert.equal(done.merged, true); - assert.equal(state.emitted.length, 0, "no check was asked"); -}); - -// **Issue 1301 (ADR 0447 §1, §4; ADR 0503 §3, §5): the merge tool judges on the status the base requires.** A main that -// requires mesh/check is judged by the checker's line ("pass on …"): it merges on a pass that names the -// trunk's head, and refuses one that names an older trunk, naming both, writing nothing and asking nothing — a re-check on -// a trunk's move is the checker's. No mesh/merge-gate is read or written there. -test("gitea_merge_pull_request judges a main requiring mesh/check on mesh/check alone, and asks nothing of it", async () => { - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/check"] }]; - const line = (trunk: string, s = "success", words = "pass") => - [{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }]; - const before = state.emitted.length; - - state.statuses = line(MOVED); - await assert.rejects(merge(), (err: Error) => { - assert.match(err.message, /mesh\/check/); - assert.match(err.message, new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`)); - assert.match(err.message, /checker checks it again/i); - assert.match(err.message, /Nothing was merged/); - return true; - }); - state.statuses = line(TRUNK, "pending", "checking"); - await assert.rejects(merge(), /mesh\/check is pending/); - state.statuses = judgedAt(TRUNK); // the old two, current: not what this main requires - await assert.rejects(merge(), /mesh\/check/); - assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's"); - assert.equal(state.emitted.length, before, "no check is asked"); - assert.equal(state.merged, false); - - state.statuses = line(TRUNK); - assert.equal(((await merge()) as Record).merged, true); -}); - -// hq's main requires mesh/repo-check alone (ADR 0503 §5): judged on its words, and an out-of-date one is asked again on -// mesh/repo-check alone; no mesh/merge-gate is written on it. -test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on it, and writes no mesh/merge-gate", async () => { - const { keepSiblings, siblingsFile } = await import("../dist/siblings.js"); - keepSiblings(siblingsFile(stateDir), {}); // no sibling record left by the tests before - const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }); - state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/repo-check"] }]; - state.statuses = [judgedAt(MOVED)[1]]; - const before = state.emitted.length; - await assert.rejects(merge(), /judged against trunk/); - assert.deepEqual(state.sent.filter((s) => s.path.includes("/statuses/")).map((s) => `${s.body.context} ${s.body.state}`), - ["mesh/repo-check pending"]); - assert.equal(state.emitted.length, before + 1, "its check is asked once"); - await assert.rejects(merge(), /already asked/); - state.sent = []; - state.statuses = [judgedAt(TRUNK)[1]]; - assert.equal(((await merge()) as Record).merged, true); -}); - // Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as // gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards. test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => { @@ -870,3 +430,167 @@ test("the two new tools are named for what mesh_search is asked, and gitea_api s assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" })); assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" })); }); + +// **The seat git's verbs judge their caller, and merge-pull judges the Approve's head and no check** (design 47 *The seat +// git*, ADR 0661 §3): a caller the trusted list does not name is refused before the forge is asked; merge-pull merges the +// head it is named into a base that takes no push, whatever its statuses say, and an id said again after the merge is +// answered performed. +test("the seat git's verbs judge their caller, and merge-pull merges the Approve's head and judges no check", async () => { + const { seatTools, gitCallers } = await import("../dist/seat.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const bridge = "ace.mesh-delivery-forge-bridge"; + 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"); + assert.deepEqual(gitCallers(undefined), {}, "unset, no one may call"); + // The head's one writer of its statuses (gate.ts), over a forge whose first write is still in flight when the next is asked. + const { GateWriter } = await import("../dist/gate.js"); + const landed: string[] = []; + let inFlight: Promise | null = null, land = () => {}; + const slow = { + headChecks: async () => ({}), + setCommitStatus: async (_o: string, _r: string, _sha: string, s: { state: string; description: string }) => { + if (inFlight) { const w = inFlight; inFlight = null; await w; } + landed.push(`${s.state} ${s.description}`); + }, + }; + 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 }); + 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 }[] = [ + { 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 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 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 = []; + state.rules = [protectedMain()]; state.prState = "open"; state.parents = null; state.commitsFail = 0; state.landed = ""; + r.set(); + const got = await merge(r.extra, r.caller); + if (r.want === "merged") { + assert.equal(got.performed, true, `${r.name}: ${JSON.stringify(got)}`); + assert.equal(got["merge-commit"], state.mergeCommit, r.name); + assert.equal(state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))?.body.head_commit_id, REVIEWED, r.name); + } else if (r.want === "held") { + assert.deepEqual([got.performed, got.held], [true, true], r.name); + assert.deepEqual(writes(), [], `${r.name}: nothing merged again`); + } else { + assert.match(String(got.refused ?? got.error), r.want, r.name); + assert.deepEqual(writes(), r.loud ? ["POST /repos/novox/mesh-catalog/pulls/7/merge"] : [], `${r.name}: what was merged`); + } + } + // A read: an open pull request answers what the forge would merge now; a forge that fails is the kind failed. + state.merged = false; state.head = REVIEWED; state.rules = [protectedMain()]; + const pull = await call("pull", { repository: "novox/mesh-catalog", number: 7 }); + assert.equal(pull.pull, undefined, "answered whole or not at all"); + assert.equal(pull.error?.startsWith("the forge did not say whether novox/mesh-catalog#7 merges now"), true, JSON.stringify(pull)); + assert.equal(pull["error-kind"], "failed", "a read the forge could not make answers the verb's error, never a guess"); + state.mergeable = true; + const open = await call("pull", { repository: "novox/mesh-catalog", number: 7 }); + state.mergeable = undefined; + assert.deepEqual([open.pull?.head, open.pull?.state, open.merging?.protected, open.merging?.required], + [REVIEWED, "open", true, ["mesh/merge-gate", "mesh/repo-check"]], JSON.stringify(open)); + assert.equal("body" in (open.pull ?? {}), false, "never its description"); + // Two writes of one head out of order (issue 456): a recheck's pending still in flight when the verdict is set through the + // seat; the verdict takes its turn after it and is what the head carries. + inFlight = new Promise((r) => (land = r)); + const recheck = gates.pending("novox", "mesh-catalog", REVIEWED); + const verdict = call("status", { id: "act-s", repository: "novox/mesh-catalog", commit: REVIEWED, state: "success", + context: "mesh/merge-gate", description: "passed" }); + await new Promise((r) => setImmediate(r)); + land(); + await recheck; + assert.equal((await verdict).performed, true); + assert.equal(landed.at(-1), "success passed", `the verdict lands last: ${JSON.stringify(landed)}`); + // A pull request the forge does not have is the kind not-found, so a caller never reads it out of the words. + const gone = await call("pull", { repository: "novox/mesh-catalog", number: 404 }); + assert.deepEqual([gone.pull, gone["error-kind"]], [undefined, "not-found"], JSON.stringify(gone)); +}); + +// Each verb of the seat git answers the contract's shape from what the forge gives, and an error naming why when the forge +// does not (design 47 §310, §312, §317): an empty list or an absent file only when the forge said so, a forge that failed +// the kind failed, a 404 the kind not-found, and an act whose id the pull request or the note already holds is held. +test("every read and act of the seat git answers its contract, and an error is an error, never an empty answer", async () => { + const { seatTools } = await import("../dist/seat.js"); + const { runAs } = await import("@novox/mesh-sdk/callers"); + const SHA = "a".repeat(40), OTHER = "b".repeat(40); + const gone = () => { throw new Error("Gitea API GET /repos/novox/app/pulls/9: 404 Not Found"); }; + const down = () => { throw new Error("Gitea API GET /repos/novox/app/x: 502 Bad Gateway"); }; + const pr = { number: 9, title: "t", state: "open", merged: false, head: "feat/x", head_sha: SHA, base: "main", user: "jochen", html_url: "http://f/9", mergeable: true }; + let comments: { id: number; body: string }[] = [], noteShown = "", added = 0, noteWritten = 0; + const forge: Record = { + getPullRequest: async (_o: string, _r: string, n: number) => (n === 9 ? pr : gone()), + pullOfCommit: async () => ({ ...pr, state: "closed", merged: true, merge_commit_sha: OTHER, merged_at: "2026-11-11T10:00:00Z" }), + appliedProtection: async () => ({ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/check"] }), + getRepo: async () => ({ writes: true }), + openPulls: async () => [{ owner: "novox", repo: "app", number: 9, base: "main", head: "feat/x", head_sha: SHA, required: ["mesh/check"], checks: {} }], + tags: async () => ["go/v0.1.1", "go/v0.1.0", "v1"], + commitTrees: async () => ({ root: SHA, trees: { "": SHA } }), + getFile: async (_o: string, _r: string, path: string) => (path === "gone" ? (() => { throw new Error("Gitea API GET /repos/novox/app/contents/gone: 404 Not Found"); })() : path === "down" ? down() : { path, sha: OTHER, size: 3, content: "abc" }), + compare: async () => ({ total_commits: 2, commits: [{ sha: OTHER }, { sha: SHA }] }), + combinedStatus: async () => ({ sha: SHA, state: "success", statuses: [{ context: "mesh/check", status: "success", description: "ok" }] }), + listComments: async () => comments, + addComment: async (_o: string, _r: string, _n: number, body: string) => { added++; comments.push({ id: added, body }); }, + editComment: async () => {}, + }; + 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 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 () => {} } }); + 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][] = [ + ["pull", "by number", { repository: "novox/app", number: 9 }, {}, (a) => [a.pull.head, a.pull.state, a.merging.mergeable, a.merging.required, a.merging.writes], [SHA, "open", true, ["mesh/check"], true]], + ["pull", "by merge commit", { repository: "novox/app", "merge-commit": OTHER }, {}, (a) => [a.pull.state, a.pull["merge-commit"], a.merging], ["merged", OTHER, undefined]], + ["pull", "unknown", { repository: "novox/app", number: 404 }, {}, (a) => [a.pull, a["error-kind"]], [undefined, "not-found"]], + ["pull", "forge down", { repository: "novox/app", number: 9 }, { getPullRequest: down }, (a) => [a.pull, a["error-kind"]], [undefined, "failed"]], + ["open-pulls", "one", {}, {}, (a) => [a.pulls.length, a.pulls[0].required], [1, ["mesh/check"]]], + ["open-pulls", "forge down", {}, { openPulls: down }, (a) => [a.pulls, a["error-kind"]], [undefined, "failed"]], + ["tags", "a page", { repository: "novox/app", prefix: "go/" }, {}, (a) => [a.tags, a.page, a.bound, a.more], [["go/v0.1.1", "go/v0.1.0"], 1, 50, undefined]], + ["tags", "forge down", { repository: "novox/app" }, { tags: down }, (a) => [a.tags, a["error-kind"]], [undefined, "failed"]], + ["trees", "root", { repository: "novox/app", commit: SHA }, {}, (a) => [a.commit, a.root], [SHA, SHA]], + ["trees", "forge down", { repository: "novox/app", commit: SHA }, { commitTrees: down }, (a) => [a.root, a["error-kind"]], [undefined, "failed"]], + ["file", "present", { repository: "novox/app", path: "a.txt" }, {}, (a) => [a.content, a.absent], ["abc", undefined]], + ["file", "the forge said absent", { repository: "novox/app", path: "gone" }, {}, (a) => [a.absent, a.error], [true, undefined]], + ["file", "forge down is no absent file", { repository: "novox/app", path: "down" }, {}, (a) => [a.absent, a["error-kind"]], [undefined, "failed"]], + ["contains", "ahead", { repository: "novox/app", commit: SHA, in: OTHER }, {}, (a) => [a.contains, a.ahead], [false, 2]], + ["contains", "forge down", { repository: "novox/app", commit: SHA, in: OTHER }, { compare: down }, (a) => [a.contains, a["error-kind"]], [undefined, "failed"]], + ["statuses", "newest of each", { repository: "novox/app", commit: SHA }, {}, (a) => [a.combined, a.statuses.map((s: any) => s.context)], ["success", ["mesh/check"]]], + ["statuses", "forge down", { repository: "novox/app", commit: SHA }, { combinedStatus: down }, (a) => [a.statuses, a["error-kind"]], [undefined, "failed"]], + ["status", "refused state", { id: "s1", repository: "novox/app", commit: SHA, context: "c", state: "bogus" }, {}, (a) => [a.performed, a["error-kind"]], [undefined, "refused"]], + ["comment", "first time", { id: "c1", repository: "novox/app", number: 9, text: "hi" }, {}, (a) => [a.performed, a.held, added], [true, undefined, 1]], + ["comment", "forge down", { id: "c2", repository: "novox/app", number: 9, text: "hi" }, { listComments: down }, (a) => [a.performed, a["error-kind"]], [undefined, "failed"]], + ["view", "first time", { id: "v1", repository: "novox/app", number: 9, text: "state" }, {}, (a) => [a.performed, a.held], [true, undefined]], + ["note", "a line", { id: "n1", repository: "novox/app", commit: SHA, line: "planned" }, {}, (a) => [a.performed, a.held, noteWritten], [true, undefined, 1]], + ]; + for (const [verb, name, args, over, pick, want] of rows) { + assert.deepEqual(pick(await call(verb, args, over)), want, `${verb}: ${name}`); + } + // The same comment id said again is held, and nothing is written again; so is a note line whose id the note holds. + const before = added; + const again = await call("comment", { id: "c1", repository: "novox/app", number: 9, text: "hi" }); + assert.deepEqual([again.performed, again.held, added], [true, true, before]); + const view = await call("view", { id: "v1", repository: "novox/app", number: 9, text: "state" }); + assert.deepEqual([view.performed, view.held], [true, true]); + noteShown = "planned [n1]"; noteWritten = 0; + 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]); +}); diff --git a/modules/gitea/test/kept.test.ts b/modules/gitea/test/kept.test.ts index a526eb104..72144e862 100644 --- a/modules/gitea/test/kept.test.ts +++ b/modules/gitea/test/kept.test.ts @@ -16,8 +16,8 @@ test("the states the module announces are the states its manifest declares", asy test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => { const { statesAnnouncement } = await import("../kept.ts"); - assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] }); - assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }] }); + assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }, { name: "first-views" }] }); + assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound", readable: true }, { name: "findings", readable: true }, { name: "first-views" }] }); }); test("index.ts says the announcement as the event `states` before the tools are served", () => { diff --git a/modules/gitea/test/protection-full.test.ts b/modules/gitea/test/protection-full.test.ts index f07e22e76..5da3d6016 100644 --- a/modules/gitea/test/protection-full.test.ts +++ b/modules/gitea/test/protection-full.test.ts @@ -23,7 +23,6 @@ beforeEach(() => { enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0, enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }]; - source = {}; repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true }; sent = []; }); @@ -88,11 +87,7 @@ await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; const url = `http://127.0.0.1:${port}`; -// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories -// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel. -let source: Record }> = {}; -const readSource = async () => source; -const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource); +const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); const setRepo = (args: Record) => tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args }); const setFull = (args: Record) => @@ -227,24 +222,12 @@ test("a force-push onto a rule covering main is refused, nothing sent; on anothe assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer"); }); -// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as -// the gate's source names them; "main" only when it names none, and said. -test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => { - source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } }; - await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/); - await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/); - assert.deepEqual(sent, []); - const main: any = await setFull({ enable_force_push: true }); - assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" }); - assert.equal(main.trunk_rule.is_trunk, false); - const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true }); - assert.equal(beta.trunk_rule.is_trunk, true); - assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why); -}); - -test("a source that names no trunk of the repository falls back to main, and says so", async () => { +// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branch the mesh installs from: main, +// the mesh's one trunk of every repository (design 47); no other module's state is read for it (ADR 0596 §2). +test("main is the repository's trunk, read from no other module, and another branch is none", async () => { const out: any = await setFull({ block_admin_merge_override: true }); - assert.equal(out.trunks.names[0], "main"); - assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/); + assert.deepEqual(out.trunks, { names: ["main"], from: "the mesh's one trunk of every repository (design 47)" }); assert.equal(out.trunk_rule.is_trunk, true); + const beta: any = await setFull({ branch: "beta", enable_force_push: true }); + assert.equal(beta.trunk_rule.is_trunk, false); }); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 54fc995fc..4e9fd2af0 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -527,8 +527,7 @@ test("the tools register once there is a way to a token, and the first call mint withAdmin.tools.map((t) => t.name), [ "gitea_list_repos", "gitea_create_repo", "gitea_delete_repo", - "gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment", - // gitea_merge_pull_request is served from the watcher's bundle, beside the merge check's one writer (ADR 0323). + "gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_close_pull_request", "gitea_reopen_pull_request", @@ -542,7 +541,7 @@ test("the tools register once there is a way to a token, and the first call mint "gitea_delete_branch", "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full", "gitea_default_branch_set", "gitea_pull_base_set", - "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", + "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", "gitea_list_labels", "gitea_create_label", "gitea_api", ], diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index d05ec24b6..f77b199ae 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -638,7 +638,7 @@ test("on the forge's events: a repository made is granted read at once, a known // The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator, // 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there. -test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => { +test("the merging account is given write on each repository the catalog half's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => { const env = await environment(); const client = fresh(env); await client.listRepos(); // the account and its team exist, as after any mint @@ -652,13 +652,17 @@ test("the merging account is given write on each repository the indexer's module const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l), async (type: string, body: unknown) => { said.push({ type, body }); }); const [unwritableTool, grantTool] = writeTools(() => writes); - await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read"); + await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the catalog half's first view is held"); assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write"); - // 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once. - await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true }); - await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true }); - await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true }); + // 1945/AC1: the first view hands the catalog half's modules; a user's repository a module builds from is made writable, + // once, and only once the first view is held (design 47: a part acts on nothing before its first view). + const viewing = forge.calls.length; + await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" } }); + await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" } }); + await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" } }); + assert.equal(forge.calls.length, viewing, "the first view's entries ask the forge nothing until it is held"); + await writes.viewed(); const from = forge.calls.length; await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } }); assert.equal(forge.calls.length, from, "a repository already written asks nothing more"); @@ -668,7 +672,7 @@ test("the merging account is given write on each repository the indexer's module assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator"); assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true); assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]); - assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/); + assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds a registered module/); assert.match(lines.join("\n"), /broken names no repository/); // 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's @@ -723,7 +727,7 @@ test("the merging account is given write on each repository the indexer's module await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT), /mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/); userRules.delete("jschoubben/dotfiles"); - // A module gone from the indexer leaves its repository out of the delivered list. + // A module gone from the catalog half leaves its repository out of the delivered list. await writes.take({ key: "snake", op: "delete" }); assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]); }); diff --git a/modules/gitea/test/views.test.ts b/modules/gitea/test/views.test.ts index 3f53160c9..48ee8bcaf 100644 --- a/modules/gitea/test/views.test.ts +++ b/modules/gitea/test/views.test.ts @@ -1,62 +1,95 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { ViewFollower, findingKey, unansweredFinding, viewTools, type ViewAnswer, type ViewChange, type ViewEvent } from "../views.ts"; +import { viewWaitsFinding } from "@novox/mesh-sdk/views"; +import { CatalogView, REVISION_VERB, findingValue, revisionAnswer, unansweredFinding } from "../catalog.ts"; -// ADR 0615: the module asks the indexer's read tool once, then keeps its picture of `modules` from the indexer's views -// events; a gap asks once more; an owner that is down is asked again and never read instead. +// Design 47 *Every part takes a first view, then follows the events*: the forge module asks the seat mesh-catalog's verb +// `revision` for the catalog half (every page of one trunk revision), then hears its event `catalog-revision`. A candidate's +// revision moves nothing; a newer trunk revision is waited for, said, and asked; waited for past the bound, view-waits is +// written, and cleared once held; an owner that does not answer is asked again and raised past its bound. -const made = "2026-11-04T12:00:00Z"; -const answer = (revision: number, values: Record): ViewAnswer => - ({ v: 1, owner: "o", state: "modules", revision, created: made, values }); -const ev = (revision: number, key: string, value?: unknown, extra: Partial = {}): ViewEvent => - ({ v: 1, owner: "o", state: "modules", key, revision, created: made, value, deleted: value === undefined, ...extra }); +const entry = (module: string, repository: string) => ({ module, repository, dir: ".", manifest: "m", fingerprint: "f", verdict: "accepted" }); +const revisionOf = (revision: number, kind = "trunk") => ({ revision, kind, source: revision, entries: [] }); -test("a follower takes the first view, then the events, and asks again only on a gap", async () => { - const changes: ViewChange[] = []; +/** A holder of the seat whose trunk revisions are pages of entries, and who answers what is asked of it. */ +function aHolder() { + const asked: Record[] = []; + const trunk = new Map([ + [7, [[entry("a", "u/a"), entry("b", "u/b")], [entry("c", "u/c")]]], + [8, [[entry("a", "u/a"), entry("c", "u/c2")]]], + ]); + let newest = 7; + let down = 0; + return { + asked, + set newest(n: number) { newest = n; }, + set down(n: number) { down = n; }, + ask: async (verb: string, input: { after?: string; revision?: number }) => { + assert.equal(verb, REVISION_VERB); + asked.push({ ...input }); + if (down > 0) { down--; return { error: "no responders" }; } + const rev = input.revision ?? newest; + const pages = trunk.get(rev)!; + const at = input.after ? pages.findIndex((p) => p[0].module > input.after!) : 0; + return { revision: revisionOf(rev), entries: pages[at], ...(at < pages.length - 1 ? { next: pages[at].at(-1)!.module } : {}) }; + }, + }; +} + +test("the catalog view takes the trunk's first view page by page, then a newer trunk revision, and a candidate's moves nothing", async () => { + const holder = aHolder(); + const changes: string[] = []; + const view = new CatalogView(holder.ask, { pause: async () => {}, changed: (k, v, deleted) => { changes.push(deleted ? `-${k}` : `${k}=${(v as any).repository}`); } }); + await view.follower.start(); + assert.deepEqual(holder.asked, [{}, { after: "b", revision: 7 }], "the second page names the first page's revision"); + assert.deepEqual([view.held, view.follower.status().view, changes.sort()], [7, "7", ["a=u/a", "b=u/b", "c=u/c"]]); + const rows: { name: string; ev: Record; asks: number; held: number }[] = [ + { name: "a candidate's revision", ev: { v: 1, kind: "candidate", revision: 9, source: 8, group: "g", heads: "h" }, asks: 0, held: 7 }, + { name: "the revision it holds", ev: { v: 1, kind: "trunk", revision: 7, source: 7 }, asks: 0, held: 7 }, + { name: "a newer trunk revision", ev: { v: 1, kind: "trunk", revision: 8, source: 8 }, asks: 1, held: 8 }, + ]; + for (const r of rows) { + holder.newest = 8; + holder.asked.splice(0); + changes.splice(0); + await view.hear(r.ev as any); + await view.asked; + assert.deepEqual([holder.asked.length, view.held], [r.asks, r.held], r.name); + } + assert.deepEqual(changes.sort(), ["-b", "c=u/c2"], "the newer revision hands what changed"); + const s = view.follower.status(); + assert.deepEqual([s.view, s["waits-for"]], ["8", undefined], "it holds what it waited for"); + await assert.rejects(view.hear({ v: 1, kind: "trunk" } as any), /not a mesh-catalog.catalog-revision event/); + assert.throws(() => revisionAnswer({ error: "refused for gitea" }), /answered no revision: refused for gitea/); +}); + +test("a revision waited for past the bound writes view-waits, cleared once held; an unanswered first view is raised, then cleared", async () => { + const holder = aHolder(); const said: string[] = []; - let asks = 0; - let down = 2; - const raised: string[][] = []; - const f = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", - ask: async () => { if (down-- > 0) throw new Error("no responders"); asks++; return answer(9, { a: { repository: "novox/a" }, b: { repository: "novox/b" } }); }, - take: (c) => { changes.push(c); }, raise: (_a, e) => { raised.push(["raised", e]); }, clear: () => { raised.push(["cleared", ""]); }, say: (l) => said.push(l), sleep: async () => {}, now: (() => { let t = 0; return () => (t += 200_000); })() }); - await f.start(); - assert.equal(f.ready, true); - assert.equal(f.values.size, 2); - assert.deepEqual(raised.map((r) => r.slice(0, 2)), [["raised", "no responders"], ["cleared", ""]]); - assert.ok(said.some((l) => l.includes("no first view of o's modules") && l.includes("o.read")), said.join("|")); - await f.hear(ev(8, "a", { repository: "old" })); // already in the picture - await f.hear(ev(10, "c", { repository: "novox/c" })); - await f.hear(ev(11, "a")); // deleted - assert.deepEqual([f.revision, f.values.has("a"), f.values.has("c"), asks], [11, false, true, 1]); - assert.deepEqual(changes.slice(-2).map((c) => [c.key, c.op]), [["c", "put"], ["a", "delete"]]); - await f.hear(ev(13, "d", { repository: "x" })); // revision 12 missed: a gap - assert.equal(asks, 2); - await f.hear(ev(1, "e", { repository: "x" }, { created: "2026-11-04T13:00:00Z" })); // the state made again: a gap - assert.equal(asks, 3); - await f.hear(ev(10, "f", undefined, { gap: true })); // the next revision, too large to carry: a gap - assert.equal(asks, 4); - await f.hear({ ...ev(10, "g", { repository: "x" }), state: "other" }); // another state - assert.equal(f.values.has("g"), false); -}); - -test("the reader's two tools say where the first view stands and ask it again by hand", async () => { - let asks = 0; - const acts: Record[] = []; - const follower = new ViewFollower({ owner: "o", state: "modules", tool: "o.read", ask: async () => { asks++; return answer(5, { a: 1 }); }, - take: () => {}, sleep: async () => {} }); - const [first, again] = viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async (act) => { acts.push(act); }); - assert.deepEqual(await first.run({}), { views: [{ owner: "o", tool: "o.read", state: "modules", revision: 0, created: "", keys: 0, answered: false, asks: 0, "by-hand": 0 }] }); - const after = (await again.run({})) as { views: { revision: number; "by-hand": number; asks: number }[] }; - assert.deepEqual([after.views[0].revision, after.views[0]["by-hand"], asks, acts.length], [5, 1, 1, 1]); - await assert.rejects(viewTools(() => ({ follower, owner: "o", tool: "o.read", state: "modules" }), async () => { throw new Error("no holder"); })[1].run({}), /not recorded/); - assert.deepEqual(await viewTools(() => undefined, async () => {})[0].run({}), { views: [] }); -}); - -test("the finding of a first view unanswered is the one mesh-sdk writes: key, kind, subject and the words", () => { - const f = unansweredFinding("gitea", "mesh-delivery-indexer", "modules", "mesh-delivery-indexer.indexer_read_modules", new Date("2026-11-04T12:00:00Z"), "no responders", new Date("2026-11-04T12:03:00Z")); - assert.equal(f.key, "module.gitea.mesh-delivery-indexer.modules.first-view-unanswered"); - assert.equal(findingKey("module", "a.b", "k"), "module.a.b.k"); - assert.equal(f.value.severity, "warning"); - assert.match(String(f.value.summary), /gitea has no first view of mesh-delivery-indexer's modules: .* \(no responders\)/); + let bound = () => {}; + let clock = 0; + const view = new CatalogView(holder.ask, { + pause: () => new Promise((r) => setImmediate(r)), bound: 1000, now: () => new Date(Date.parse("2026-11-09T22:00:00Z") + (clock += 600)), + waitsBound: () => new Promise((r) => (bound = r)), + raise: (s) => { said.push(`raised ${findingValue(unansweredFinding("gitea", s, new Date())).key}`); }, + clear: () => { said.push("cleared"); }, + raiseWaits: (s) => { said.push(`waits ${findingValue(viewWaitsFinding("gitea", s, new Date())).key}`); }, + clearWaits: () => { said.push("waits cleared"); }, + }); + holder.down = 3; + await view.follower.start(); + assert.deepEqual(said, ["raised module.gitea.mesh-catalog.catalog-revisions.first-view-unanswered", "cleared"]); + said.splice(0); + holder.newest = 8; + holder.down = 100; // the holder is away when the newer revision is said + const heard = view.hear({ v: 1, kind: "trunk", revision: 8, source: 8 } as any); + await heard; + assert.equal(view.follower.status()["waits-for"], "8", "it says what it waits for"); + bound(); + await new Promise((r) => setImmediate(r)); + assert.deepEqual(said.filter((l) => l.startsWith("waits")), ["waits module.gitea.mesh-catalog.catalog-revisions.view-waits"]); + holder.down = 0; + await view.follower.gap("the holder is back"); + await view.asked; + assert.deepEqual([view.held, view.follower.status()["waits-for"], said.includes("waits cleared")], [8, undefined, true]); }); diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 94736c81c..8c7e73e2b 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -12,9 +12,7 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools" import { emit } from "@novox/mesh-sdk/events"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; import { readTeamRights } from "../teamrights.js"; -import { state } from "@novox/mesh-sdk/state"; -import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js"; -import { appendNote, run, viewBody, viewComment } from "../delivery.js"; +import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, trunksOf, givenFields, trunkRule, unapplied } from "../protectfull.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, pathSegments, routedPath, sentQuery, @@ -24,10 +22,6 @@ import { /** 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}$/; -/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). Its name - * is the manifest's container's; no variable names it (a MESH_* word nothing sets is refused, checks/environment). */ -const forgeContainer = "gitea"; - /** A protection rule as a person reads it: what it guards, not every field the forge keeps. */ export function summarised(p: BranchProtection) { return { @@ -91,13 +85,14 @@ function filesDirs(raw: unknown): string[] | undefined { return raw as string[]; } -export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { - return namesChecked(allGiteaTools(gitea, admin, source)); +export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { + // The seat git's verbs are served by the events entrypoint (index.ts), beside the head's one writer of its statuses. + return namesChecked(allGiteaTools(gitea, admin)); } -// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one -// writer (novox/hq issue 456, ADR 0323). -function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] { +// A merge, a status, a comment, a delivery's view and its note are the seat git's acts (seat.ts), asked by the forge +// bridge alone: no tool of the module's own does them (novox/hq design 47 *The seat git*). +function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined): ToolDefinition[] { return [ // ---- Repositories ---- { @@ -233,19 +228,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined issue: await gitea.setIssueState(String(args.owner), String(args.repo), Number(args.number), "closed"), }), }, - { - name: "gitea_add_comment", - description: "Add a comment to an issue or pull request.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - number: { type: "number", description: "the issue or PR number" }, - body: { type: "string", description: "the comment body (markdown)" }, - }, - run: async (args) => ({ - comment: await gitea.addComment(String(args.owner), String(args.repo), Number(args.number), String(args.body)), - }), - }, // ---- Pull requests ---- { @@ -502,7 +484,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined "enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " + "flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " + "forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " + - "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " + + "tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunk, the branch the mesh installs from, is main, the mesh's one trunk of every repository (said in trunks.from). As the forge's " + "administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " + "this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " + "agent-, no administrator override) and why not.", @@ -524,7 +506,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined const given = givenFields(args); if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`); const body = fullProtectionBody(args, given); - const trunks = await trunksOf(owner, repo, source); + const trunks = trunksOf(); const operators = forcePushToTrunk(branch, body, trunks.names); if (operators) throw new Refused(operators); const forge = admin ? admin() : gitea; @@ -640,40 +622,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined }, }, - // ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ---- - { - name: "gitea_note_append", - description: "Append one line to a commit's git note under refs/notes/ (mesh-plan for a delivery), written in the forge's own repository as the forge's own account; a line the note already holds is not added again. `git log --notes=mesh-plan` shows it.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - sha: { type: "string", description: "the commit" }, - ref: { type: "string", description: "the notes ref's name (default mesh-plan)" }, - line: { type: "string", description: "the line, one line" }, - }, - run: async (args) => { - const said = await appendNote(run, forgeContainer, String(args.owner), String(args.repo), String(args.sha), - String(args.ref ?? "mesh-plan") || "mesh-plan", String(args.line ?? "")); - return { commit: String(args.sha), ...said }; - }, - }, - { - name: "gitea_delivery_view", - description: "Keep a delivery's view on its pull request: one comment, marked as the delivery's, created the first time and edited in place after — the page every status of the delivery links to.", - input: { - owner: { type: "string", description: "the repository owner" }, - repo: { type: "string", description: "the repository name" }, - number: { type: "number", description: "the pull request's number" }, - body: { type: "string", description: "the view, markdown" }, - }, - run: async (args) => { - const owner = String(args.owner), repo = String(args.repo), number = Number(args.number); - const body = viewBody(String(args.body ?? "")); - const existing = viewComment(await gitea.listComments(owner, repo, number)); - if (existing) return { edited: await gitea.editComment(owner, repo, existing.id, body) }; - return { created: await gitea.addComment(owner, repo, number, body) }; - }, - }, + // ---- A commit's statuses (a delivery's note, view and statuses are the seat git's acts, seat.ts) ---- { name: "gitea_commit_statuses", description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out); and merge.writes, whether the mesh's merging account may write the repository at all (false: its statuses and its merge are refused until gitea_grant_write). Reads only.", @@ -768,8 +717,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined return { commit, in: within, contains, ahead }; }, }, - // gitea_commit_status is served from the watcher's bundle (index.ts), beside the merge check's one writer - // (novox/hq issue 456). // ---- Labels ---- { @@ -805,7 +752,7 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined name: "gitea_api", description: "Make an authenticated Gitea API call for an endpoint without a dedicated tool. Path is relative to /api/v1. " + "Reads anything but the forge's administration; writes only what an author writes — issues, comments, labels, " + - "pull requests (not their merge: gitea_merge_pull_request), branches, and files on a branch that is not the " + + "pull requests (not their merge: the seat git's merge-pull), branches, and files on a branch that is not the " + "default branch and that no protection rule names. Administration, protection, settings, collaborators, teams, " + "hooks, keys, tokens and statuses are refused (novox/hq ADR 0266).", input: { diff --git a/modules/gitea/views.ts b/modules/gitea/views.ts deleted file mode 100644 index dbcc7d6a4..000000000 --- a/modules/gitea/views.ts +++ /dev/null @@ -1,241 +0,0 @@ -// A reader's picture of one state of the indexer or the assigner (novox/hq ADR 0615 §1, §4 to §6; design 25 § 12.2): asked once -// of the owner's read tool, then kept by the owner's views events alone. It is the TypeScript form of the Go reader -// (mesh-delivery sdk/views Follower) and follows the same rules: -// -// - at its start it asks the tool until it is answered, waiting longer between asks (up to a minute), and past two minutes -// says so naming the owner and the tool; it never reads the owner's state instead; -// - an event of another `created` is a gap (the owner's state was made again); one at or below the revision held is already -// in the picture; one past the next revision is a gap; one with `gap: true` (a value too large to carry) is a gap; -// - a gap asks the tool once more, said in the log; -// - events heard while an ask runs are taken again after its answer, so none is lost. - -/** The answer of the owner's read tool with `values`: one page of the state, at the owner's revision. */ -export interface ViewAnswer { - v: number; - owner: string; - state: string; - revision: number; - created: string; - values: Record; - next?: string; -} - -/** One change of one key, said on the owner's views family. */ -export interface ViewEvent { - v: number; - owner: string; - state: string; - key: string; - revision: number; - created: string; - deleted?: boolean; - value?: unknown; - gap?: boolean; -} - -/** One change of the picture, in the form the module's watch handed before. */ -export interface ViewChange { - key: string; - op: "put" | "delete"; - value?: unknown; - revision?: number; - current?: boolean; -} - -export interface ViewFollowerOptions { - owner: string; - state: string; - /** The owner's read tool as the manifest invokes it, for the log. */ - tool: string; - /** Asks the tool for the page after `after` (none: the first). */ - ask: (after?: string) => Promise; - take: (change: ViewChange) => void | Promise; - say?: (line: string) => void; - sleep?: (ms: number) => Promise; - now?: () => number; - /** How long it asks before it says so; two minutes. */ - boundMs?: number; - /** Told once when the first view stays unanswered past the bound (the module writes its finding), and once when it is answered after. */ - raise?: (asked: Date, error: string) => void | Promise; - clear?: () => void | Promise; -} - -export class ViewFollower { - readonly values = new Map(); - revision = 0; - created = ""; - ready = false; - asks = 0; - byHand = 0; - private asking = false; - private during: ViewEvent[] = []; - - private readonly o: ViewFollowerOptions; - - constructor(o: ViewFollowerOptions) { - this.o = o; - } - - private say(line: string): void { - (this.o.say ?? ((l) => console.error(`[gitea] ${l}`)))(line); - } - - /** Asks until answered: each failed ask waits, doubling to a minute, and past the bound says what waits. */ - async start(): Promise { - await this.askUntil("start"); - } - - /** Asks once more by hand, until answered (a hand act, counted). */ - async again(why: string): Promise { - this.byHand++; - await this.askUntil(`by hand: ${why}`); - } - - private async askUntil(why: string): Promise { - if (this.asking) return; // one ask at a time; the one running brings the picture to its newest - this.asking = true; - this.during = []; - const sleep = this.o.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); - const now = this.o.now ?? Date.now; - const first = now(); - let said = false; - try { - for (let wait = 5000; ; wait = Math.min(wait * 2, 60_000)) { - try { - await this.askOnce(); - if (said) { - this.say(`the first view ${this.o.tool} of ${this.o.owner}'s ${this.o.state} is answered`); - await this.o.clear?.(); - } - return; - } catch (err) { - if (!said && now() - first >= (this.o.boundMs ?? 120_000)) { - said = true; - await this.o.raise?.(new Date(first), err instanceof Error ? err.message : String(err)); - this.say(`no first view of ${this.o.owner}'s ${this.o.state}: ${this.o.tool} has not answered since ${new Date(first).toISOString()} ` + - `(${err instanceof Error ? err.message : String(err)}); it keeps asking and reads the state of nobody (${why})`); - } - await sleep(wait); - } - } - } finally { - this.asking = false; - } - } - - private async askOnce(): Promise { - this.asks++; - const values = new Map(); - let firstPage: ViewAnswer | undefined; - for (let after: string | undefined; ;) { - const a = await this.o.ask(after); - firstPage ??= a; - for (const [k, v] of Object.entries(a.values ?? {})) values.set(k, v); - if (!a.next || a.next === after) break; - after = a.next; - } - const old = new Map(this.values); - this.values.clear(); - for (const [k, v] of values) this.values.set(k, v); - this.revision = firstPage.revision; - this.created = firstPage.created; - this.ready = true; - const during = this.during.sort((a, b) => a.revision - b.revision); - this.during = []; - for (const ev of during) { - if (ev.created === this.created && ev.revision === this.revision + 1 && !ev.gap) { - if (ev.deleted) this.values.delete(ev.key); - else this.values.set(ev.key, ev.value); - this.revision = ev.revision; - } - } - for (const [k, v] of this.values) { - if (!old.has(k) || JSON.stringify(old.get(k)) !== JSON.stringify(v)) await this.o.take({ key: k, op: "put", value: v, current: true }); - } - for (const k of old.keys()) if (!this.values.has(k)) await this.o.take({ key: k, op: "delete", current: true }); - } - - /** One event of the owner's views family: taken into the picture, or a gap that asks the tool once more. */ - async hear(ev: ViewEvent): Promise { - if (ev.state !== this.o.state) return; - if (this.asking) this.during.push(ev); - if (!this.ready) return; // the first view holds it - let why = ""; - if (ev.created !== this.created) why = `${this.o.owner}'s ${this.o.state} was made again`; - else if (ev.revision <= this.revision) return; - else if (ev.revision > this.revision + 1) why = `${this.o.owner}'s ${this.o.state} at revision ${ev.revision}, the picture holds ${this.revision}`; - else if (ev.gap) why = `${this.o.owner}'s ${this.o.state}.${ev.key} was too large to carry`; - if (why) { - this.say(`a gap in ${this.o.state} (${why}): its first view ${this.o.tool} is asked once more`); - await this.askUntil(`a gap: ${why}`); - return; - } - if (ev.deleted) this.values.delete(ev.key); - else this.values.set(ev.key, ev.value); - this.revision = ev.revision; - await this.o.take({ key: ev.key, op: ev.deleted ? "delete" : "put", value: ev.value, revision: ev.revision, current: false }); - } -} - -type Tool = { name: string; description: string; input: Record; run: (args: Readonly>) => Promise }; - -/** Where a first view stands (design 25 § 12.2): the owner and its tool, the revision the picture holds, how many asks. (r) */ -export function status(f: ViewFollower, owner: string, tool: string, state: string): Record { - return { owner, tool, state, revision: f.revision, created: f.created, keys: f.values.size, answered: f.ready, asks: f.asks, "by-hand": f.byHand }; -} - -/** The reader's two tools: `gitea_first_views` says where its first views stand, `gitea_first_view_again` asks one once more by hand. */ -export function viewTools(view: () => { follower: ViewFollower; owner: string; tool: string; state: string } | undefined, - record: (act: Record) => Promise): Tool[] { - return [ - { - name: "gitea_first_views", - description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, how many asks (by hand among them). (r)", - input: {}, - run: async () => { - const v = view(); - return { views: v ? [status(v.follower, v.owner, v.tool, v.state)] : [] }; - }, - }, - { - name: "gitea_first_view_again", - description: "Ask the indexer's modules again by hand: a hand act, for a picture the operator doubts. Answers where it stands after. (a)", - input: {}, - run: async () => { - const v = view(); - if (!v) throw new Error("the module follows no first view yet"); - // A hand act, recorded through the seat mesh-hand-acts before it is done; one that cannot be recorded asks nothing. - try { - await record({ what: "asked a first view again by hand (gitea)", why: "the operator asked", cause: "first-view-again", verb: "first-view-again" }); - } catch (err) { - throw new Error(`the hand act was not recorded through mesh-hand-acts, so nothing was asked again: ${err instanceof Error ? err.message : String(err)}`); - } - await v.follower.again("the operator asked"); - return { views: [status(v.follower, v.owner, v.tool, v.state)] }; - }, - }, - ]; -} - -/** The key of a finding about a module: scope, the id's dotted parts, and the kind, each part made safe (mesh-sdk health.Key). */ -export function findingKey(scope: string, id: string, kind: string): string { - return [scope, ...id.split("."), kind].map((p) => p.trim().replace(/[^A-Za-z0-9_=/-]/g, "_") || "_").join("."); -} - -export const UNANSWERED_KIND = "first-view-unanswered"; - -/** A reader's finding of a first view unanswered past its bound, as mesh-sdk views.UnansweredFinding writes it (ADR 0615 §6): the - * condition keeper raises the condition from the module's state `findings`. */ -export function unansweredFinding(reader: string, owner: string, state: string, tool: string, asked: Date, error: string, at: Date): - { key: string; value: Record } { - const id = `${reader}.${owner}.${state}`; - const key = findingKey("module", id, UNANSWERED_KIND); - const summary = `${reader} has no first view of ${owner}'s ${state}: ${tool} has not answered since ${asked.toISOString()} (${error.trim()})`; - return { key, value: { - v: 1, key, kind: UNANSWERED_KIND, subject: { scope: "module", id }, severity: "warning", summary, - headline: `${reader} waits for ${owner}`, - explanation: `${reader} reads ${owner}'s ${state} only through ${tool}, and keeps asking it; it never reads the state itself. The answer clears this. ` + - `Look at ${owner}'s runtime of its own: is it running and connected?`, - resolved: "", evidence: [{ at: at.toISOString(), said: summary }], source: `${reader}.${UNANSWERED_KIND}`, observer: reader, at: at.toISOString(), - } }; -} diff --git a/modules/gitea/writes.ts b/modules/gitea/writes.ts index 029e4d8ba..2bd8aa980 100644 --- a/modules/gitea/writes.ts +++ b/modules/gitea/writes.ts @@ -3,15 +3,16 @@ // 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first // act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts). // -// **Which account.** The tools' account, `mesh-tools`: the gitea module's own tools set every commit status and merge -// (gitea_commit_status, gitea_merge_pull_request) with its token. The build modules' `nox-builder` and the machines' +// **Which account.** The tools' account, `mesh-tools`: the seat git's acts (seat.ts) set every commit status and merge +// with its token. The build modules' `nox-builder` and the machines' // `nox-agent` only fetch, and keep read (readall.ts). // -// **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its -// state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads -// that state through the indexer's read tool (its manifest's `invokes`) and then its views events (`consumes`; ADR 0615): the -// first view hands the latest value of every key when the module starts, and then every change, so a repository is granted -// at the module's start and when a module of it is first registered. Nothing polls and the state is not read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other +// **Which repositories: the catalog half's word, read over the bus.** The seat mesh-catalog's trunk revision holds every +// registered module's entry, each naming the repository it builds from. This module asks it of the seat's verb `revision` +// (its manifest's `invokes`) and then hears the seat's event `catalog-revision` (`consumes`; catalog.ts, design 47): the +// first view hands every entry when the module starts, and each newer trunk revision what changed, so a repository is +// granted at the module's start and when a module of it is first registered. A candidate's revision moves nothing, and +// before the first view nothing is granted. Nothing polls and no holder's state is read. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other // repository of a user (the operator's dotfiles, say) is left as it is: read only. // // **Every grant is read back** from the forge (its permission for the account), and said on the bus as @@ -24,19 +25,15 @@ // set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but // blocked, with why (merge_blocked). // -// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it; +// Not taken away: a repository whose last module leaves the catalog keeps its collaborator until a hand removes it; // gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now. import type { ForgeRepository } from "./client.js"; import type { BranchProtection } from "./protection.js"; import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js"; -/** The indexer, whose state `modules` this module follows: one key per registered module, its value naming the repository. - * It asks the indexer's read tool once and then hears its views events (ADR 0615), never reading the state. */ -export const INDEXER_OWNER = "mesh-delivery-indexer"; -export const INDEXER_MODULES_TOOL = `${INDEXER_OWNER}.indexer_read_modules`; -export const INDEXER_MODULES_EVENTS = `${INDEXER_OWNER}.views.modules`; -const INDEXER_MODULES = `${INDEXER_OWNER}.modules`; +/** What the view is called in what this module says: the catalog half, through the seat mesh-catalog. */ +const CATALOG_HALF = "the catalog half (the seat mesh-catalog's revision)"; /** The event said once an account writes a repository it could not write before. */ export const WRITABLE_EVENT = "repo.writable"; @@ -75,18 +72,16 @@ export function mergeBlocks(rule: BranchProtection | null, account: string): str const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`; -/** One change of the indexer's modules state, as the SDK's state watch hands it. */ +/** One change of the catalog half's entries: a module, put with its entry or deleted. */ export interface ModulesChange { key: string; op: "put" | "delete"; value?: unknown; - revision?: number; - current?: boolean; } export interface Written { account: string; - /** The repositories that build a registered module, as the indexer says now. */ + /** The repositories that build a registered module, as the catalog half says now. */ delivered: string[]; /** Made writable now, each read back from the forge. */ granted: string[]; @@ -113,9 +108,9 @@ function split(full: string): [string, string] { } export class DeliveredWrites { - /** Module to the repository it builds from, as the indexer's state says. */ + /** Module to the repository it builds from, as the catalog half says. */ readonly modules = new Map(); - /** Whether the watch has handed the indexer's state at all: until then, which repositories are delivered is not known. */ + /** Whether the first view of the catalog half is held: until then, which repositories are delivered is not known. */ heard = false; /** Repositories seen written by the account in this process: a module registered again asks nothing more. */ readonly writable = new Set(); @@ -129,9 +124,23 @@ export class DeliveredWrites { return [...new Set(this.modules.values())].sort(); } - /** One change of the indexer's modules: a repository newly delivered is made writable now. Never throws. */ - async take(change: ModulesChange): Promise { + /** The first view is held, its entries taken: from now on which repositories are delivered is known, even when the + * catalog lists none, and every one the account cannot write yet is granted now, in one pass. Never throws. */ + async viewed(): Promise { + if (this.heard) return; this.heard = true; + try { + const r = await this.grant(); + for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds a registered module`); + for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository} — ${x.why}`); + } catch (err) { + this.log(`which repositories ${this.account} writes could not be settled at the first view — ${said(err)}`); + } + } + + /** One change of the catalog half: a repository newly delivered is made writable now. Never throws. Before the first + * view is held (viewed) nothing is granted: the change is kept, and the first view's changes follow it. */ + async take(change: ModulesChange): Promise { if (change.op === "delete") { this.modules.delete(change.key); return; @@ -140,11 +149,11 @@ export class DeliveredWrites { try { split(repository); } catch { - this.log(`${INDEXER_MODULES} ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`); + this.log(`${CATALOG_HALF}: ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`); return; } this.modules.set(change.key, repository); - if (this.writable.has(repository)) return; + if (!this.heard || this.writable.has(repository)) return; try { const r = await this.grant([repository]); for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`); @@ -158,8 +167,8 @@ export class DeliveredWrites { * delivered); with none named, also say which repositories it cannot write are left so, and why. */ async grant(only?: string[]): Promise { if (!this.heard) { - throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read ` + - "(the watch is not granted or not answered); nothing is granted"); + throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view ` + + "held (not answered, or waited for); nothing is granted"); } const delivered = this.delivered(); const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] }; @@ -169,7 +178,7 @@ export class DeliveredWrites { split(full); if (delivered.includes(full)) targets.push(full); else result.left_read_only.push({ repository: full, permission: "unchanged", - why: "builds no registered module (the indexer names none from it), so the account is given no write there" }); + why: "builds no registered module (the catalog half names none from it), so the account is given no write there" }); } for (const full of targets) { const [owner, name] = split(full); @@ -248,7 +257,7 @@ export class DeliveredWrites { } if (WRITES.has(permission)) continue; result.left_read_only.push({ repository: repo.full_name, permission, - why: "builds no registered module (the indexer names none from it), so the account is given no write there" }); + why: "builds no registered module (the catalog half names none from it), so the account is given no write there" }); } } return result; @@ -257,7 +266,7 @@ export class DeliveredWrites { /** Every delivered repository the account cannot write, as the forge says now, and why. */ async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[]; merge_blocked: { repository: string; why: string }[] }> { - if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`); + if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${CATALOG_HALF} has no first view held`); const delivered = this.delivered(); const out: { repository: string; permission: string; why: string }[] = []; const blocked: { repository: string; why: string }[] = []; @@ -295,7 +304,7 @@ export function writeTools(writes: () => DeliveredWrites): Tool[] { return [ { name: "gitea_unwritable_repos", - description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " + + description: "Every repository that builds a registered module (the catalog half's trunk revision, read over the bus) on which " + "the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " + "permission the forge gives it there and why: such a pull request can be checked but never merged, and the " + "delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +