Skip to content

Commit 131f593

Browse files
committed
fix(api): hoists fork types, removes dead code, adds missing tests
1 parent 05ab22f commit 131f593

File tree

8 files changed

+90
-101
lines changed

8 files changed

+90
-101
lines changed

src/app/services/api.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,22 @@ interface GraphQLPRSearchResponse {
272272
rateLimit?: { remaining: number; resetAt: string };
273273
}
274274

275+
interface ForkCandidate {
276+
pr: PullRequest;
277+
headOwner: string;
278+
headRepo: string;
279+
sha: string;
280+
}
281+
282+
interface ForkRepoResult {
283+
object: { statusCheckRollup: { state: string } | null } | null;
284+
}
285+
286+
interface ForkQueryResponse {
287+
rateLimit?: { remaining: number; resetAt: string };
288+
[key: string]: ForkRepoResult | { remaining: number; resetAt: string } | undefined | null;
289+
}
290+
275291
// ── GraphQL search query constants ───────────────────────────────────────────
276292

277293
const ISSUES_SEARCH_QUERY = `
@@ -626,12 +642,6 @@ async function graphqlSearchPRs(
626642
// differs from base repo owner, query the head repo's commit statusCheckRollup.
627643
// GitHub copies fork PR commits into base repo (refs/pull/N/head), so most PRs
628644
// resolve via the base repo. The fallback handles cases where CI runs only on the fork.
629-
interface ForkCandidate {
630-
pr: PullRequest;
631-
headOwner: string;
632-
headRepo: string;
633-
sha: string;
634-
}
635645
const forkCandidates: ForkCandidate[] = [];
636646

637647
for (const [databaseId, pr] of prMap) {
@@ -669,14 +679,6 @@ async function graphqlSearchPRs(
669679
const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { remaining resetAt }\n}`;
670680

671681
try {
672-
interface ForkRepoResult {
673-
object: { statusCheckRollup: { state: string } | null } | null;
674-
}
675-
interface ForkQueryResponse {
676-
rateLimit?: { remaining: number; resetAt: string };
677-
[key: string]: ForkRepoResult | { remaining: number; resetAt: string } | undefined | null;
678-
}
679-
680682
const forkResponse = await octokit.graphql<ForkQueryResponse>(forkQuery, variables);
681683
if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { remaining: number; resetAt: string });
682684

src/app/services/poll.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ const REJITTER_WINDOW_MS = 30_000; // ±30 seconds jitter
203203
const REVISIT_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
204204

205205
// Sources managed by the poll coordinator — used for reconciliation
206-
const POLL_MANAGED_SOURCES = new Set(["poll", "search", "graphql", "rate-limit", "notifications"]);
206+
const POLL_MANAGED_SOURCES = new Set(["poll", "graphql", "rate-limit", "notifications"]);
207207

