From a433a9496b1fe480f638823a5b199eefcb8c79b5 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:57:25 -0700 Subject: [PATCH] fix(reopen): cap issue-events pagination at 10 pages and cover scoring branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pagination cap: getLastCloserLogin now exits after 10 pages (1 000 events) instead of looping indefinitely — prevents GitHub API rate exhaustion on pathologically long issue timelines. Returns the best lastCloser found within the cap; adds a test that validates the cap fires and page 11 is never fetched. Scoring coverage: add tests for the four uncovered branches in the #1192 diff: - fetchUpstreamRefSha catch path (network throw → null → mutable-ref fallback) - non-string SHA response (typeof guard → null → mutable-ref fallback) - empty SHA string (length guard → null → mutable-ref fallback) - prior fallback snapshot on second fail-closed attempt (sourceKind === "fallback" guard → does not freeze the prior fallback, bootstraps fresh defaults instead) --- src/github/pr-actions.ts | 4 +- test/unit/github-pr-actions.test.ts | 22 +++++++++++ test/unit/scoring.test.ts | 57 +++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/github/pr-actions.ts b/src/github/pr-actions.ts index eb1a44465f..c3055d22a0 100644 --- a/src/github/pr-actions.ts +++ b/src/github/pr-actions.ts @@ -120,7 +120,8 @@ export async function getLastCloserLogin(env: Env, installationId: number, repoF const token = await createInstallationToken(env, installationId); const octokit = new Octokit({ auth: token }); let lastCloser: string | null = null; - for (let page = 1; ; page += 1) { + // Cap at 10 pages (1 000 events) — enough for any real PR timeline without risking API rate exhaustion. + for (let page = 1; page <= 10; page += 1) { // issue-events are returned oldest-first; walk every page so the final `closed` entry is truly the latest. const response = await octokit.request("GET /repos/{owner}/{repo}/issues/{issue_number}/events", { owner, repo, issue_number: issueNumber, per_page: 100, page }); const events = response.data as Array<{ event?: string; actor?: { login?: string | null } | null }>; @@ -129,6 +130,7 @@ export async function getLastCloserLogin(env: Env, installationId: number, repoF } if (events.length < 100) return lastCloser; } + return lastCloser; } catch { return null; } diff --git a/test/unit/github-pr-actions.test.ts b/test/unit/github-pr-actions.test.ts index 8f31010e2c..e91514eb0e 100644 --- a/test/unit/github-pr-actions.test.ts +++ b/test/unit/github-pr-actions.test.ts @@ -127,6 +127,28 @@ describe("GitHub PR action primitives (#778)", () => { }); await expect(getLastCloserLogin(envWithKey(), 123, "owner/repo", 19)).resolves.toBeNull(); }); + + it("returns the best-known closer when the 10-page event cap is reached (page-cap exit path)", async () => { + const fetchedPages: number[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + if (url.includes("/issues/20/events")) { + const page = Number(new URL(url).searchParams.get("page") ?? "1"); + fetchedPages.push(page); + // Page 5 contains the close event; all pages return exactly 100 entries so the loop never exits early. + const events = Array.from({ length: 100 }, (_, i) => + page === 5 && i === 50 ? { event: "closed", actor: { login: "capped-closer" } } : { event: "labeled" }, + ); + return Response.json(events); + } + return new Response("unexpected", { status: 500 }); + }); + await expect(getLastCloserLogin(envWithKey(), 123, "owner/repo", 20)).resolves.toBe("capped-closer"); + // Loop ran pages 1–10 and then exited via the cap, never requesting page 11. + expect(fetchedPages).toHaveLength(10); + expect(fetchedPages).not.toContain(11); + }); }); function generateRsaPrivateKeyPem(): string { diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 44bec497e4..e532b42aae 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -531,6 +531,63 @@ NOVELTY_BONUS_SCALAR = 3 expect(bootstrap.warnings.join(" ")).toMatch(/fetch failed/i); }); + it("does not freeze a prior fallback snapshot — bootstraps fresh defaults instead", async () => { + const env = createTestEnv(); + vi.stubGlobal("fetch", async () => new Response("gone", { status: 410 })); + // First refresh → fallback stored (no verified last-good yet). + const first = await refreshScoringModelSnapshot(env); + expect(first.sourceKind).toBe("fallback"); + // Second refresh — constants still fail; lastGood exists but sourceKind === "fallback" + // → the guard (lastGood && sourceKind !== "fallback") is false → must NOT freeze → new fallback. + const second = await refreshScoringModelSnapshot(env); + expect(second.sourceKind).toBe("fallback"); + expect(second.id).not.toBe(first.id); + expect(second.warnings.join(" ")).not.toMatch(/froze the last-good/i); + }); + + it("falls back to the mutable ref when the upstream SHA lookup throws (fetchUpstreamRefSha catch path)", async () => { + const env = createTestEnv({ GITTENSOR_UPSTREAM_REPO: "custom/upstream", GITTENSOR_UPSTREAM_REF: "test" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("api.github.com") && url.includes("/commits/")) throw new Error("network failure"); + if (url.includes("constants.py")) return new Response("MERGED_PR_BASE_SCORE = 25\n"); + if (url.includes("programming_languages.json")) return Response.json({}); + return new Response("not found", { status: 404 }); + }); + const snapshot = await refreshScoringModelSnapshot(env); + expect(snapshot.sourceUrl).toContain("/test/gittensor/constants.py"); + expect((snapshot.payload as Record).upstreamSourceSha).toBeUndefined(); + expect(snapshot.sourceKind).toBe("raw-github"); + }); + + it("falls back to the mutable ref when the SHA endpoint returns a non-string sha", async () => { + const env = createTestEnv({ GITTENSOR_UPSTREAM_REPO: "custom/upstream", GITTENSOR_UPSTREAM_REF: "test" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("api.github.com") && url.includes("/commits/")) return Response.json({ sha: 42 }); + if (url.includes("constants.py")) return new Response("MERGED_PR_BASE_SCORE = 25\n"); + if (url.includes("programming_languages.json")) return Response.json({}); + return new Response("not found", { status: 404 }); + }); + const snapshot = await refreshScoringModelSnapshot(env); + expect(snapshot.sourceUrl).toContain("/test/gittensor/constants.py"); + expect((snapshot.payload as Record).upstreamSourceSha).toBeUndefined(); + }); + + it("falls back to the mutable ref when the SHA endpoint returns an empty sha string", async () => { + const env = createTestEnv({ GITTENSOR_UPSTREAM_REPO: "custom/upstream", GITTENSOR_UPSTREAM_REF: "test" }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("api.github.com") && url.includes("/commits/")) return Response.json({ sha: "" }); + if (url.includes("constants.py")) return new Response("MERGED_PR_BASE_SCORE = 25\n"); + if (url.includes("programming_languages.json")) return Response.json({}); + return new Response("not found", { status: 404 }); + }); + const snapshot = await refreshScoringModelSnapshot(env); + expect(snapshot.sourceUrl).toContain("/test/gittensor/constants.py"); + expect((snapshot.payload as Record).upstreamSourceSha).toBeUndefined(); + }); + it("uses saturation math as the active private preview model", () => { const saturationSnapshot: ScoringModelSnapshotRecord = { ...snapshot,