Files
mesh-catalog/modules/gitea/index.ts
T
jschoubben 470687ea4c
mesh/delivery merged: source.main moved to novox/mesh-catalog@774fcdfa93ad
mesh/check being carried to 470687ea4cce on 774fcdfa93ad | from 2a03c106f4b9
Gitea announces from the forge's events and one pass at the start, not by polling (issue 1422)
An edited pull request is announced into its new base at once; a close without a merge is announced by its event; the 30 s/5 min polls and the 10 min sweep are gone.
2026-10-28 12:05:03 +01:00

669 lines
41 KiB
TypeScript

// gitea's events. The tool runtime imports this once the broker is bound. It watches the forge and
// emits what appeared.
//
// Emits (novox/hq ADR 0041/0042):
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
// module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool)
// module.gitea.pull.updated — an open pull request's head moved, opened or pushed to: the mesh checks it
// before it merges (novox/hq to-be 45 §9)
//
// module.gitea.pull.closed — a pull request closed unmerged: the delivery it was is stopped (novox/hq ADR 0239)
//
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere, hq included since
// ADR 0527 §2, and since R4g no controller judges a merge (its merge gate went with build-agent, ADR 0524 §5), so nothing
// publishes mesh-controller.checked and these two are written for no base.
//
// 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.
//
// 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).
//
// **A trunk's move puts every open verdict into it out of date** (novox/hq ADR 0323 §4, trunk.ts): each pass reads the
// head of every trunk an open pull request merges into, and when one moved, the merge check statuses of every open pull
// 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.
//
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
// polling is the only way to catch every path — and the only emitter, so a fact is never announced
// twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice.
//
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
// the forge for an immediacy nobody asked for is not.
import { emit, on } 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 { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { keepMainRules, mainRulesStep } from "./mainrules.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained,
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js";
import { actOn, announceNewHeads, startPass } from "./watch.js";
import { GateWriter } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindInBackground, bindUntilHeld } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
import { ReadAll, readAllTools } from "./readall.js";
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
// or reuses the token, so the runtime's start also shows what it did about it.
let gitea: GiteaClient | null = null;
try {
gitea = GiteaClient.fromEnv();
} catch (err) {
console.log(`[gitea] not watching — ${err instanceof Error ? err.message : String(err)}`);
}
// New repositories, by diffing the repo list. Primed silently on the first look, or a restart would
// re-announce every existing repository as freshly created.
const seen = new Set<string>();
let primed = false;
async function pollRepos(client: GiteaClient): Promise<void> {
const repos = await client.listAllRepos();
for (const repo of repos) {
if (!seen.has(repo.full_name)) {
if (primed) {
await emit("repo.created", {
full_name: repo.full_name,
owner: repo.owner,
name: repo.name,
private: repo.private,
html_url: repo.html_url,
});
}
seen.add(repo.full_name);
}
}
primed = true;
}
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
// What has been announced is kept beside the module's state, so a restart does not announce the
// whole history again — and the first tick on a machine with no record announces nothing, because
// everything it sees then predates the watching.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
// What has been announced is no longer kept in a file (novox/hq ADR 0406 §6, §7, issue 976): an announcement carries a
// message id (announce.ts), so the same merge announced by the poll and by the forge's event, or again after a restart,
// is stored once by the bus — within its de-duplication window, and past it the consumers' idempotence per merge commit
// holds. This process remembers what it announced meanwhile, so it does not ask the forge for the same files twice.
// The one value kept is where the poll last looked from (`since`), in the module's own state: a merge made while this
// module was away, after it, is announced when it is back; one before it is history. With none kept — the first look
// ever — nothing is announced, because everything it sees then predates the watching.
const announced = new Set<string>();
let primedMerges = false;
let since = "";
let watermarkRead = false;
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
// repository's update time; asking every repository for its pull requests on every tick took longer than the
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
// minute before the last look is skipped — the minute absorbs the forge's clock against this one.
let lastLook = "";
const MARGIN_MS = 60_000;
async function pollMerged(client: GiteaClient): Promise<void> {
if (!watermarkRead) {
since = await readWatermark();
primedMerges = since !== "";
watermarkRead = true;
}
const began = new Date().toISOString();
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
const repos = movedSince(await client.listAllRepos(), floor);
for (const repo of repos) {
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
for (const pull of pulls) {
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) await announceClosed(client, repo, pull);
announced.add(closedKey);
continue;
}
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
// Announced only if merged since the watching began; recorded either way, so it is looked
// at once.
const fresh = !!pull.merged_at && !!since && pull.merged_at > since;
if (primedMerges && fresh) {
if (await announceMerge(client, repo, pull)) pace.miss(`the merge of ${repo.full_name}#${pull.number}`);
}
else announced.add(pull.merge_commit_sha);
announced.add(pull.merge_commit_sha);
}
}
// The next look starts from this one's start less the margin; kept, so a restart looks from there.
since = primedMerges ? new Date(Date.parse(began) - MARGIN_MS).toISOString() : began;
await keepWatermark(since);
primedMerges = true;
lastLook = began;
}
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
/** A pull request closed without a merge, announced once (ADR 0239): by its event, or by the start's pass. */
async function announceClosed(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (pull.merged || pull.state !== "closed" || announced.has(closedKey)) return false;
announced.add(closedKey);
try {
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
} catch (err) {
announced.delete(closedKey);
throw err;
}
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
await siblingMoves(client, repo.full_name, pull.number);
return true;
}
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
// Recorded before the first wait, so the other path finds it taken while this one is still asking the forge.
announced.add(pull.merge_commit_sha);
try {
// What it changed, asked for only now: a module is rebuilt because a file inside its own
// directory moved, and without this every module built from a repository is rebuilt for a
// change to any of them (novox/hq 04-ISSUES/131).
const changed = await client.listPullFiles(repo.owner, repo.name, pull.number);
// Which of the directories they are in hold a module at the merge commit (novox/hq issue 278): a
// change inside one is that module's, held or not, and only a file in none is shared code. Not
// said when the list is cut or the forge could not be asked; the mesh then keeps its old rule.
let moduleDirs: string[] | null = null;
if (!changed.truncated) {
try {
moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, pull.merge_commit_sha, changed.paths);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: which directories hold a module could not be read, ` +
`so the mesh reads its files by the old rule — ${err instanceof Error ? err.message : String(err)}`);
}
}
// The head it merged, and what its head was checked as (novox/hq ADR 0239): the delivery it was, made
// from the forge's word when its owner never heard the head.
let headChecks: Record<string, string> | null = null;
if (pull.head_sha) {
try {
headChecks = await client.commitStatuses(repo.owner, repo.name, pull.head_sha);
} catch (err) {
console.error(`[gitea] ${repo.full_name}#${pull.number}: its head's statuses could not be read — ${err instanceof Error ? err.message : err}`);
}
}
await announce("pull.merged", {
owner: repo.owner,
repo: repo.name,
number: pull.number,
title: pull.title,
body: pull.body,
head_sha: pull.head_sha,
...(headChecks ? { head_checks: headChecks } : {}),
head: pull.head,
base: pull.base,
merge_commit_sha: pull.merge_commit_sha,
merged_at: pull.merged_at,
clone_url: repo.clone_url,
html_url: pull.html_url,
paths: changed.paths,
paths_truncated: changed.truncated,
// Which of them the merge deleted (novox/hq ADR 0236): a module whose manifest went is forgotten,
// not built.
removed: changed.removed,
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
});
// Said, because a trigger that fires silently is indistinguishable from one that did not
// fire (novox/hq 04-ISSUES/131) — this line is how an operator knows the mesh was told.
console.log(`[gitea] announced merge ${repo.full_name}#${pull.number} (${pull.merge_commit_sha.slice(0, 8)}) into ${pull.base}`);
// Its own merge of the head that was read is no sibling's move; any other is (ADR 0382 §5).
await siblingMoves(client, repo.full_name, pull.number);
} catch (err) {
announced.delete(pull.merge_commit_sha);
throw err;
}
return true;
}
// **Every new head of an open pull request is announced** (novox/hq to-be 45 §9): the mesh checks it against every
// machine of its facts before it merges. What this process announced is remembered in memory only (novox/hq ADR 0406
// §6, §7, issue 976): the announcement carries a message id (announce.ts), so the poll's, the forge event's and the
// merge tool's are one to the bus. After a start, the first look announces a head only if the forge shows no merge check
// of it yet — a head with one was announced — and only what moved in the last day, so a forge's whole backlog is not
// checked at once and a restart asks no check again against a trunk that moved.
let heads: Announced = {};
let primedPulls = false;
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
const key = headKey(repo.full_name, pull.number, pull.base);
const sha = String(pull.head_sha ?? "");
const before = heads[key];
// Claimed before the first wait, so the other path finds it taken while this one is still announcing.
if (!claimHead(heads, key, sha)) return false;
try {
// The announcement the merge tool makes too when it asks a check (trunk.ts, novox/hq ADR 0323 §5).
await announce("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
} catch (err) {
if (before === undefined) delete heads[key];
else heads[key] = before;
throw err;
}
// A sibling's new head puts out of date every verdict that read the sibling at another head (ADR 0382 §5).
await siblingMoves(client, repo.full_name, pull.number);
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's;
// a rule that cannot be read is said pending, as before.
const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined);
if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha);
return true;
}
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
/** Whether the forge shows a merge check of the head, in any state: only an announced head has one. */
async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
try {
const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha));
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks || CHECK_STATUS in checks;
} catch {
return false; // the forge cannot say: announced, and the bus's message id collapses it if it was
}
}
let lastPullLook = "";
/** The pass over every open pull request: run at the start and when the receiver listens, never on a timer (issue 1422). */
async function passPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString();
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
for (const repo of await client.listAllRepos()) {
// Every open pull request, paged (novox/hq issue 579).
const open = await client.listOpenPullsSince(repo.owner, repo.name, "");
await announceNewHeads(repo.full_name, open, heads, primedPulls, dayAgo, {
alreadyChecked: (pull) => alreadyChecked(client, repo, pull),
announceHead: (pull) => announceHead(client, repo, pull),
missed: (what) => pace.miss(what),
});
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
}
if (changedTrunks) keepTrunks();
changedTrunks = false;
primedPulls = true;
lastPullLook = began;
}
// **The trunks' heads, as this module last read them** (novox/hq ADR 0323 §4), by `owner/repo:branch`: kept beside the
// other records, so a move made while this module was away is weighed on its first pass back. With no record — the
// first pass after this rolls out — a trunk's head is taken as it is and nothing is put out of date: the verdicts
// then standing are weighed on the trunk's next move, and the merge tool compares commits whatever this holds.
const trunksRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "trunks-seen.json") : null;
let trunks: Record<string, string> = {};
let changedTrunks = false;
if (trunksRecord && existsSync(trunksRecord)) {
try {
trunks = (JSON.parse(readFileSync(trunksRecord, "utf8")) as { trunks: Record<string, string> }).trunks ?? {};
} catch {
// An unreadable record is no record: the next pass takes each trunk's head as it is.
}
}
function keepTrunks(): void {
if (!trunksRecord) return;
mkdirSync(join(trunksRecord, ".."), { recursive: true });
const tmp = trunksRecord + ".tmp";
writeFileSync(tmp, JSON.stringify({ trunks }));
renameSync(tmp, trunksRecord);
}
/** Whether a trunk of one repository moved, weighed with this module's forge and its one writer (trunk.ts). */
function trunksMoved(client: GiteaClient, owner: string, name: string): Promise<boolean> {
return weighTrunks(client, gates, trunks, owner, name, () => siblingMoves(client, `${owner}/${name}`));
}
// **The siblings each open pull request's latest verdict names** (novox/hq ADR 0382 §5, siblings.ts), by member, kept beside
// the other records. A sibling's new head, close unmerged, merge, or a move of its base puts the merge check of every
// member whose verdict names it back to pending, with the reason; nothing is run for it.
const siblingsRecord = siblingsFile(process.env.MESH_GITEA_STATE_DIR);
const siblingNamed = readSiblings(siblingsRecord);
/** A sibling moved, weighed (ADR 0382 §5): never fatal to what heard it. */
async function siblingMoves(client: GiteaClient, repository: string, number?: number): Promise<void> {
try {
if (await weighSiblings(client, gates, siblingNamed, repository, number)) keepSiblings(siblingsRecord, siblingNamed);
} catch (err) {
console.error(`[gitea] whether a sibling of ${repository}${number ? "#" + number : ""} moved could not be weighed — ${err instanceof Error ? err.message : err}`);
}
}
// **The verdict, set where the pull request shows it.** Every verdict is the head commit's status; one
// that is not a pass also leaves the check's own account as a comment, so the reason is read where the
// change is reviewed. An error — the check could not run — is the forge's `error`, never a success.
async function setVerdict(client: GiteaClient, event: { body: unknown }): Promise<void> {
const c = (event.body ?? {}) as Checked;
if (!c.group && c.owner && c.repo && c.took) {
// How long it took, for the merge tool to say how long the next one may (novox/hq ADR 0323 §5).
keepTook(tookFile(process.env.MESH_GITEA_STATE_DIR), `${c.owner}/${c.repo}`,
{ took: c.took, at: new Date().toISOString(), head: c.commit });
}
if (c.group) {
// A delivery group's composed check (novox/hq ADR 0239, ADR 0403 §1): its verdict is each member's merge gate, at the
// head it read; the group's own status, mesh/delivery-group, is its owner's to say.
await setGroupVerdict(client, c);
return;
}
if (!c.owner || !c.repo || !c.commit || !c.verdict) {
console.error("[gitea] a merge check's verdict named no repository, commit or verdict; ignored");
return;
}
// The siblings this verdict read are kept for the member (ADR 0382 §5); a verdict that read none removes the record.
const siblings = siblingsOf(c.siblings);
if (c.number && noteVerdict(siblingNamed, `${c.owner}/${c.repo}#${c.number}`.toLowerCase(), c.commit, siblings)) {
keepSiblings(siblingsRecord, siblingNamed);
}
// A sibling that moved while the check ran is weighed once this verdict has landed, in the head's own turn after it.
if (siblings.length) {
for (const repository of new Set(siblings.map((s) => s.repository))) {
void Promise.resolve().then(() => siblingMoves(client, repository));
}
}
// Each status links to the pull request, where the delivery's view is kept (novox/hq ADR 0239).
let target: string | undefined;
let base: string | undefined;
if (c.number) {
const pull = await client.getPullRequest(c.owner, c.repo, c.number).catch(() => undefined);
target = pull?.html_url || undefined;
base = pull?.base || undefined;
}
// **A verdict judged against a trunk commit that is no longer the trunk's head is out of date when it lands**
// (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of
// its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits.
const trunk = base ?? c.plan?.base;
// Only the statuses the base still requires are written (issue 1301): on hq, mesh/repo-check alone.
const required = trunk ? requiredOf(await client.appliedProtection(c.owner, c.repo, trunk).catch(() => undefined)) : undefined;
const kept = maintained(required);
if (c.trunk && trunk) {
const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined);
if (head && !sameCommit(c.trunk, head)) {
const description = outOfDateWords(c.trunk, head);
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]
.filter((x) => kept.includes(x))) {
await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description,
...(target ? { target_url: target } : {}) });
}
// Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date.
const said = commentFor(c, await repoCheckFacts(client, c, trunk), required);
if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description));
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` +
`${description}`);
return;
}
}
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record<string, { description?: string }>)))[CHECK_CONTEXT]?.description : undefined;
for (const status of statusesFor(c, facts, gateNow, required)) {
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
}
const comment = composedGate(c) ? null : commentFor(c, facts, required);
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
}
/** **The composed verdict sets each member's gate** (novox/hq ADR 0403 §1, issue 978), in each head's own turn of the gate
* writer (gate.ts), judged against the trunk as a member's own verdict is: one that names a trunk commit that is no longer
* the trunk's head is out of date when it lands, and said so, pending. */
async function setGroupVerdict(client: GiteaClient, c: Checked): Promise<void> {
// The base branch each member merges into, read with its pull request below: the composed verdict names the siblings of
// each member from it (issue 1049).
const bases = new Map<string, string>();
for (const m of c.members ?? []) {
if (!m.owner || !m.repo || !m.commit || !c.verdict) {
console.error(`[gitea] a composed verdict of ${c.group} names a member with no repository, commit or verdict; ignored`);
continue;
}
await gates.verdict(m.owner, m.repo, m.commit, async () => {
const pull = m.number ? await client.getPullRequest(m.owner, m.repo, m.number).catch(() => undefined) : undefined;
const target = pull?.html_url || undefined;
if (m.number && pull?.base) bases.set(`${m.owner}/${m.repo}#${m.number}`, pull.base);
let status = groupGateStatus(c, m);
if (m.trunk && pull?.base) {
const head = await client.branchHead(m.owner, m.repo, pull.base).catch(() => undefined);
if (head && !sameCommit(m.trunk, head)) status = { state: "pending", context: CHECK_CONTEXT, description: outOfDateWords(m.trunk, head) };
}
await client.setCommitStatus(m.owner, m.repo, m.commit, target ? { ...status, target_url: target } : status);
console.log(`[gitea] ${m.owner}/${m.repo}#${m.number ?? "?"} at ${m.commit.slice(0, 8)}: the group ${c.group}'s composed check ${c.verdict}, ` +
`its merge gate ${status.state}`);
}, c.id);
}
// The siblings of each member are the other members at the heads and base commits this verdict composed (issue 1049): a
// sibling's own merge of such a head is no move of the member's base. Kept for what the merge tool and the siblings'
// weighing read; nothing is run.
if (c.verdict && c.members?.length) {
const members = c.members.map((m) => ({ ...m, base: m.number ? bases.get(`${m.owner}/${m.repo}#${m.number}`) : undefined }));
if (noteComposed(siblingNamed, members)) keepSiblings(siblingsRecord, siblingNamed);
}
}
// **This module is the one writer of a head's mesh/merge-gate** (novox/hq issue 446): the announcement's pending words,
// where the check is when a build machine takes it, and the verdict — each head's writes in one chain, the verdict last
// (gate.ts). A second writer elsewhere raced the verdict and could leave a pass reading pending.
const gates = new GateWriter({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gateOf: async (owner, repo, sha) => (await (gitea as GiteaClient).headChecks(owner, repo, sha))[CHECK_CONTEXT],
gateHistory: (owner, repo, sha) => (gitea as GiteaClient).contextHistory(owner, repo, sha, CHECK_CONTEXT),
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();
/** Asks the pass over every repository again, shortly and once (set where the pass is made): an event that could not be
* acted on is not lost until the next start. */
let catchUp: () => void = () => {};
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
// **The mesh's accounts read every repository** (readall.ts; the operator, 2026-10-26): the tools', the build modules'
// and the machines' agents' (meshAccounts, tools-account.ts), granted with the site
// administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on
// first use, so a module whose admin login is missing still serves the rest and the tools say why.
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.
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)));
// 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), ...(gitea ? [commitStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gates,
}), mergeTool(gitea, { 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<void> => actOn<RepoOf, PullOf>(event, {
getRepo: (o, r) => client.getRepo(o, r),
getPull: (o, r, n) => client.getPullRequest(o, r, n) as Promise<PullOf & { state?: string; merged?: boolean }>,
announceHead: (repo, pull) => announceHead(client, repo, pull as OpenOf),
announceMerge: (repo, pull) => announceMerge(client, repo, pull),
announceClosed: (repo, pull) => announceClosed(client, repo, pull),
siblingMoves: (full, n) => siblingMoves(client, full, n),
});
/**
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
* the token; the webhook is made or corrected with the admin account (ADR 0266: the tools' token holds no admin scope).
* Answers whether events are heard; a forge that refuses is said, and the polls keep their pace.
*/
async function listenForForge(client: GiteaClient): Promise<boolean> {
const stateDir = process.env.MESH_GITEA_STATE_DIR;
const url = process.env.MESH_GITEA_HOOK_URL;
if (!stateDir || !url) {
console.log("[gitea] no webhook receiver: MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset, so the polls alone watch the forge");
pace.unheard("MESH_GITEA_STATE_DIR or MESH_GITEA_HOOK_URL unset", false);
return false;
}
try {
const secret = keptSecret(join(stateDir, "webhook.secret"));
const port = Number(process.env.MESH_GITEA_HOOK_PORT ?? HOOK_PORT);
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),
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) =>
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
try {
await actOnEvent(client, event);
} catch (err) {
catchUp();
throw err;
}
} });
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
});
const hook = await ensureSystemHook(GiteaAdmin.fromEnv(), url.replace(/\/+$/, "") + HOOK_PATH, secret);
pace.listening(host, port, hook.url, hook);
console.log(`[gitea] listening for the forge's events on ${host}:${port}; its system webhook ${hook.id} ${hook.did} ` +
`at ${hook.url} (in place of ${hook.removed.system} system, ${hook.removed.default} default and ` +
`${hook.removed.repository} repository webhook(s)${hook.removed.repositories.length ? ` in ${hook.removed.repositories.join(", ")}` : ""}), ` +
`${hook.events.length} event families, signed`);
return true;
} catch (err) {
const why = err instanceof Error ? err.message : String(err);
pace.unheard(why);
console.error(`[gitea] the forge's events are not heard, so the polls keep their pace — ${why}`);
return false;
}
}
if (gitea) {
const client = gitea;
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
let failing: string | null = null;
const tick = (fn: () => Promise<void>, every: number | (() => number)): void => {
const everyMs = (): number => (typeof every === "number" ? every : every());
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
// could each announce the same merge before either recorded it.
const run = (): void =>
void fn()
.then(() => {
if (failing !== null) console.log("[gitea] watching again");
failing = null;
})
.catch((err) => {
const why = err instanceof Error ? err.message : String(err);
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
failing = why;
})
.finally(() => setTimeout(run, everyMs()));
run();
};
// **The forge's events first, the polls as the safety net** (novox/hq issue 729): while verified deliveries arrive the
// 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
// 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
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
// their first consumer asks, and the forge sends no event for a user made) is looked for again ten minutes apart
// until every account was granted once; the line of each pass names it.
const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).then((r) => {
if (r.absent.length) setTimeout(readAllAtStart, 600_000);
}, (err) => {
console.error(`[gitea] whether the mesh's accounts read every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`);
setTimeout(readAllAtStart, 60_000);
});
try {
readAll();
readAllAtStart();
} 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.
try {
const w = writes();
void bindUntilHeld(INDEXER_MODULES, () => state<unknown>(INDEXER_MODULES).watch((change) => w.take(change as ModulesChange)));
} 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}`);
}
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000);
tick(() => pollRepos(client), 60_000);
// **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over
// every repository runs at the start and again once the receiver listens, so what happened while the module was away or
// not yet listening is announced; passes run one at a time, a failed one is asked again a minute later.
let passes: Promise<void> = Promise.resolve();
const pass = (): Promise<void> => (passes = passes.catch(() => {}).then(() =>
startPass(async () => { await pollMerged(client); await passPulls(client); }, { retryMs: 60_000 })));
void pass();
let catchUpAsked = false;
catchUp = () => {
if (catchUpAsked) return;
catchUpAsked = true;
setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000);
};
void listenOk.then((ok) => (ok ? pass() : undefined));
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
// handshake in 10 s, so the import must end while a refused bind is still asked again.
bindInBackground([
["mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
})],
], () => 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<OwedChange["value"]>(CHECKER_OWED).watch((change) => handler(change)), checks);
}