Files
jschoubben 32655b95f6 GIT1, GIT2, GIT3, GIT10: the forge module serves the seat git's verbs and says its events, retires its acting tools, follows the catalog half
The four share files (index.ts, module.json, guard.test.ts), so they land as one commit.
GIT1: seat.ts serves pull, open-pulls, tags, trees, file, contains, statuses, status, comment, view, note, merge-pull
as tools on the seat, each answering the contract's shape and an error of kind refused, not-found or failed; a comment,
view or note whose act id is held is answered held (note matched by id). merge-pull merges the head the caller names
(its from-G judgements are GIT6, GIT7, GIT9).
GIT2: seatevents.ts says pull-updated, pull-merged, pull-closed, forge-pull, forge-status, forge-delete and repo-writable on
mesh.seat.git.event.<event>; the module's emits drop them.
GIT3: merge.ts, check.ts, the acting tools, the owed watch, the gate's source read and check-gates-merge are gone.
GIT10: catalog.ts follows the seat mesh-catalog (revision, catalog-revision); no write is granted before the first view.

Builds: design 47 G §310, §311, §312, §313, §314, §163, §317, §278, §321, §196

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:31:24 +01:00

104 lines
5.1 KiB
TypeScript

// **One writer of a head's mesh/merge-gate** (novox/hq issue 446).
//
// A pending check says where it is: "waiting in the build queue since …" when the head is announced. The forge keeps only
// the newest status of a context, so two writers cannot order their writes; this module is the one writer of
// mesh/merge-gate, and every write of one head's gate goes through one chain, in the order it was asked. A recheck's
// pending (novox/hq issue 456) and a trunk's move (ADR 0323 §4) take their turns on the same chain. No verdict is written
// here any more: the controller's `checked` is no event (ADR 0571 clause 13), and main requires the checker's mesh/check.
import { CHECK_CONTEXT, pendingWords } from "./pulls.js";
import type { CommitStatus, HeadCheck } from "./client.js";
/** What the gate writer needs of the forge: a status set, and a head's statuses as the forge holds them. */
export interface GateForge {
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
/** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */
headChecks(owner: string, repo: string, sha: string): Promise<Record<string, HeadCheck>>;
}
/** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */
export function clonedAs(url: string | undefined, owner: string, repo: string): boolean {
const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, "");
const full = `${owner}/${repo}`.toLowerCase();
return u === full || u.endsWith("/" + full);
}
interface Head {
/** The chain the head's writes take their turns on. */
chain: Promise<void>;
}
export class GateWriter {
private heads = new Map<string, Head>();
private forge: GateForge;
private now: () => Date;
constructor(forge: GateForge, now: () => Date = () => new Date()) {
this.forge = forge;
this.now = now;
}
private head(owner: string, repo: string, sha: string): Head {
const key = `${owner}/${repo}@${sha}`.toLowerCase();
let h = this.heads.get(key);
if (!h) {
h = { chain: Promise.resolve() };
this.heads.set(key, h);
// Kept for the heads still in play; the oldest go first, past what any forge holds open at once.
if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string);
}
return h;
}
/** One turn on the head's chain; a turn that fails never stops the next. Its failure is the caller's. */
private turn(h: Head, fn: () => Promise<void>): Promise<void> {
const run = h.chain.then(fn);
h.chain = run.catch(() => {});
return run;
}
/** A turn whose failure is said here: a pending word not written is not worth the event again. */
private said(h: Head, what: string, fn: () => Promise<void>): Promise<void> {
return this.turn(h, fn).catch((err) => {
console.error(`[gitea] ${what} — ${err instanceof Error ? err.message : err}`);
});
}
/** The head announced: it waits in the build queue from now. A new check of the head, so no verdict yet. */
pending(owner: string, repo: string, sha: string): Promise<void> {
const h = this.head(owner, repo, sha);
const at = this.now();
return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () =>
this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) }));
}
/**
* 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.
*/
status(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status));
}
/**
* **A trunk's move puts the head's verdict out of date** (novox/hq ADR 0323 §4): its merge check statuses are put back
* to pending, with the reason, in their turn after every write of the head already asked for — a verdict in flight
* lands first. What is written is decided in the turn, from the statuses the forge holds then (`decide`, trunk.ts's
* outOfDateStatuses): a check on its way, or a verdict already judged against the trunk's head, is left as it is. It
* opens no check — nothing is asked — so a verdict of a check asked before it keeps its weight. Answers what it wrote;
* a write that fails fails the caller, and the move is weighed again on the next pass.
*/
outOfDate(owner: string, repo: string, sha: string,
decide: (checks: Record<string, HeadCheck>) => CommitStatus[] | Promise<CommitStatus[]>, target?: string): Promise<CommitStatus[]> {
const h = this.head(owner, repo, sha);
const written: CommitStatus[] = [];
return this.turn(h, async () => {
for (const s of await decide(await this.forge.headChecks(owner, repo, sha))) {
await this.forge.setCommitStatus(owner, repo, sha, target ? { ...s, target_url: target } : s);
written.push(s);
}
}).then(() => written);
}
}