208208
function withJitter(intervalMs: number): number {
209209
const jitter = (Math.random() * 2 - 1) * REJITTER_WINDOW_MS;

src/app/stores/cache.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -100,30 +100,6 @@ export async function clearCache(): Promise<void> {
100100
await db.clear("cache");
101101
}
102102

103-
/**
104-
* Evicts cache entries whose key starts with `prefix` but is NOT in `keepKeys`.
105-
* Used to clean up per-PR cache entries for PRs no longer in the active set.
106-
*/
107-
export async function evictByPrefix(
108-
prefix: string,
109-
keepKeys: Set<string>
110-
): Promise<number> {
111-
const db = await getDb();
112-
const tx = db.transaction("cache", "readwrite");
113-
const range = IDBKeyRange.bound(prefix, prefix + "\uffff");
114-
let cursor = await tx.store.openCursor(range);
115-
let count = 0;
116-
while (cursor) {
117-
if (!keepKeys.has(cursor.key as string)) {
118-
await cursor.delete();
119-
count++;
120-
}
121-
cursor = await cursor.continue();
122-
}
123-
await tx.done;
124-
return count;
125-
}
126-
127103
export async function evictStaleEntries(maxAgeMs: number): Promise<number> {
128104
const db = await getDb();
129105
const tx = db.transaction("cache", "readwrite");

tests/components/layout/Header.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ describe("Header", () => {
103103
vi.mocked(githubService.getCoreRateLimit).mockRestore();
104104
});
105105

106+
it("shows GraphQL rate limit with label when available", () => {
107+
vi.spyOn(githubService, "getGraphqlRateLimit").mockReturnValue({
108+
remaining: 4800,
109+
resetAt: new Date("2024-01-10T09:00:00Z"),
110+
});
111+
render(() => <Header />);
112+
screen.getByText(/GraphQL/);
113+
screen.getByText(/4800\/5k\/hr/);
114+
vi.mocked(githubService.getGraphqlRateLimit).mockRestore();
115+
});
116+
106117
it("does not show rate limit when not available", () => {
107118
render(() => <Header />);
108119
expect(screen.queryByText(/\/5k\/hr/)).toBeNull();

tests/integration/data-pipeline.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,14 @@ const rawRun = {
3232
head_sha: "abc1234",
3333
head_branch: "main",
3434
run_number: 1,
35+
run_attempt: 1,
3536
html_url: "https://github.com/octocat/Hello-World/actions/runs/9001",
3637
created_at: "2024-01-15T09:00:00Z",
3738
updated_at: "2024-01-15T09:10:00Z",
39+
run_started_at: "2024-01-15T09:00:00Z",
40+
completed_at: "2024-01-15T09:10:00Z",
41+
display_title: "CI",
42+
actor: { login: "octocat", avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4" },
3843
};
3944

4045
const graphqlIssueNode = {

tests/services/api.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,10 +453,10 @@ const graphqlPRNode = {
453453
commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
454454
};
455455

456-
function makeGraphqlPRResponse(nodes = [graphqlPRNode], hasNextPage = false) {
456+
function makeGraphqlPRResponse(nodes = [graphqlPRNode], hasNextPage = false, issueCount?: number) {
457457
return {
458458
search: {
459-
issueCount: nodes.length,
459+
issueCount: issueCount ?? nodes.length,
460460
pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor1" : null },
461461
nodes,
462462
},
@@ -691,6 +691,32 @@ describe("fetchPullRequests", () => {
691691
expect(pushNotification).not.toHaveBeenCalled();
692692
});
693693

694+
it("caps at 1000 PRs and warns via pushNotification", async () => {
695+
vi.mocked(pushNotification).mockClear();
696+
697+
const manyNodes = Array.from({ length: 1000 }, (_, i) => ({ ...graphqlPRNode, databaseId: i + 1 }));
698+
const octokit = makePROctokit(async (_query, variables) => {
699+
const q = (variables as Record<string, unknown>).q as string;
700+
if (q.includes("involves:")) {
701+
return makeGraphqlPRResponse(manyNodes, true, 1500);
702+
}
703+
return makeGraphqlPRResponse([]);
704+
});
705+
706+
const result = await fetchPullRequests(
707+
octokit as unknown as ReturnType<typeof import("../../src/app/services/github").getClient>,
708+
[testRepo],
709+
"octocat"
710+
);
711+
712+
expect(result.pullRequests.length).toBe(1000);
713+
expect(pushNotification).toHaveBeenCalledWith(
714+
"search/prs",
715+
expect.stringContaining("capped at 1,000"),
716+
"warning"
717+
);
718+
});
719+
694720
it("returns empty result when repos is empty", async () => {
695721
const octokit = makePROctokit();
696722
const result = await fetchPullRequests(

tests/services/github.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "fake-indexeddb/auto";
22
import { describe, it, expect, vi, beforeEach } from "vitest";
33
import { createRoot } from "solid-js";
4-
import { createGitHubClient, cachedRequest, getClient, initClientWatcher } from "../../src/app/services/github";
4+
import { createGitHubClient, cachedRequest, getClient, initClientWatcher, getGraphqlRateLimit, updateGraphqlRateLimit } from "../../src/app/services/github";
55
import { clearCache } from "../../src/app/stores/cache";
66

77
// ── createGitHubClient ───────────────────────────────────────────────────────
@@ -292,3 +292,31 @@ describe("getClient / initClientWatcher", () => {
292292
void authModule;
293293
});
294294
});
295+
296+
// ── getGraphqlRateLimit / updateGraphqlRateLimit ─────────────────────────────
297+
298+
describe("getGraphqlRateLimit", () => {
299+
it("returns null before any update", () => {
300+
// May be non-null if a prior test called updateGraphqlRateLimit;
301+
// verify the function is callable and returns the expected shape
302+
const rl = getGraphqlRateLimit();
303+
expect(rl === null || (typeof rl === "object" && "remaining" in rl)).toBe(true);
304+
});
305+
306+
it("converts ISO 8601 resetAt string to Date", () => {
307+
const iso = "2024-06-01T12:00:00Z";
308+
updateGraphqlRateLimit({ remaining: 4500, resetAt: iso });
309+
const rl = getGraphqlRateLimit();
310+
expect(rl).not.toBeNull();
311+
expect(rl!.remaining).toBe(4500);
312+
expect(rl!.resetAt).toBeInstanceOf(Date);
313+
expect(rl!.resetAt.getTime()).toBe(new Date(iso).getTime());
314+
});
315+
316+
it("overwrites previous value on subsequent updates", () => {
317+
updateGraphqlRateLimit({ remaining: 5000, resetAt: "2024-06-01T12:00:00Z" });
318+
updateGraphqlRateLimit({ remaining: 3000, resetAt: "2024-06-01T13:00:00Z" });
319+
const rl = getGraphqlRateLimit();
320+
expect(rl!.remaining).toBe(3000);
321+
});
322+
});

tests/stores/cache.test.ts

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
deleteCacheEntry,
88
clearCache,
99
evictStaleEntries,
10-
evictByPrefix,
1110
cachedFetch,
1211
} from "../../src/app/stores/cache";
1312

@@ -203,64 +202,6 @@ describe("evictStaleEntries", () => {
203202
});
204203
});
205204

206-
// ── qa-8: evictByPrefix ──────────────────────────────────────────────────────
207-
208-
describe("evictByPrefix", () => {
209-
it("deletes prefix entries not in keepKeys, retains kept and non-prefix entries", async () => {
210-
// Seed two "pr-detail:" entries and one non-prefixed entry
211-
await setCacheEntry("pr-detail:octocat/Hello-World:42", { pr: 42 }, "etag-42");
212-
await setCacheEntry("pr-detail:octocat/Hello-World:99", { pr: 99 }, "etag-99");
213-
await setCacheEntry("other:key", { other: true }, "etag-other");
214-
215-
// Keep pr-detail:octocat/Hello-World:42 but evict :99
216-
const keepKeys = new Set(["pr-detail:octocat/Hello-World:42"]);
217-
const count = await evictByPrefix("pr-detail:", keepKeys);
218-
219-
// Only :99 should have been evicted
220-
expect(count).toBe(1);
221-
// Kept prefix entry must still exist
222-
expect(await getCacheEntry("pr-detail:octocat/Hello-World:42")).toBeDefined();
223-
// Evicted prefix entry must be gone
224-
expect(await getCacheEntry("pr-detail:octocat/Hello-World:99")).toBeUndefined();
225-
// Non-prefix entry must be untouched
226-
expect(await getCacheEntry("other:key")).toBeDefined();
227-
});
228-
229-
it("evicts all prefix entries when keepKeys is empty", async () => {
230-
await setCacheEntry("pr-detail:a/b:1", { pr: 1 }, null);
231-
await setCacheEntry("pr-detail:a/b:2", { pr: 2 }, null);
232-
await setCacheEntry("keep:this", { keep: true }, null);
233-
234-
const count = await evictByPrefix("pr-detail:", new Set());
235-
236-
expect(count).toBe(2);
237-
expect(await getCacheEntry("pr-detail:a/b:1")).toBeUndefined();
238-
expect(await getCacheEntry("pr-detail:a/b:2")).toBeUndefined();
239-
expect(await getCacheEntry("keep:this")).toBeDefined();
240-
});
241-
242-
it("returns 0 when no entries match the prefix", async () => {
243-
await setCacheEntry("other:entry", { data: true }, null);
244-
245-
const count = await evictByPrefix("pr-detail:", new Set());
246-
247-
expect(count).toBe(0);
248-
expect(await getCacheEntry("other:entry")).toBeDefined();
249-
});
250-
251-
it("returns 0 when all prefix entries are in keepKeys", async () => {
252-
await setCacheEntry("pr-detail:x/y:1", { pr: 1 }, null);
253-
await setCacheEntry("pr-detail:x/y:2", { pr: 2 }, null);
254-
255-
const keepKeys = new Set(["pr-detail:x/y:1", "pr-detail:x/y:2"]);
256-
const count = await evictByPrefix("pr-detail:", keepKeys);
257-
258-
expect(count).toBe(0);
259-
expect(await getCacheEntry("pr-detail:x/y:1")).toBeDefined();
260-
expect(await getCacheEntry("pr-detail:x/y:2")).toBeDefined();
261-
});
262-
});
263-
264205
describe("cachedFetch", () => {
265206
it("calls fetchFn with null headers when no cache entry exists", async () => {
266207
const fetchFn = vi.fn().mockResolvedValue({

0 commit comments

Comments
 (0)