Files
mesh-catalog/modules/gitea/watch.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

88 lines
4.5 KiB
TypeScript

// What the forge's events ask of the announcer, and the one pass at a start (issue 1422, notes 10 and 11). Nothing polls the
// forge: a head or merge is announced when its event is heard, and a pass over every repository runs once when the module
// starts and again when the receiver is listening, so what happened while it was down is announced too.
//
// Apart from index.ts, which has side effects at import, so these are tested with the announcers injected.
import type { GiteaPull, GiteaRepo } from "./client.js";
import type { Announced } from "./pulls.js";
import { headKey, headsToAnnounce } from "./pulls.js";
import type { ForgeEvent } from "./webhook.js";
export interface ActDeps<R, P> {
getRepo(owner: string, repo: string): Promise<R>;
getPull(owner: string, repo: string, number: number): Promise<P & { state?: string; merged?: boolean }>;
announceHead(repo: R, pull: P): Promise<unknown>;
announceMerge(repo: R, pull: P): Promise<unknown>;
announceClosed(repo: R, pull: P): Promise<unknown>;
siblingMoves(full: string, number: number): Promise<unknown>;
}
const HEAD_EVENTS = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"];
/**
* What the forge's event asks of the announcer at once: a new head is announced, a merge, a close without a merge, and a
* pull request retargeted (an edit: the head is announced into the base it now has — the base is in the head's key, so an
* edit that changed nothing about the base announces nothing).
*/
export async function actOn<R, P>(event: ForgeEvent, d: ActDeps<R, P>): Promise<void> {
const b = event.body as { owner?: string; repo?: string; number?: number; merged?: boolean };
if (!b.owner || !b.repo || !b.number) return;
const edited = event.name === "forge.pull-request.edited";
const closed = event.name === "forge.pull-request.closed";
const head = HEAD_EVENTS.includes(event.name) || edited;
const merged = event.name === "forge.pull-request.merged";
if (!head && !merged && !closed) return;
if (closed && b.merged) return; // a merge is weighed with its own event (ADR 0382 §5)
const [repo, pull] = await Promise.all([d.getRepo(b.owner, b.repo), d.getPull(b.owner, b.repo, b.number)]);
if (closed) {
await d.announceClosed(repo, pull);
await d.siblingMoves(`${b.owner}/${b.repo}`, b.number);
return;
}
if (head && pull.state === "open") await d.announceHead(repo, pull);
if (merged) await d.announceMerge(repo, pull);
}
/**
* The one pass at a start, and its retry: run now; if it fails, say so and run again after `retryMs`, until one pass has
* ended. A retry of a failed pass, never a poll: after a pass has ended nothing is scheduled.
*/
export function startPass(run: () => Promise<void>, opts: { retryMs: number; log?: (l: string) => void }): Promise<void> {
const log = opts.log ?? ((l) => console.error(`[gitea] ${l}`));
return run().catch((err) => {
log(`the pass over every repository failed, asked again in ${opts.retryMs / 1000} s — ${err instanceof Error ? err.message : err}`);
return new Promise<void>((resolve) => setTimeout(() => resolve(startPass(run, opts)), opts.retryMs));
});
}
export interface HeadDeps {
/** Whether the forge shows a merge check of the head: only an announced head has one. */
alreadyChecked(pull: GiteaPull): Promise<boolean>;
/** Announce it; true when it was this call that announced it (not an event before it). */
announceHead(pull: GiteaPull): Promise<boolean>;
/** Told of a head this pass announced that no event had. */
missed(what: string): void;
}
/**
* The heads of one repository's open pull requests a pass announces. A head is announced when it is new and fresh (moved in
* the last day); at the first pass after a start (`primed` false) one the forge shows a merge check of was announced
* already, and is only remembered.
*/
export async function announceNewHeads(full: string, open: GiteaPull[], heads: Announced, primed: boolean, dayAgo: string, d: HeadDeps): Promise<void> {
for (const pull of headsToAnnounce(full, open, heads)) {
const key = headKey(full, pull.number, pull.base);
const fresh = primed || (!!pull.updated_at && pull.updated_at > dayAgo);
if (fresh && !primed && await d.alreadyChecked(pull)) {
heads[key] = String(pull.head_sha);
continue;
}
if (fresh) {
if (await d.announceHead(pull)) d.missed(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
}
}
export type { GiteaRepo };