diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 0aafb42..e9e10b7 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -17,6 +17,8 @@ export interface GiteaRepo { description?: string; html_url: string; default_branch?: string; + /** When anything last moved in it — a push, and so a merge. */ + updated_at?: string; } /** An issue, with its labels flattened to names. */ @@ -356,6 +358,7 @@ export class GiteaClient { description: r.description || undefined, html_url: r.html_url, default_branch: r.default_branch, + updated_at: r.updated_at ?? undefined, }; } @@ -598,3 +601,11 @@ export class GiteaAdmin { GiteaAdmin.fail(`/admin/users/${username}`, res); } } + +/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose + * update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */ +export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] { + if (!floor) return repos; + const at = Date.parse(floor); + return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at)); +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index fcb437d..d30214f 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -14,7 +14,7 @@ // the forge for an immediacy nobody asked for is not. import { emit } from "@novox/mesh-sdk/events"; -import { GiteaClient } from "./client.js"; +import { GiteaClient, movedSince } from "./client.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 @@ -84,8 +84,16 @@ function keepAnnounced(): void { 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 { - const repos = await client.listAllRepos(); + 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" }); @@ -124,6 +132,7 @@ async function pollMerged(client: GiteaClient): Promise { if (!primedMerges) since = new Date().toISOString(); if (!primedMerges || changed) keepAnnounced(); primedMerges = true; + lastLook = began; } if (gitea) { @@ -132,6 +141,9 @@ if (gitea) { // 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, 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(() => { @@ -142,8 +154,8 @@ if (gitea) { const why = err instanceof Error ? err.message : String(err); if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`); failing = why; - }); - setInterval(run, everyMs); + }) + .finally(() => setTimeout(run, everyMs)); run(); }; tick(() => pollRepos(client), 60_000); diff --git a/modules/gitea/test/pages.test.ts b/modules/gitea/test/pages.test.ts index 6e6d964..59597a6 100644 --- a/modules/gitea/test/pages.test.ts +++ b/modules/gitea/test/pages.test.ts @@ -22,3 +22,15 @@ test("every page of a pull request's files is read, though the forge caps a page assert.equal(got.truncated, false); assert.equal(new Set(got.paths).size, total); }); + +test("a pass asks only the repositories that moved since the last look, every one before the first", async () => { + const { movedSince } = await import("../client.ts"); + const repos = [ + { full_name: "a/old", name: "old", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T10:00:00Z" }, + { full_name: "a/new", name: "new", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T16:10:02Z" }, + { full_name: "a/unknown", name: "unknown", owner: "a", private: false, html_url: "" }, + ]; + assert.deepEqual(movedSince(repos, "").map((r) => r.name), ["old", "new", "unknown"]); + assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]); + assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string"); +});