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,