issue 976: the forge's announcements get a deterministic message id; the announcement files go, the poll's watermark is kept in the module's state
This commit is contained in:
+41
-56
@@ -39,6 +39,7 @@
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { registerModuleTools } from "@novox/mesh-sdk/tools";
|
||||
import { commitStatusTool } from "./delivery.js";
|
||||
import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
|
||||
@@ -90,32 +91,17 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
||||
// 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;
|
||||
// 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;
|
||||
// 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);
|
||||
}
|
||||
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
|
||||
@@ -123,10 +109,14 @@ function keepAnnounced(): void {
|
||||
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);
|
||||
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) {
|
||||
@@ -134,13 +124,12 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
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,
|
||||
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 });
|
||||
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
|
||||
await siblingMoves(client, repo.full_name, pull.number);
|
||||
}
|
||||
announced.add(closedKey);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||
@@ -152,11 +141,11 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
}
|
||||
else announced.add(pull.merge_commit_sha);
|
||||
announced.add(pull.merge_commit_sha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!primedMerges) since = new Date().toISOString();
|
||||
if (!primedMerges || changed) keepAnnounced();
|
||||
// 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;
|
||||
}
|
||||
@@ -196,7 +185,7 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P
|
||||
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", {
|
||||
await announce("pull.merged", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
number: pull.number,
|
||||
@@ -226,32 +215,17 @@ async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): P
|
||||
announced.delete(pull.merge_commit_sha);
|
||||
throw err;
|
||||
}
|
||||
keepAnnounced();
|
||||
return true;
|
||||
}
|
||||
|
||||
// **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;
|
||||
// **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;
|
||||
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);
|
||||
}
|
||||
/** **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 = `${repo.full_name}#${pull.number}`;
|
||||
@@ -261,13 +235,12 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
|
||||
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 emit("pull.updated", await announcementOf(client, repo.owner, repo.name, repo.clone_url ?? "", pull));
|
||||
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;
|
||||
}
|
||||
keepHeads();
|
||||
// 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.
|
||||
@@ -279,6 +252,16 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
|
||||
}
|
||||
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;
|
||||
} catch {
|
||||
return false; // the forge cannot say: announced, and the bus's message id collapses it if it was
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -288,7 +271,6 @@ 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;
|
||||
@@ -309,14 +291,17 @@ async function pollPulls(client: GiteaClient): Promise<void> {
|
||||
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);
|
||||
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
|
||||
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
|
||||
heads[key] = String(pull.head_sha);
|
||||
continue;
|
||||
}
|
||||
if (fresh) {
|
||||
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
|
||||
} else heads[key] = String(pull.head_sha);
|
||||
changed = true;
|
||||
}
|
||||
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
||||
}
|
||||
if (!primedPulls || changed) keepHeads();
|
||||
if (changedTrunks) keepTrunks();
|
||||
changedTrunks = false;
|
||||
primedPulls = true;
|
||||
|
||||
Reference in New Issue
Block a user