Merge pull request 'gitea: a merge poll looks only at repositories that moved, one pass at a time (hq issue 250)' (#66) from fix/the-merge-poll-looks-only-at-what-moved into main

This commit was merged in pull request #66.
This commit is contained in:
2026-10-05 16:12:13 +00:00
3 changed files with 39 additions and 4 deletions
+11
View File
@@ -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));
}
+16 -4
View File
@@ -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<void> {
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<void> {
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<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(() => {
@@ -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);
+12
View File
@@ -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");
});