mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The announcer read only the repositories whose update time moved since its last pass, less a minute. Opening, reopening or retargeting a pull request does not move a repository's update time, so a pull request opened more than a minute after its push was never announced, never checked, and nothing said so (mesh-sdk #14, 2026-10-09). - Each pass also looks at every repository holding a pull request updated since the floor, by the forge's own search; every ten minutes, or when the search fails, at every repository. - Every five minutes the forge is asked for every open pull request; one on a base requiring mesh/merge-gate whose head has had none for ten minutes is said as the forge failing that pull request's merge check (a provider standing, ADR 0224), which the controller keeps as a condition, and is recovered when a check arrives or it closes. - The client refuses a pull request or issue number that is not one before asking the forge (a misnamed tool argument sent pulls/NaN).
432 lines
23 KiB
TypeScript
432 lines
23 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.
|
|
//
|
|
// 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 { GiteaClient, movedSince } from "./client.js";
|
|
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
|
type RepoCheckFacts, reposToLook, unannounced, type OpenPull } from "./pulls.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";
|
|
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
|
|
const announced = new Set<string>();
|
|
let primedMerges = false;
|
|
// since is the moment the watching began: a merge made before it is history, whatever page of the
|
|
// forge's listing it surfaces on. Without it, an old merge past the first page — pushed into view
|
|
// as newer pull requests were updated — was announced as if it had just happened, and the mesh
|
|
// rebuilt everything built from that repository, once per old merge (2026-09-28).
|
|
let since = "";
|
|
if (mergedRecord && existsSync(mergedRecord)) {
|
|
try {
|
|
const kept = JSON.parse(readFileSync(mergedRecord, "utf8")) as string[] | { announced: string[]; since: string };
|
|
const list = Array.isArray(kept) ? kept : kept.announced;
|
|
for (const sha of list) announced.add(sha);
|
|
since = Array.isArray(kept) ? new Date().toISOString() : kept.since;
|
|
primedMerges = true;
|
|
} catch {
|
|
// An unreadable record is treated as no record: prime again rather than re-announce history.
|
|
}
|
|
}
|
|
function keepAnnounced(): void {
|
|
if (!mergedRecord) return;
|
|
mkdirSync(join(mergedRecord, ".."), { recursive: true });
|
|
const tmp = mergedRecord + ".tmp";
|
|
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
|
renameSync(tmp, mergedRecord);
|
|
}
|
|
// **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> {
|
|
const began = new Date().toISOString();
|
|
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
|
|
const repos = movedSince(await client.listAllRepos(), floor);
|
|
let changed = false;
|
|
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 emit("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 });
|
|
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
|
}
|
|
announced.add(closedKey);
|
|
changed = true;
|
|
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) {
|
|
// 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 emit("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}`);
|
|
}
|
|
announced.add(pull.merge_commit_sha);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!primedMerges) since = new Date().toISOString();
|
|
if (!primedMerges || changed) keepAnnounced();
|
|
primedMerges = true;
|
|
lastLook = began;
|
|
}
|
|
|
|
// **Every new head of an open pull request is announced, once** (novox/hq to-be 45 §9): the mesh checks it
|
|
// against every machine of its facts before it merges. Kept beside the merges' record, so a restart
|
|
// announces nothing twice; the first look on a machine with no record announces only what moved in the
|
|
// last day, so a forge's whole backlog is not checked at once.
|
|
const pullsRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-announced.json") : null;
|
|
let heads: Announced = {};
|
|
let primedPulls = false;
|
|
if (pullsRecord && existsSync(pullsRecord)) {
|
|
try {
|
|
heads = (JSON.parse(readFileSync(pullsRecord, "utf8")) as { heads: Announced }).heads ?? {};
|
|
primedPulls = true;
|
|
} catch {
|
|
// An unreadable record is no record: the first look announces only the last day's.
|
|
}
|
|
}
|
|
function keepHeads(): void {
|
|
if (!pullsRecord) return;
|
|
mkdirSync(join(pullsRecord, ".."), { recursive: true });
|
|
const tmp = pullsRecord + ".tmp";
|
|
writeFileSync(tmp, JSON.stringify({ heads }));
|
|
renameSync(tmp, pullsRecord);
|
|
}
|
|
let lastPullLook = "";
|
|
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
|
|
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
|
|
const SWEEP_MS = 10 * 60_000;
|
|
let lastSweep = 0;
|
|
async function pollPulls(client: GiteaClient): Promise<void> {
|
|
const began = new Date().toISOString();
|
|
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
|
|
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
|
|
let changed = false;
|
|
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
|
|
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
|
|
let sweep = Date.now() - lastSweep >= SWEEP_MS;
|
|
let updated: { owner: string; name: string; updated_at?: string }[] = [];
|
|
if (floor && !sweep) {
|
|
try {
|
|
updated = await client.searchOpenPulls(floor);
|
|
} catch (err) {
|
|
console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`);
|
|
sweep = true;
|
|
}
|
|
}
|
|
if (sweep) lastSweep = Date.now();
|
|
for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) {
|
|
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", sort: "recentupdate", limit: "20" });
|
|
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
|
|
const key = `${repo.full_name}#${pull.number}`;
|
|
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
|
|
if (fresh) {
|
|
const files = await client.listPullFiles(repo.owner, repo.name, pull.number);
|
|
const head = String(pull.head_sha);
|
|
// What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as
|
|
// amended): which changed directories hold a module at the head — the merge's rule (issue 278) —
|
|
// and whether the head holds the repository's own merge-check.sh. Not said when it could not be
|
|
// read; the controller then reads the change as touching everything built from the repository.
|
|
let moduleDirs: string[] | null = null;
|
|
let mergeCheck: boolean | null = null;
|
|
try {
|
|
if (!files.truncated) moduleDirs = await client.moduleDirsAt(repo.owner, repo.name, head, files.paths);
|
|
mergeCheck = await client.holdsFile(repo.owner, repo.name, head, "merge-check.sh");
|
|
} catch (err) {
|
|
console.error(`[gitea] ${key}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
await emit("pull.updated", {
|
|
owner: repo.owner,
|
|
repo: repo.name,
|
|
number: pull.number,
|
|
title: pull.title,
|
|
body: pull.body,
|
|
base: pull.base,
|
|
head: pull.head,
|
|
head_sha: pull.head_sha,
|
|
clone_url: repo.clone_url,
|
|
html_url: pull.html_url,
|
|
paths: files.paths,
|
|
paths_truncated: files.truncated,
|
|
removed: files.removed,
|
|
...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}),
|
|
...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}),
|
|
});
|
|
// Said, so an operator knows the mesh was asked to check it.
|
|
console.log(`[gitea] announced ${key} at ${String(pull.head_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.
|
|
await client
|
|
.setCommitStatus(repo.owner, repo.name, String(pull.head_sha), {
|
|
state: "pending", context: CHECK_CONTEXT, description: "the mesh is mapping this head onto its module graph",
|
|
})
|
|
.catch((err) => console.error(`[gitea] ${key}: could not say a check is pending — ${err instanceof Error ? err.message : err}`));
|
|
}
|
|
heads[key] = String(pull.head_sha);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!primedPulls || changed) keepHeads();
|
|
primedPulls = true;
|
|
lastPullLook = began;
|
|
}
|
|
|
|
// **An open pull request nobody checks is said** (novox/hq issue 347). One whose base requires the merge check and
|
|
// whose head has had none for ten minutes was announced by nobody — the fault this watcher exists for, which went
|
|
// unseen while the pull request could not merge. Said as this forge failing the merge check, the provider standing
|
|
// the controller keeps as a condition (ADR 0224) and says to the operator; said again every pass while it lasts, and
|
|
// recovered when a check arrives or the pull request is no longer open. Asked of the forge itself, never of what
|
|
// the announcer remembers, so a fault in the announcer cannot hide itself.
|
|
const unannouncedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "pulls-unannounced.json") : null;
|
|
let failing: Record<string, string> = {};
|
|
if (unannouncedRecord && existsSync(unannouncedRecord)) {
|
|
try {
|
|
failing = (JSON.parse(readFileSync(unannouncedRecord, "utf8")) as { failing: Record<string, string> }).failing ?? {};
|
|
} catch {
|
|
// Unreadable is no record: a pull request still unchecked is said again at once.
|
|
}
|
|
}
|
|
function keepFailing(): void {
|
|
if (!unannouncedRecord) return;
|
|
mkdirSync(join(unannouncedRecord, ".."), { recursive: true });
|
|
const tmp = unannouncedRecord + ".tmp";
|
|
writeFileSync(tmp, JSON.stringify({ failing }));
|
|
renameSync(tmp, unannouncedRecord);
|
|
}
|
|
const consumerOf = (key: string) => `the merge check of ${key}`;
|
|
async function watchUnannounced(client: GiteaClient): Promise<void> {
|
|
const open: OpenPull[] = [];
|
|
const rules = new Map<string, Awaited<ReturnType<GiteaClient["branchProtections"]>> | undefined>();
|
|
for (const found of await client.searchOpenPulls()) {
|
|
const full = `${found.owner}/${found.name}`;
|
|
const pull = await client.getPullRequest(found.owner, found.name, found.number);
|
|
if (pull.state !== "open" || !pull.head_sha) continue;
|
|
if (!rules.has(full)) rules.set(full, await client.branchProtections(found.owner, found.name).catch(() => undefined));
|
|
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
|
|
const statuses = Object.keys(await client.commitStatuses(found.owner, found.name, pull.head_sha));
|
|
open.push({ key: `${full}#${pull.number}`, base: pull.base ?? "", head_sha: pull.head_sha, updated_at: pull.updated_at,
|
|
statuses, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [] });
|
|
}
|
|
const now = Date.now();
|
|
const unchecked = new Map(unannounced(open, now).map((p) => [p.key, p] as const));
|
|
const node = process.env.MESH_NODE ?? "";
|
|
for (const [key, p] of unchecked) {
|
|
const since = failing[key] ?? p.updated_at ?? new Date(now).toISOString();
|
|
await emit("provisioner.failing", {
|
|
provider: "git", "provider-node": node, consumer: consumerOf(key), node: "", class: "unannounced",
|
|
error: `${key} is open on ${p.base}, which requires ${CHECK_CONTEXT}, and its head ${p.head_sha.slice(0, 12)} has had no ` +
|
|
`merge check for ${Math.round((now - Date.parse(p.updated_at ?? since)) / 60_000)} minutes: the mesh was never asked to check it`,
|
|
since, attempts: 1,
|
|
});
|
|
if (!failing[key]) console.error(`[gitea] ${key} has had no merge check for ten minutes: said to the mesh`);
|
|
failing[key] = since;
|
|
}
|
|
for (const key of Object.keys(failing)) {
|
|
if (unchecked.has(key)) continue;
|
|
await emit("provisioner.recovered", {
|
|
provider: "git", "provider-node": node, consumer: consumerOf(key), node: "", since: failing[key], attempts: 0,
|
|
why: "it has a merge check, or is no longer open",
|
|
});
|
|
console.log(`[gitea] ${key} has a merge check now, or is no longer open`);
|
|
delete failing[key];
|
|
}
|
|
keepFailing();
|
|
}
|
|
|
|
// **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) {
|
|
// A delivery group's composed check (novox/hq ADR 0239): its verdict is the group owner's to say, on each
|
|
// member's head, as mesh/delivery-group — never this head's merge gate.
|
|
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;
|
|
}
|
|
// 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;
|
|
}
|
|
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
|
|
for (const status of statusesFor(c, facts)) {
|
|
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
|
|
}
|
|
const comment = commentFor(c, facts);
|
|
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}`);
|
|
}
|
|
|
|
// **What only the forge knows of a repository check said as a warning** (novox/hq issue 293): whether the
|
|
// head holds a merge-check.sh at all, and — when it does not — whether the base branch's protection
|
|
// requires mesh/repo-check. Asked only for a warning; unknown is left undefined, which statusesFor reads
|
|
// as possibly required: a failure on a status nothing requires blocks nothing, a success on one that is
|
|
// required would let an untested repository merge.
|
|
async function repoCheckFacts(client: GiteaClient, c: Checked, base: string | undefined): Promise<RepoCheckFacts> {
|
|
if (c["repo-check"]?.verdict !== "warning") return {};
|
|
const defined = await client.holdsFile(c.owner, c.repo, c.commit, "merge-check.sh").catch(() => undefined);
|
|
if (defined !== false) return { defined };
|
|
const rules = await client.branchProtections(c.owner, c.repo).catch(() => undefined);
|
|
if (!rules) return { defined };
|
|
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === (base || "main"));
|
|
const required = !!rule?.enable_status_check && (rule.status_check_contexts ?? []).includes(REPO_CHECK_CONTEXT);
|
|
return { defined, required };
|
|
}
|
|
|
|
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>, everyMs: number): void => {
|
|
// **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();
|
|
};
|
|
tick(() => pollRepos(client), 60_000);
|
|
tick(() => pollMerged(client), 30_000);
|
|
tick(() => pollPulls(client), 30_000);
|
|
tick(() => watchUnannounced(client), 5 * 60_000);
|
|
await on("mesh-controller.checked", (event) => setVerdict(client, event));
|
|
console.log("[gitea] watching for new repositories and merged pull requests");
|
|
}
|