diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index af6c247..fa3c6b9 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -16,17 +16,19 @@ async function setupAuth(page: Page) { }, }) ); - await page.route("https://api.github.com/search/issues*", (route) => - route.fulfill({ - status: 200, - json: { total_count: 0, incomplete_results: false, items: [] }, - }) - ); await page.route("https://api.github.com/notifications*", (route) => route.fulfill({ status: 200, json: [] }) ); await page.route("https://api.github.com/graphql", (route) => - route.fulfill({ status: 200, json: { data: {} } }) + route.fulfill({ + status: 200, + json: { + data: { + search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }, + }, + }) ); await page.addInitScript(() => { diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 65369c0..fc2cc64 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -17,12 +17,6 @@ async function setupAuth(page: Page) { }, }) ); - await page.route("https://api.github.com/search/issues*", (route) => - route.fulfill({ - status: 200, - json: { total_count: 0, incomplete_results: false, items: [] }, - }) - ); await page.route( "https://api.github.com/repos/*/actions/runs*", (route) => @@ -35,7 +29,15 @@ async function setupAuth(page: Page) { route.fulfill({ status: 200, json: [] }) ); await page.route("https://api.github.com/graphql", (route) => - route.fulfill({ status: 200, json: { data: {} } }) + route.fulfill({ + status: 200, + json: { + data: { + search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }, + }, + }) ); // Seed localStorage with auth token and config before the page loads @@ -104,10 +106,15 @@ test("OAuth callback flow completes and redirects", async ({ page }) => { ); }); // Also intercept downstream dashboard API calls - await page.route("https://api.github.com/search/issues*", (route) => + await page.route("https://api.github.com/graphql", (route) => route.fulfill({ status: 200, - json: { total_count: 0, incomplete_results: false, items: [] }, + json: { + data: { + search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + rateLimit: { remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }, + }, }) ); await page.route("https://api.github.com/notifications*", (route) => diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 7b148e5..74e9522 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -14,7 +14,7 @@ import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../sto // ── Shared dashboard store (module-level to survive navigation) ───────────── -const CACHE_VERSION = 1; +const CACHE_VERSION = 2; interface DashboardStore { issues: Issue[]; diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index d77d095..c67cf01 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -349,7 +349,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { createdAt={pr.createdAt} url={pr.htmlUrl} labels={pr.labels} - commentCount={pr.comments + pr.reviewComments} + commentCount={pr.comments + pr.reviewThreads} onIgnore={() => handleIgnore(pr)} density={config.viewDensity} > diff --git a/src/app/components/layout/Header.tsx b/src/app/components/layout/Header.tsx index e8ca9e8..21b8c2e 100644 --- a/src/app/components/layout/Header.tsx +++ b/src/app/components/layout/Header.tsx @@ -1,7 +1,7 @@ import { createSignal, Show } from "solid-js"; import { useNavigate } from "@solidjs/router"; import { user, clearAuth } from "../../stores/auth"; -import { getCoreRateLimit, getSearchRateLimit } from "../../services/github"; +import { getCoreRateLimit, getGraphqlRateLimit } from "../../services/github"; import { getUnreadCount, markAllAsRead } from "../../lib/errors"; import NotificationDrawer from "../shared/NotificationDrawer"; import ToastContainer from "../shared/ToastContainer"; @@ -27,7 +27,7 @@ export default function Header() { const unreadCount = () => getUnreadCount(); const coreRL = () => getCoreRateLimit(); - const searchRL = () => getSearchRateLimit(); + const graphqlRL = () => getGraphqlRateLimit(); function formatLimit(remaining: number, limit: number, unit: string): string { const k = limit >= 1000 ? `${limit / 1000}k` : String(limit); @@ -43,7 +43,7 @@ export default function Header() {
- +
Rate Limits
@@ -57,13 +57,13 @@ export default function Header() { )} - + {(rl) => ( - {formatLimit(rl().remaining, 30, "min")} + GraphQL {formatLimit(rl().remaining, 5000, "hr")} )} diff --git a/src/app/services/api.ts b/src/app/services/api.ts index e01404a..6393037 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1,5 +1,4 @@ -import { getClient, cachedRequest, updateRateLimitFromHeaders } from "./github"; -import { evictByPrefix } from "../stores/cache"; +import { getClient, cachedRequest, updateGraphqlRateLimit } from "./github"; import { pushNotification } from "../lib/errors"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -62,7 +61,7 @@ export interface PullRequest { deletions: number; changedFiles: number; comments: number; - reviewComments: number; + reviewThreads: number; labels: { name: string; color: string }[]; reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; totalReviewCount: number; @@ -117,28 +116,6 @@ interface RawRepo { pushed_at: string | null; } -interface RawPullRequest { - id: number; - number: number; - title: string; - state: string; - draft: boolean; - html_url: string; - created_at: string; - updated_at: string; - user: { login: string; avatar_url: string } | null; - head: { sha: string; ref: string; repo: { full_name: string } | null }; - base: { ref: string }; - assignees: { login: string }[]; - requested_reviewers: { login: string }[]; - additions: number; - deletions: number; - changed_files: number; - comments: number; - review_comments: number; - labels: { name: string; color: string }[]; -} - interface RawWorkflowRun { id: number; name: string; @@ -159,49 +136,14 @@ interface RawWorkflowRun { actor: { login: string } | null; } -// ── Search API types ───────────────────────────────────────────────────────── - -interface RawSearchResponse { - total_count: number; - incomplete_results: boolean; - items: RawSearchItem[]; -} - -interface RawSearchItem { - id: number; - number: number; - title: string; - state: string; - html_url: string; - created_at: string; - updated_at: string; - user: { login: string; avatar_url: string } | null; - labels: { name: string; color: string }[]; - assignees: { login: string }[]; - // Search API returns repository_url (string), NOT repository (object). - // We parse full_name from the URL in getRepoFullName(). - repository_url?: string; - pull_request?: unknown; - comments: number; -} - -/** Extract "owner/repo" from "https://api.github.com/repos/owner/repo" */ -function getRepoFullName(item: RawSearchItem): string | null { - const url = item.repository_url; - if (!url) return null; - const match = url.match(/\/repos\/([^/]+\/[^/]+)$/); - return match ? match[1] : null; -} - // ── Constants ──────────────────────────────────────────────────────────────── // Batch repos into chunks for search queries (keeps URL length manageable) const SEARCH_REPO_BATCH_SIZE = 30; -// Max PRs per GraphQL batch. Each alias fetches statusCheckRollup + pullRequest -// (reviewDecision + latestReviews(first:15)). Cost: ~16 nodes/alias = ~800 pts/batch. -// At 6 polls/hr (10min interval): ~4800 pts/hr against 5000/hr GraphQL budget. -// Do not increase batch size or latestReviews.first without recalculating. +// Max fork PRs per GraphQL batch for the statusCheckRollup fallback query. +// Each alias looks up a single commit in the fork repo. Kept conservatively small +// to avoid hitting query complexity limits when many fork PRs need fallback. const GRAPHQL_CHECK_BATCH_SIZE = 50; // Repos confirmed to have zero workflow runs — skipped on subsequent polls. @@ -230,6 +172,27 @@ function extractRejectionError(reason: unknown): { statusCode: number | null; me return { statusCode, message }; } +/** + * Extracts partial data from a GraphqlResponseError for search queries. + * Only matches responses containing a `search` key (issues/PRs search shape). + */ +function extractSearchPartialData(err: unknown): T | null { + if ( + err && + typeof err === "object" && + "data" in err && + err.data && + typeof err.data === "object" && + "search" in err.data + ) { + return err.data as T; + } + return null; +} + +const VALID_REPO_NAME = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const VALID_LOGIN = /^[A-Za-z0-9\[\]-]+$/; + function chunkArray(arr: T[], size: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < arr.length; i += size) { @@ -238,108 +201,533 @@ function chunkArray(arr: T[], size: number): T[][] { return chunks; } +type GitHubOctokit = NonNullable>; + +// ── GraphQL search types ───────────────────────────────────────────────────── + +interface GraphQLIssueNode { + databaseId: number; + number: number; + title: string; + state: string; + url: string; + createdAt: string; + updatedAt: string; + author: { login: string; avatarUrl: string } | null; + labels: { nodes: { name: string; color: string }[] }; + assignees: { nodes: { login: string }[] }; + repository: { nameWithOwner: string } | null; + comments: { totalCount: number }; +} + +interface GraphQLIssueSearchResponse { + search: { + issueCount: number; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: (GraphQLIssueNode | null)[]; + }; + rateLimit?: { remaining: number; resetAt: string }; +} + +interface GraphQLPRNode { + databaseId: number; + number: number; + title: string; + state: string; + isDraft: boolean; + url: string; + createdAt: string; + updatedAt: string; + author: { login: string; avatarUrl: string } | null; + headRefOid: string; + headRefName: string; + baseRefName: string; + headRepository: { owner: { login: string }; nameWithOwner: string } | null; + repository: { nameWithOwner: string } | null; + assignees: { nodes: { login: string }[] }; + reviewRequests: { nodes: { requestedReviewer: { login: string } | null }[] }; + labels: { nodes: { name: string; color: string }[] }; + additions: number; + deletions: number; + changedFiles: number; + comments: { totalCount: number }; + reviewThreads: { totalCount: number }; + reviewDecision: string | null; + latestReviews: { + totalCount: number; + nodes: { author: { login: string } | null }[]; + }; + commits: { + nodes: { + commit: { + statusCheckRollup: { state: string } | null; + }; + }[]; + }; +} + +interface GraphQLPRSearchResponse { + search: { + issueCount: number; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: (GraphQLPRNode | null)[]; + }; + rateLimit?: { remaining: number; resetAt: string }; +} + +interface ForkCandidate { + databaseId: number; + headOwner: string; + headRepo: string; + sha: string; +} + +interface ForkRepoResult { + object: { statusCheckRollup: { state: string } | null } | null; +} + +interface ForkQueryResponse { + rateLimit?: { remaining: number; resetAt: string }; + [key: string]: ForkRepoResult | { remaining: number; resetAt: string } | undefined | null; +} + +// ── GraphQL search query constants ─────────────────────────────────────────── + +const ISSUES_SEARCH_QUERY = ` + query($q: String!, $cursor: String) { + search(query: $q, type: ISSUE, first: 100, after: $cursor) { + issueCount + pageInfo { hasNextPage endCursor } + nodes { + ... on Issue { + databaseId + number + title + state + url + createdAt + updatedAt + author { login avatarUrl } + labels(first: 20) { nodes { name color } } + assignees(first: 20) { nodes { login } } + repository { nameWithOwner } + comments { totalCount } + } + } + } + rateLimit { remaining resetAt } + } +`; + +const PR_SEARCH_QUERY = ` + query($q: String!, $cursor: String) { + # GitHub search API uses type: ISSUE for both issues and PRs + search(query: $q, type: ISSUE, first: 100, after: $cursor) { + issueCount + pageInfo { hasNextPage endCursor } + nodes { + ... on PullRequest { + databaseId + number + title + state + isDraft + url + createdAt + updatedAt + author { login avatarUrl } + headRefOid + headRefName + baseRefName + headRepository { owner { login } nameWithOwner } + repository { nameWithOwner } + assignees(first: 20) { nodes { login } } + reviewRequests(first: 20) { + # Team reviewers are excluded (only User fragment matched) + nodes { requestedReviewer { ... on User { login } } } + } + labels(first: 20) { nodes { name color } } + additions + deletions + changedFiles + comments { totalCount } + reviewThreads { totalCount } + reviewDecision + latestReviews(first: 15) { + totalCount + nodes { author { login } } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { state } + } + } + } + } + } + } + rateLimit { remaining resetAt } + } +`; + +// ── GraphQL search functions ────────────────────────────────────────────────── + +interface SearchPageResult { + issueCount: number; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: (T | null)[]; +} + /** - * Paginated search. Returns up to 1000 items per query. - * Search API has its own rate limit: 30 req/min (separate from core 5000/hr). - * Does NOT use IDB caching — search results are volatile and the poll interval - * already gates how often we call. + * Paginates a single GraphQL search query string, collecting results via a + * caller-provided `processNode` callback. Handles partial errors, cap enforcement, + * and rate limit tracking. Returns the count of items added by processNode. */ -async function searchAllPages( - octokit: NonNullable>, - query: string -): Promise { - const items: RawSearchItem[] = []; - let page = 1; - const perPage = 100; +async function paginateGraphQLSearch; rateLimit?: { remaining: number; resetAt: string } }, TNode>( + octokit: GitHubOctokit, + query: string, + queryString: string, + batchLabel: string, + errors: ApiError[], + processNode: (node: TNode) => boolean, // returns true if node was added (for cap counting) + currentCount: () => number, + cap: number, +): Promise<{ capReached: boolean }> { + let cursor: string | null = null; + let capReached = false; while (true) { - const response = await octokit.request("GET /search/issues", { - q: query, - per_page: perPage, - page, - sort: "updated", - order: "desc", - }); + try { + let response: TResponse; + let isPartial = false; + try { + response = await octokit.graphql(query, { q: queryString, cursor }); + } catch (err) { + const partial = extractSearchPartialData(err); + if (partial) { + response = partial; + isPartial = true; + const { message } = extractRejectionError(err); + errors.push({ repo: batchLabel, statusCode: null, message, retryable: true }); + } else { + const { statusCode, message } = extractRejectionError(err); + errors.push({ + repo: batchLabel, + statusCode, + message, + retryable: statusCode === null || statusCode >= 500, + }); + break; + } + } - updateRateLimitFromHeaders(response.headers as Record, "search"); - const data = response.data as unknown as RawSearchResponse; - items.push(...data.items); - - if ( - items.length >= data.total_count || - items.length >= 1000 || - data.items.length < perPage - ) { - if (data.incomplete_results) { - console.warn( - `[api] Search results incomplete for: ${query.slice(0, 80)}…` - ); - pushNotification("search", "Search results may be incomplete — GitHub returned partial data", "warning"); + if (response.rateLimit) updateGraphqlRateLimit(response.rateLimit); + + for (const node of response.search.nodes) { + if (currentCount() >= cap) { + capReached = true; + break; + } + if (!node) continue; + processNode(node); } - if (items.length >= 1000 && data.total_count > 1000) { - console.warn( - `[api] Search results capped at 1000 (${data.total_count} total) for: ${query.slice(0, 80)}…` - ); - pushNotification("search", `Search results capped at 1,000 of ${data.total_count.toLocaleString()} total — some items are hidden`, "warning"); + + if (capReached) { + return { capReached: true }; } + + if (isPartial) break; + + if (currentCount() >= cap) { + return { capReached: true }; + } + + if (!response.search.pageInfo.hasNextPage || !response.search.pageInfo.endCursor) break; + cursor = response.search.pageInfo.endCursor; + } catch (err) { + const { message } = extractRejectionError(err); + errors.push({ repo: batchLabel, statusCode: null, message, retryable: false }); break; } - page++; } - return items; + return { capReached }; } -/** - * Runs a search query across batched repo qualifiers, deduplicating results. - * Splits repos into chunks of SEARCH_REPO_BATCH_SIZE to keep query length safe. - */ -interface BatchSearchResult { - items: RawSearchItem[]; - errors: ApiError[]; +function buildRepoQualifiers(repos: RepoRef[]): string { + return repos + .filter((r) => VALID_REPO_NAME.test(r.fullName)) + .map((r) => `repo:${r.fullName}`) + .join(" "); } -async function batchedSearch( - octokit: NonNullable>, - baseQuery: string, - repos: RepoRef[] -): Promise { - if (repos.length === 0) return { items: [], errors: [] }; +/** + * Fetches open issues via GraphQL search, using cursor-based pagination. + * Batches repos into chunks of SEARCH_REPO_BATCH_SIZE and runs chunks in parallel. + */ +async function graphqlSearchIssues( + octokit: GitHubOctokit, + repos: RepoRef[], + userLogin: string +): Promise { + if (!VALID_LOGIN.test(userLogin)) return { issues: [], errors: [{ repo: "search", statusCode: null, message: "Invalid userLogin", retryable: false }] }; - // Run search batches sequentially to avoid exceeding the 30 req/min search rate limit. - // With multiple search types (issues, PR involves, PR review-requested) running concurrently, - // parallel batches can quickly exhaust the shared search budget. const chunks = chunkArray(repos, SEARCH_REPO_BATCH_SIZE); - const results: PromiseSettledResult[] = []; - for (const chunk of chunks) { - const repoQualifiers = chunk.map((r) => `repo:${r.fullName}`).join(" "); - const result = await Promise.allSettled([searchAllPages(octokit, `${baseQuery} ${repoQualifiers}`)]); - results.push(result[0]); - } const seen = new Set(); - const items: RawSearchItem[] = []; + const issues: Issue[] = []; const errors: ApiError[] = []; + const CAP = 1000; + + const chunkResults = await Promise.allSettled(chunks.map(async (chunk, chunkIdx) => { + const repoQualifiers = buildRepoQualifiers(chunk); + const queryString = `is:issue is:open involves:${userLogin} ${repoQualifiers}`; + + await paginateGraphQLSearch( + octokit, ISSUES_SEARCH_QUERY, queryString, + `search-batch-${chunkIdx + 1}/${chunks.length}`, + errors, + (node) => { + if (node.databaseId == null || !node.repository) return false; + if (seen.has(node.databaseId)) return false; + seen.add(node.databaseId); + issues.push({ + id: node.databaseId, + number: node.number, + title: node.title, + state: node.state, + htmlUrl: node.url, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + userLogin: node.author?.login ?? "", + userAvatarUrl: node.author?.avatarUrl ?? "", + labels: node.labels.nodes.map((l) => ({ name: l.name, color: l.color })), + assigneeLogins: node.assignees.nodes.map((a) => a.login), + repoFullName: node.repository.nameWithOwner, + comments: node.comments.totalCount, + }); + return true; + }, + () => issues.length, + CAP, + ); + })); - for (let i = 0; i < results.length; i++) { - const result = results[i]; - if (result.status !== "fulfilled") { + for (const result of chunkResults) { + if (result.status === "rejected") { const { statusCode, message } = extractRejectionError(result.reason); - errors.push({ - repo: `search-batch-${i + 1}/${chunks.length}`, - statusCode, - message, - retryable: statusCode === null || statusCode >= 500, - }); - continue; + errors.push({ repo: "search-batch", statusCode, message, retryable: statusCode === null || statusCode >= 500 }); } - for (const item of result.value) { - if (seen.has(item.id)) continue; - seen.add(item.id); - items.push(item); + } + + if (issues.length >= CAP) { + console.warn(`[api] Issue search results capped at ${CAP}`); + pushNotification("search/issues", `Issue search results capped at 1,000 — some items are hidden`, "warning"); + issues.splice(CAP); + } + + return { issues, errors }; +} + +/** + * Maps a GraphQL statusCheckRollup state string to the app's CheckStatus type. + */ +function mapCheckStatus(state: string | null | undefined): CheckStatus["status"] { + if (state === "FAILURE" || state === "ERROR" || state === "ACTION_REQUIRED") return "failure"; + if (state === "PENDING" || state === "EXPECTED" || state === "QUEUED") return "pending"; + if (state === "SUCCESS") return "success"; + return null; +} + +/** + * Maps a GraphQL reviewDecision string to the typed union or null. + */ +function mapReviewDecision( + raw: string | null | undefined +): "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null { + if ( + raw === "APPROVED" || + raw === "CHANGES_REQUESTED" || + raw === "REVIEW_REQUIRED" + ) { + return raw; + } + return null; +} + +/** + * Fetches open PRs via GraphQL search with two queries (involves + review-requested), + * deduplicates by databaseId, and handles fork PR statusCheckRollup fallback. + * Chunks run in parallel; fork fallback batches run in parallel. + */ +async function graphqlSearchPRs( + octokit: GitHubOctokit, + repos: RepoRef[], + userLogin: string +): Promise { + if (!VALID_LOGIN.test(userLogin)) return { pullRequests: [], errors: [{ repo: "pr-search", statusCode: null, message: "Invalid userLogin", retryable: false }] }; + + const chunks = chunkArray(repos, SEARCH_REPO_BATCH_SIZE); + const prMap = new Map(); + const forkInfoMap = new Map(); + const errors: ApiError[] = []; + const CAP = 1000; + + function processPRNode(node: GraphQLPRNode): boolean { + if (node.databaseId == null || !node.repository) return false; + if (prMap.has(node.databaseId)) return false; + + const pendingLogins = node.reviewRequests.nodes + .map((n) => n.requestedReviewer?.login) + .filter((l): l is string => l != null); + const actualLogins = node.latestReviews.nodes + .map((n) => n.author?.login) + .filter((l): l is string => l != null); + const reviewerLogins = [...new Set([...pendingLogins, ...actualLogins].map(l => l.toLowerCase()))]; + + const rawState = node.commits.nodes[0]?.commit?.statusCheckRollup?.state ?? null; + + // Store fork info for fallback detection + if (node.headRepository) { + const parts = node.headRepository.nameWithOwner.split("/"); + if (parts.length === 2) { + forkInfoMap.set(node.databaseId, { owner: node.headRepository.owner.login, repoName: parts[1] }); + } + } + + prMap.set(node.databaseId, { + id: node.databaseId, + number: node.number, + title: node.title, + state: node.state, + draft: node.isDraft, + htmlUrl: node.url, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + userLogin: node.author?.login ?? "", + userAvatarUrl: node.author?.avatarUrl ?? "", + headSha: node.headRefOid, + headRef: node.headRefName, + baseRef: node.baseRefName, + assigneeLogins: node.assignees.nodes.map((a) => a.login), + reviewerLogins, + repoFullName: node.repository.nameWithOwner, + checkStatus: mapCheckStatus(rawState), + additions: node.additions, + deletions: node.deletions, + changedFiles: node.changedFiles, + comments: node.comments.totalCount, + reviewThreads: node.reviewThreads.totalCount, + labels: node.labels.nodes.map((l) => ({ name: l.name, color: l.color })), + reviewDecision: mapReviewDecision(node.reviewDecision), + totalReviewCount: node.latestReviews.totalCount, + }); + return true; + } + + // Run involves and review-requested searches across all repo chunks in parallel + const queryTypes = [ + `is:pr is:open involves:${userLogin}`, + `is:pr is:open review-requested:${userLogin}`, + ]; + + const allTasks = queryTypes.flatMap((queryType) => + chunks.map(async (chunk, chunkIdx) => { + const repoQualifiers = buildRepoQualifiers(chunk); + const queryString = `${queryType} ${repoQualifiers}`; + await paginateGraphQLSearch( + octokit, PR_SEARCH_QUERY, queryString, + `pr-search-batch-${chunkIdx + 1}/${chunks.length}`, + errors, processPRNode, () => prMap.size, CAP, + ); + }) + ); + + const taskResults = await Promise.allSettled(allTasks); + for (const result of taskResults) { + if (result.status === "rejected") { + const { statusCode, message } = extractRejectionError(result.reason); + errors.push({ repo: "pr-search-batch", statusCode, message, retryable: statusCode === null || statusCode >= 500 }); } } - return { items, errors }; + if (prMap.size >= CAP) { + console.warn(`[api] PR search results capped at ${CAP}`); + pushNotification("search/prs", `PR search results capped at 1,000 — some items are hidden`, "warning"); + } + + // Fork PR fallback: for PRs with null checkStatus where head repo owner differs from base + const forkCandidates: ForkCandidate[] = []; + for (const [databaseId, pr] of prMap) { + if (pr.checkStatus !== null) continue; + const headInfo = forkInfoMap.get(databaseId); + if (!headInfo) continue; + const baseOwner = pr.repoFullName.split("/")[0].toLowerCase(); + if (headInfo.owner.toLowerCase() === baseOwner) continue; + forkCandidates.push({ databaseId, headOwner: headInfo.owner, headRepo: headInfo.repoName, sha: pr.headSha }); + } + + if (forkCandidates.length > 0) { + const forkChunks = chunkArray(forkCandidates, GRAPHQL_CHECK_BATCH_SIZE); + // Run fork fallback batches in parallel + await Promise.allSettled(forkChunks.map(async (forkChunk) => { + const varDefs: string[] = []; + const variables: Record = {}; + const fragments: string[] = []; + + for (let i = 0; i < forkChunk.length; i++) { + varDefs.push(`$owner${i}: String!`, `$repo${i}: String!`, `$sha${i}: String!`); + variables[`owner${i}`] = forkChunk[i].headOwner; + variables[`repo${i}`] = forkChunk[i].headRepo; + variables[`sha${i}`] = forkChunk[i].sha; + fragments.push( + `fork${i}: repository(owner: $owner${i}, name: $repo${i}) { + object(expression: $sha${i}) { + ... on Commit { + statusCheckRollup { state } + } + } + }` + ); + } + + const forkQuery = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { remaining resetAt }\n}`; + + try { + const forkResponse = await octokit.graphql(forkQuery, variables); + if (forkResponse.rateLimit) updateGraphqlRateLimit(forkResponse.rateLimit as { remaining: number; resetAt: string }); + + for (let i = 0; i < forkChunk.length; i++) { + const data = forkResponse[`fork${i}`] as ForkRepoResult | null | undefined; + const state = data?.object?.statusCheckRollup?.state ?? null; + const pr = prMap.get(forkChunk[i].databaseId); + if (pr) pr.checkStatus = mapCheckStatus(state); + } + } catch (err) { + // Extract partial data from GraphqlResponseError — some fork aliases may have resolved + const partialData = (err && typeof err === "object" && "data" in err && err.data && typeof err.data === "object") + ? err.data as Record + : null; + + if (partialData) { + for (let i = 0; i < forkChunk.length; i++) { + const data = partialData[`fork${i}`]; + if (!data) continue; + const state = data.object?.statusCheckRollup?.state ?? null; + const pr = prMap.get(forkChunk[i].databaseId); + if (pr) pr.checkStatus = mapCheckStatus(state); + } + } + + console.warn("[api] Fork PR statusCheckRollup fallback partially failed:", err); + pushNotification("graphql", "Fork PR check status unavailable — CI status may be missing for some PRs", "warning"); + } + })); + } + + const pullRequests = [...prMap.values()]; + if (pullRequests.length >= CAP) pullRequests.splice(CAP); + return { pullRequests, errors }; } // ── Step 1: fetchOrgs ──────────────────────────────────────────────────────── @@ -390,17 +778,6 @@ export async function fetchRepos( const repos: RepoEntry[] = []; - function collectRepos(page: RawRepo[], into: RepoEntry[]): void { - for (const repo of page) { - into.push({ - owner: repo.owner.login, - name: repo.name, - fullName: repo.full_name, - pushedAt: repo.pushed_at ?? null, - }); - } - } - if (type === "org") { for await (const response of octokit.paginate.iterator(`GET /orgs/{org}/repos`, { org: orgOrUser, @@ -408,7 +785,9 @@ export async function fetchRepos( sort: "pushed" as const, direction: "desc" as const, })) { - collectRepos(response.data as RawRepo[], repos); + for (const repo of response.data as RawRepo[]) { + repos.push({ owner: repo.owner.login, name: repo.name, fullName: repo.full_name, pushedAt: repo.pushed_at ?? null }); + } } } else { for await (const response of octokit.paginate.iterator(`GET /user/repos`, { @@ -417,21 +796,20 @@ export async function fetchRepos( sort: "pushed" as const, direction: "desc" as const, })) { - collectRepos(response.data as RawRepo[], repos); + for (const repo of response.data as RawRepo[]) { + repos.push({ owner: repo.owner.login, name: repo.name, fullName: repo.full_name, pushedAt: repo.pushed_at ?? null }); + } } } return repos; } -// ── Step 3: fetchIssues (Search API) ───────────────────────────────────────── +// ── Step 3: fetchIssues (GraphQL Search) ───────────────────────────────────── /** * Fetches open issues across repos where the user is involved (author, assignee, - * mentioned, or commenter) using the GitHub Search API. - * - * Before: 3 API calls per repo (creator/assignee/mentioned) = 225 calls for 75 repos. - * After: ~3 search calls total (batched in chunks of 30 repos). + * mentioned, or commenter) using GraphQL search queries, batched in chunks of 30 repos. */ export interface FetchIssuesResult { issues: Issue[]; @@ -445,329 +823,11 @@ export async function fetchIssues( ): Promise { if (!octokit) throw new Error("No GitHub client available"); if (repos.length === 0 || !userLogin) return { issues: [], errors: [] }; - - const { items, errors } = await batchedSearch( - octokit, - `is:issue is:open involves:${userLogin}`, - repos - ); - - const issues = items - .filter((item) => item.pull_request === undefined && getRepoFullName(item) != null) - .map((item) => ({ - id: item.id, - number: item.number, - title: item.title, - state: item.state, - htmlUrl: item.html_url, - createdAt: item.created_at, - updatedAt: item.updated_at, - userLogin: item.user?.login ?? "", - userAvatarUrl: item.user?.avatar_url ?? "", - labels: item.labels.map((l) => ({ name: l.name, color: l.color })), - assigneeLogins: item.assignees.map((a) => a.login), - repoFullName: getRepoFullName(item)!, - comments: item.comments, - })); - - return { issues, errors }; -} - -// ── Step 4: fetchPullRequests (Search API + GraphQL check status) ───────────── - -interface CheckStatusResult { - checkStatus: CheckStatus["status"]; - reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null; - actualReviewerLogins: string[]; - totalReviewCount: number; -} - -type GitHubOctokit = NonNullable>; - -/** - * REST fallback for check status + reviews when GraphQL is unavailable. - * Uses the core REST rate limit (5000/hr, separate from GraphQL 5000 pts/hr). - * All requests go through cachedRequest for ETag-based caching. - * - * Fetches both the legacy Status API and the Check Runs API in parallel, then - * combines their results so GitHub Actions workflows (which use Check Runs) are - * correctly reflected. This makes REST a full-fidelity fallback for GraphQL. - */ -async function restFallbackCheckStatuses( - octokit: GitHubOctokit, - prs: { owner: string; repo: string; sha: string; prNumber: number }[], - results: Map -): Promise { - // Process in chunks of 10 to avoid overwhelming the browser's 6-connection limit - const REST_CONCURRENCY = 10; - const chunks = chunkArray(prs, REST_CONCURRENCY); - for (const chunk of chunks) { - const tasks = chunk.map(async (pr) => { - const key = `${pr.owner}/${pr.repo}:${pr.sha}`; - try { - // Fetch legacy Status API, Check Runs API, and PR reviews in parallel - const [statusResult, checkRunsResult, reviewsResult] = await Promise.all([ - cachedRequest( - octokit, - `rest-status:${key}`, - "GET /repos/{owner}/{repo}/commits/{ref}/status", - { owner: pr.owner, repo: pr.repo, ref: pr.sha } - ), - cachedRequest( - octokit, - `rest-check-runs:${key}`, - "GET /repos/{owner}/{repo}/commits/{ref}/check-runs", - { owner: pr.owner, repo: pr.repo, ref: pr.sha } - ), - cachedRequest( - octokit, - `rest-reviews:${pr.owner}/${pr.repo}:${pr.prNumber}`, - "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews", - { owner: pr.owner, repo: pr.repo, pull_number: pr.prNumber } - ), - ]); - - const statusData = statusResult.data as { state: string; total_count: number }; - const checkRunsData = checkRunsResult.data as { - check_runs: { status: string; conclusion: string | null }[]; - }; - const reviews = reviewsResult.data as { user: { login: string } | null; state: string }[]; - - // Derive combined check status from both endpoints. - // Status API returns state:"pending" with total_count:0 when no statuses exist. - // Check Runs API returns an empty array when no check runs exist. - // If BOTH are empty → no CI configured → null. - const noLegacyStatuses = statusData.total_count === 0; - const noCheckRuns = checkRunsData.check_runs.length === 0; - - let checkStatus: CheckStatus["status"]; - if (noLegacyStatuses && noCheckRuns) { - checkStatus = null; - } else { - const legacyFailed = - statusData.state === "failure" || statusData.state === "error"; - const checkRunFailed = checkRunsData.check_runs.some( - (cr) => cr.conclusion === "failure" || cr.conclusion === "timed_out" || cr.conclusion === "cancelled" - ); - - if (legacyFailed || checkRunFailed) { - checkStatus = "failure"; - } else { - const legacySuccess = statusData.state === "success" || noLegacyStatuses; - const allCheckRunsComplete = noCheckRuns || - checkRunsData.check_runs.every((cr) => cr.status === "completed"); - const allCheckRunsSuccess = checkRunsData.check_runs.every( - (cr) => cr.conclusion === "success" || cr.conclusion === "skipped" || cr.conclusion === "neutral" - ); - - if (legacySuccess && allCheckRunsComplete && allCheckRunsSuccess) { - checkStatus = "success"; - } else { - checkStatus = "pending"; - } - } - } - - // Derive review decision from latest review per author. - // Include COMMENTED to make REVIEW_REQUIRED reachable (comments without approval). - const latestByAuthor = new Map(); - for (const review of reviews) { - if (review.user?.login && (review.state === "APPROVED" || review.state === "CHANGES_REQUESTED" || review.state === "COMMENTED")) { - latestByAuthor.set(review.user.login.toLowerCase(), review.state); - } - } - let reviewDecision: CheckStatusResult["reviewDecision"] = null; - if (latestByAuthor.size > 0) { - const states = [...latestByAuthor.values()]; - if (states.some((s) => s === "CHANGES_REQUESTED")) reviewDecision = "CHANGES_REQUESTED"; - else if (states.every((s) => s === "APPROVED")) reviewDecision = "APPROVED"; - else reviewDecision = "REVIEW_REQUIRED"; - } - - const actualReviewerLogins = reviews - .filter((r) => r.user?.login) - .map((r) => r.user!.login); - // Deduplicate reviewer logins - const uniqueReviewers = [...new Set(actualReviewerLogins)]; - - results.set(key, { checkStatus, reviewDecision, actualReviewerLogins: uniqueReviewers, totalReviewCount: reviews.length }); - } catch (err) { - console.warn(`[api] REST fallback failed for ${key}:`, err); - results.set(key, { checkStatus: null, reviewDecision: null, actualReviewerLogins: [], totalReviewCount: 0 }); - } - }); - - await Promise.allSettled(tasks); - } + return graphqlSearchIssues(octokit, repos, userLogin); } -/** - * Batches check status lookups into a single GraphQL call using - * `statusCheckRollup.state`, which combines both legacy commit status API - * and modern check runs into one field. - * - * Replaces 2N REST calls (commit status + check runs) with 1 GraphQL call. - * Uses parameterized variables to prevent injection. - * - * For fork PRs, `pr.head.sha` exists only in the fork repo, not the base repo. - * The `object(expression:)` lookup must use the head repo (fork), while - * `pullRequest(number:)` must use the base repo. We handle this by emitting a - * separate `objRepo${i}` alias pointing at the head repo when it differs from - * the base repo, and reusing the base repo alias otherwise. - */ -async function batchFetchCheckStatuses( - octokit: NonNullable>, - prs: { owner: string; repo: string; sha: string; prNumber: number }[] -): Promise> { - if (prs.length === 0) return new Map(); - - const results = new Map(); - const failedKeys = new Set(); - const failedPrs: typeof prs = []; - - // Batch into chunks and run in parallel - const chunks = chunkArray(prs, GRAPHQL_CHECK_BATCH_SIZE); - - const chunkTasks = chunks.map(async (chunk) => { - const varDefs: string[] = []; - const variables: Record = {}; - const fragments: string[] = []; - - for (let i = 0; i < chunk.length; i++) { - varDefs.push( - `$owner${i}: String!`, - `$repo${i}: String!`, - `$sha${i}: String!`, - `$prNum${i}: Int!` - ); - variables[`owner${i}`] = chunk[i].owner; - variables[`repo${i}`] = chunk[i].repo; - variables[`sha${i}`] = chunk[i].sha; - variables[`prNum${i}`] = chunk[i].prNumber; - - // GitHub copies fork PR head commits into the base repo (refs/pull/N/head), - // and CI check suites are associated with the base repo — so always query - // statusCheckRollup from the base repo, even for fork PRs. - fragments.push( - `pr${i}: repository(owner: $owner${i}, name: $repo${i}) { - object(expression: $sha${i}) { - ... on Commit { - statusCheckRollup { - state - } - } - } - pullRequest(number: $prNum${i}) { - reviewDecision - latestReviews(first: 15) { - totalCount - nodes { - author { - login - } - } - } - } - }` - ); - } - - const query = `query(${varDefs.join(", ")}) {\n${fragments.join("\n")}\nrateLimit { remaining resetAt }\n}`; - - try { - interface GraphQLRepoResult { - object: { - statusCheckRollup: { state: string } | null; - } | null; - pullRequest: { - reviewDecision: string | null; - latestReviews: { - totalCount: number; - nodes: { author: { login: string } | null }[]; - }; - } | null; - } - interface GraphQLRateLimit { - remaining: number; - resetAt: string; - } - - const response = (await octokit.graphql(query, variables)) as - Record & { rateLimit?: GraphQLRateLimit }; - - // Log GraphQL rate limit for debugging but don't overwrite the REST - // rate limit signal — they're separate pools and REST is the bottleneck - if (response.rateLimit) { - console.debug("[api] GraphQL rate limit remaining:", response.rateLimit.remaining); - } - - for (let i = 0; i < chunk.length; i++) { - const data = response[`pr${i}`] as GraphQLRepoResult | null; - const state = data?.object?.statusCheckRollup?.state ?? null; - const key = `${chunk[i].owner}/${chunk[i].repo}:${chunk[i].sha}`; - - let checkStatus: CheckStatus["status"]; - if (state === "FAILURE" || state === "ERROR") { - checkStatus = "failure"; - } else if (state === "PENDING" || state === "EXPECTED") { - checkStatus = "pending"; - } else if (state === "SUCCESS") { - checkStatus = "success"; - } else { - checkStatus = null; - } - - const rawReviewDecision = data?.pullRequest?.reviewDecision ?? null; - const reviewDecision = - rawReviewDecision === "APPROVED" || - rawReviewDecision === "CHANGES_REQUESTED" || - rawReviewDecision === "REVIEW_REQUIRED" - ? rawReviewDecision - : null; - - const actualReviewerLogins = (data?.pullRequest?.latestReviews?.nodes ?? []) - .filter((n) => n.author?.login) - .map((n) => n.author!.login); - const totalReviewCount = data?.pullRequest?.latestReviews?.totalCount ?? 0; - - results.set(key, { checkStatus, reviewDecision, actualReviewerLogins, totalReviewCount }); - } - } catch (err) { - console.warn("[api] GraphQL check status batch failed:", err); - // Track failed PRs for cache lookup / REST fallback - for (const pr of chunk) { - const key = `${pr.owner}/${pr.repo}:${pr.sha}`; - failedKeys.add(key); - failedPrs.push(pr); - } - } - }); - - await Promise.allSettled(chunkTasks); - - // Tier 2: REST fallback for ALL failed PRs (not just cache misses). - // REST uses the core rate limit (5000/hr, separate from GraphQL 5000 pts/hr). - // ETag caching via cachedRequest means unchanged PRs return 304 (free). - if (failedPrs.length > 0) { - pushNotification("graphql", `Fetching check/review data via REST for ${failedPrs.length} PR(s) — GraphQL rate limited`, "info", true); - await restFallbackCheckStatuses(octokit, failedPrs, results); - } - - return results; -} +// ── Step 4: fetchPullRequests (GraphQL search) ─────────────────────────────── -/** - * Fetches open PRs involving the user using the GitHub Search API. - * Two search queries cover all involvement types: - * - `involves:user` → author, assignee, mentioned, commenter - * - `review-requested:user` → requested reviewer (not covered by `involves`) - * - * For each found PR, fetches full PR details (head SHA, reviewers) via REST, - * then batches ALL check statuses into a single GraphQL call. - * - * Before: 1 API call per repo (list all PRs) + 2 per involved PR = 75+2N for 75 repos. - * After: ~6 search + N PR detail + 1 GraphQL = 7+N. - */ export interface FetchPullRequestsResult { pullRequests: PullRequest[]; errors: ApiError[]; @@ -780,136 +840,7 @@ export async function fetchPullRequests( ): Promise { if (!octokit) throw new Error("No GitHub client available"); if (repos.length === 0 || !userLogin) return { pullRequests: [], errors: [] }; - - const allErrors: ApiError[] = []; - - // Two searches: involves (author/assignee/mentioned/commenter) + review-requested. - // Run sequentially to share the 30 req/min search rate limit with issue searches. - const [involvedResult] = await Promise.allSettled([ - batchedSearch(octokit, `is:pr is:open involves:${userLogin}`, repos), - ]); - const [reviewResult] = await Promise.allSettled([ - batchedSearch( - octokit, - `is:pr is:open review-requested:${userLogin}`, - repos - ), - ]); - - // Merge and deduplicate by ID, collect search errors - const seen = new Set(); - const uniqueItems: RawSearchItem[] = []; - - for (const result of [involvedResult, reviewResult]) { - if (result.status !== "fulfilled") continue; - allErrors.push(...result.value.errors); - for (const item of result.value.items) { - if (seen.has(item.id)) continue; - seen.add(item.id); - uniqueItems.push(item); - } - } - - // Fetch full PR details for each (head SHA, branch info, reviewers) - // Process in chunks of 10 to avoid unbounded concurrency - const PR_DETAIL_CONCURRENCY = 10; - const validItems = uniqueItems.filter((item) => { - const fullName = getRepoFullName(item); - return fullName != null && fullName.includes("/"); - }); - const prDetailChunks = chunkArray(validItems, PR_DETAIL_CONCURRENCY); - const prDetails: PromiseSettledResult<{ pr: RawPullRequest; repoFullName: string }>[] = []; - - for (const chunk of prDetailChunks) { - const chunkTasks = chunk.map(async (item) => { - const repoFullName = getRepoFullName(item)!; - const [owner, name] = repoFullName.split("/"); - - const result = await cachedRequest( - octokit, - `pr-detail:${repoFullName}:${item.number}`, - "GET /repos/{owner}/{repo}/pulls/{pull_number}", - { owner, repo: name, pull_number: item.number } - ); - - return { pr: result.data as RawPullRequest, repoFullName }; - }); - - const chunkResults = await Promise.allSettled(chunkTasks); - prDetails.push(...chunkResults); - } - - for (const result of prDetails) { - if (result.status === "rejected") { - const { statusCode, message } = extractRejectionError(result.reason); - allErrors.push({ repo: "pr-detail", statusCode, message, retryable: true }); - } - } - - const successfulPRs = prDetails - .filter( - (r): r is PromiseFulfilledResult<{ - pr: RawPullRequest; - repoFullName: string; - }> => r.status === "fulfilled" - ) - .map((r) => r.value); - - // Batch ALL check statuses into a single GraphQL call. - // statusCheckRollup is always queried from the base repo — GitHub copies fork PR - // head commits into the base repo (refs/pull/N/head) and CI check suites are - // associated with the base repo. - const checkInputs = successfulPRs.map(({ pr, repoFullName }) => { - const [owner, repo] = repoFullName.split("/"); - return { owner, repo, sha: pr.head.sha, prNumber: pr.number }; - }); - - const checkStatuses = await batchFetchCheckStatuses(octokit, checkInputs); - - // Build final PR objects - const pullRequests = successfulPRs.map(({ pr, repoFullName }) => { - const result = checkStatuses.get(`${repoFullName}:${pr.head.sha}`); - const requestedReviewerLogins = pr.requested_reviewers.map((r) => r.login); - const actualReviewerLogins = result?.actualReviewerLogins ?? []; - const reviewerLogins = [...new Set([...requestedReviewerLogins, ...actualReviewerLogins])]; - return { - id: pr.id, - number: pr.number, - title: pr.title, - state: pr.state, - draft: pr.draft, - htmlUrl: pr.html_url, - createdAt: pr.created_at, - updatedAt: pr.updated_at, - userLogin: pr.user?.login ?? "", - userAvatarUrl: pr.user?.avatar_url ?? "", - headSha: pr.head.sha, - headRef: pr.head.ref, - baseRef: pr.base.ref, - assigneeLogins: pr.assignees.map((a) => a.login), - reviewerLogins, - repoFullName, - checkStatus: result?.checkStatus ?? null, - additions: pr.additions, - deletions: pr.deletions, - changedFiles: pr.changed_files, - comments: pr.comments, - reviewComments: pr.review_comments, - labels: pr.labels.map((l) => ({ name: l.name, color: l.color })), - reviewDecision: result?.reviewDecision ?? null, - totalReviewCount: result?.totalReviewCount ?? 0, - }; - }); - - // Evict stale PR detail cache entries for PRs no longer in the active set - const activeKeys = new Set( - uniqueItems.filter((item) => getRepoFullName(item) != null).map((item) => `pr-detail:${getRepoFullName(item)!}:${item.number}`) - ); - evictByPrefix("pr-detail:", activeKeys).catch(() => { - // Non-fatal — eviction failure shouldn't block the result - }); - - return { pullRequests, errors: allErrors }; + return graphqlSearchPRs(octokit, repos, userLogin); } // ── Step 5: fetchWorkflowRuns (single endpoint per repo) ───────────────────── @@ -999,11 +930,8 @@ export async function fetchWorkflowRuns( latestAt: runs.reduce((max, r) => r.updated_at > max ? r.updated_at : max, ""), })); workflowEntries.sort((a, b) => a.latestAt > b.latestAt ? -1 : a.latestAt < b.latestAt ? 1 : 0); - const topWorkflows = workflowEntries - .slice(0, maxWorkflows); - // Take most recent M runs per workflow - for (const { runs: workflowRuns } of topWorkflows) { + for (const { runs: workflowRuns } of workflowEntries.slice(0, maxWorkflows)) { const sorted = workflowRuns.sort( (a, b) => a.created_at > b.created_at ? -1 : a.created_at < b.created_at ? 1 : 0 ); diff --git a/src/app/services/github.ts b/src/app/services/github.ts index 4dbe401..45c7f62 100644 --- a/src/app/services/github.ts +++ b/src/app/services/github.ts @@ -23,37 +23,31 @@ interface RateLimitInfo { // ── Rate limit signals ─────────────────────────────────────────────────────── const [_coreRateLimit, _setCoreRateLimit] = createSignal(null); -const [_searchRateLimit, _setSearchRateLimit] = createSignal(null); +const [_graphqlRateLimit, _setGraphqlRateLimit] = createSignal(null); export function getCoreRateLimit(): RateLimitInfo | null { return _coreRateLimit(); } -export function getSearchRateLimit(): RateLimitInfo | null { - return _searchRateLimit(); +export function getGraphqlRateLimit(): RateLimitInfo | null { + return _graphqlRateLimit(); } -/** @deprecated Use getCoreRateLimit() instead */ -export function getRateLimit(): RateLimitInfo | null { - return _coreRateLimit(); +export function updateGraphqlRateLimit(rateLimit: { remaining: number; resetAt: string }): void { + _setGraphqlRateLimit({ + remaining: rateLimit.remaining, + resetAt: new Date(rateLimit.resetAt), // ISO 8601 string → Date + }); } -export function updateRateLimitFromHeaders( - headers: Record, - resource: "core" | "search" = "core" -): void { +export function updateRateLimitFromHeaders(headers: Record): void { const remaining = headers["x-ratelimit-remaining"]; const reset = headers["x-ratelimit-reset"]; if (remaining !== undefined && reset !== undefined) { - const info = { + _setCoreRateLimit({ remaining: parseInt(remaining, 10), resetAt: new Date(parseInt(reset, 10) * 1000), - }; - if (resource === "search") { - _setSearchRateLimit(info); - } else { - _setCoreRateLimit(info); - } + }); } } @@ -117,8 +111,7 @@ export async function cachedRequest( cacheKey: string, route: string, params?: Record, - maxAge?: number, - resource: "core" | "search" = "core" + maxAge?: number ): Promise<{ data: unknown; fromCache: boolean }> { return cachedFetch(cacheKey, async (cached: ConditionalHeaders) => { const conditionalHeaders: Record = {}; @@ -138,7 +131,7 @@ export async function cachedRequest( const response = await octokit.request(route, requestParams); const headers = response.headers as Record; - updateRateLimitFromHeaders(headers, resource); + updateRateLimitFromHeaders(headers); return { data: response.data as unknown, diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 3990de8..c61332e 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -143,27 +143,19 @@ export async function fetchAllData(): Promise { // would cause unchanged items to vanish from the display. ETag caching already // handles the "nothing changed" case for workflow runs (304 = free). - // Search-based fetches (issues, PRs) run sequentially to stay within the - // 30 req/min search rate limit. Workflow runs use the core API (5000/hr) - // so they run in parallel with the search calls. - const runsPromise = fetchWorkflowRuns( - octokit, - repos, - config.maxWorkflowsPerRepo, - config.maxRunsPerWorkflow - ); - - // Issues first, then PRs — both use the search API's shared 30/min budget - const issueResult = await Promise.allSettled([fetchIssues(octokit, repos, userLogin)]); - const prResult = await Promise.allSettled([fetchPullRequests(octokit, repos, userLogin)]); - const runResult = await Promise.allSettled([runsPromise]); + // Issues + PRs use GraphQL (5000 pts/hr), workflow runs use REST core (5000/hr) — all parallel + const [issueResult, prResult, runResult] = await Promise.allSettled([ + fetchIssues(octokit, repos, userLogin), + fetchPullRequests(octokit, repos, userLogin), + fetchWorkflowRuns(octokit, repos, config.maxWorkflowsPerRepo, config.maxRunsPerWorkflow), + ]); // Collect top-level errors (total function failures) const topLevelErrors: ApiError[] = []; const settled: [PromiseSettledResult, string][] = [ - [issueResult[0], "issues"], - [prResult[0], "pull-requests"], - [runResult[0], "workflow-runs"], + [issueResult, "issues"], + [prResult, "pull-requests"], + [runResult, "workflow-runs"], ]; for (const [result, label] of settled) { if (result.status === "rejected") { @@ -177,9 +169,9 @@ export async function fetchAllData(): Promise { } // Extract data and per-batch errors from successful results - const issueData = issueResult[0].status === "fulfilled" ? issueResult[0].value : null; - const prData = prResult[0].status === "fulfilled" ? prResult[0].value : null; - const runData = runResult[0].status === "fulfilled" ? runResult[0].value : null; + const issueData = issueResult.status === "fulfilled" ? issueResult.value : null; + const prData = prResult.status === "fulfilled" ? prResult.value : null; + const runData = runResult.status === "fulfilled" ? runResult.value : null; // Merge all error sources: top-level failures + per-batch partial failures const errors = [ @@ -211,7 +203,7 @@ const REJITTER_WINDOW_MS = 30_000; // ±30 seconds jitter const REVISIT_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes // Sources managed by the poll coordinator — used for reconciliation -const POLL_MANAGED_SOURCES = new Set(["poll", "search", "graphql", "rate-limit", "notifications"]); +const POLL_MANAGED_SOURCES = new Set(["poll", "graphql", "rate-limit", "notifications", "search/issues", "search/prs"]); function withJitter(intervalMs: number): number { const jitter = (Math.random() * 2 - 1) * REJITTER_WINDOW_MS; diff --git a/src/app/stores/cache.ts b/src/app/stores/cache.ts index 5b1a4ff..d1b4649 100644 --- a/src/app/stores/cache.ts +++ b/src/app/stores/cache.ts @@ -100,30 +100,6 @@ export async function clearCache(): Promise { await db.clear("cache"); } -/** - * Evicts cache entries whose key starts with `prefix` but is NOT in `keepKeys`. - * Used to clean up per-PR cache entries for PRs no longer in the active set. - */ -export async function evictByPrefix( - prefix: string, - keepKeys: Set -): Promise { - const db = await getDb(); - const tx = db.transaction("cache", "readwrite"); - const range = IDBKeyRange.bound(prefix, prefix + "\uffff"); - let cursor = await tx.store.openCursor(range); - let count = 0; - while (cursor) { - if (!keepKeys.has(cursor.key as string)) { - await cursor.delete(); - count++; - } - cursor = await cursor.continue(); - } - await tx.done; - return count; -} - export async function evictStaleEntries(maxAgeMs: number): Promise { const db = await getDb(); const tx = db.transaction("cache", "readwrite"); diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 227e91b..902398c 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -35,8 +35,7 @@ vi.mock("../../src/app/stores/auth", () => ({ // Mock github service (used by Header) vi.mock("../../src/app/services/github", () => ({ getCoreRateLimit: () => null, - getSearchRateLimit: () => null, - getRateLimit: () => null, + getGraphqlRateLimit: () => null, })); // Mock errors lib — return empty by default diff --git a/tests/components/layout/Header.test.tsx b/tests/components/layout/Header.test.tsx index 3c81955..b31e1db 100644 --- a/tests/components/layout/Header.test.tsx +++ b/tests/components/layout/Header.test.tsx @@ -29,8 +29,7 @@ vi.mock("../../../src/app/stores/auth", () => ({ // Mock github service vi.mock("../../../src/app/services/github", () => ({ getCoreRateLimit: () => null, - getSearchRateLimit: () => null, - getRateLimit: () => null, + getGraphqlRateLimit: () => null, })); // Mock errors module so Header's notification imports work @@ -104,6 +103,39 @@ describe("Header", () => { vi.mocked(githubService.getCoreRateLimit).mockRestore(); }); + it("shows GraphQL rate limit with label when available", () => { + vi.spyOn(githubService, "getGraphqlRateLimit").mockReturnValue({ + remaining: 4800, + resetAt: new Date("2024-01-10T09:00:00Z"), + }); + render(() =>
); + screen.getByText(/GraphQL/); + screen.getByText(/4800\/5k\/hr/); + vi.mocked(githubService.getGraphqlRateLimit).mockRestore(); + }); + + it("shows amber warning when GraphQL rate limit is below 500", () => { + vi.spyOn(githubService, "getGraphqlRateLimit").mockReturnValue({ + remaining: 499, + resetAt: new Date("2024-01-10T09:00:00Z"), + }); + render(() =>
); + const el = screen.getByText(/499\/5k\/hr/); + expect(el.className).toContain("text-amber-600"); + vi.mocked(githubService.getGraphqlRateLimit).mockRestore(); + }); + + it("shows normal color when GraphQL rate limit is at 500", () => { + vi.spyOn(githubService, "getGraphqlRateLimit").mockReturnValue({ + remaining: 500, + resetAt: new Date("2024-01-10T09:00:00Z"), + }); + render(() =>
); + const el = screen.getByText(/500\/5k\/hr/); + expect(el.className).toContain("text-gray-500"); + vi.mocked(githubService.getGraphqlRateLimit).mockRestore(); + }); + it("does not show rate limit when not available", () => { render(() =>
); expect(screen.queryByText(/\/5k\/hr/)).toBeNull(); diff --git a/tests/fixtures/github-prs.json b/tests/fixtures/github-prs.json deleted file mode 100644 index a768669..0000000 --- a/tests/fixtures/github-prs.json +++ /dev/null @@ -1,80 +0,0 @@ -[ - { - "id": 1001, - "number": 10, - "title": "Fix: resolve null pointer on startup", - "body": "Fixes #5 by adding a null check.", - "state": "open", - "html_url": "https://github.com/octocat/Hello-World/pull/10", - "created_at": "2024-01-08T10:00:00Z", - "updated_at": "2024-01-09T12:00:00Z", - "user": { - "login": "octocat", - "id": 1, - "avatar_url": "https://github.com/images/error/octocat_happy.gif" - }, - "draft": false, - "merged_at": null, - "head": { - "sha": "abc1234567890abc1234567890abc1234567890ab", - "ref": "fix/null-pointer", - "repo": { - "full_name": "octocat/Hello-World" - } - }, - "base": { - "ref": "main" - }, - "requested_reviewers": [ - { "login": "reviewer1", "id": 7777 } - ], - "assignees": [ - { "login": "octocat", "id": 1 } - ], - "additions": 42, - "deletions": 10, - "changed_files": 5, - "comments": 3, - "review_comments": 2, - "labels": [ - { "name": "bug", "color": "d73a4a" } - ] - }, - { - "id": 1002, - "number": 11, - "title": "feat: add search functionality", - "body": "Implements full-text search across all items.", - "state": "open", - "html_url": "https://github.com/acme-corp/acme-api/pull/11", - "created_at": "2024-01-12T14:00:00Z", - "updated_at": "2024-01-14T16:00:00Z", - "user": { - "login": "devuser", - "id": 8888, - "avatar_url": "https://avatars.githubusercontent.com/u/8888?v=4" - }, - "draft": false, - "merged_at": null, - "head": { - "sha": "def9876543210def9876543210def9876543210de", - "ref": "feat/search", - "repo": { - "full_name": "acme-corp/acme-api" - } - }, - "base": { - "ref": "main" - }, - "requested_reviewers": [], - "assignees": [ - { "login": "devuser", "id": 8888 } - ], - "additions": 150, - "deletions": 30, - "changed_files": 8, - "comments": 1, - "review_comments": 0, - "labels": [] - } -] diff --git a/tests/fixtures/github-search-issues.json b/tests/fixtures/github-search-issues.json deleted file mode 100644 index 4de9437..0000000 --- a/tests/fixtures/github-search-issues.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "total_count": 3, - "incomplete_results": false, - "items": [ - { - "id": 1, - "number": 1347, - "title": "Found a bug", - "state": "open", - "html_url": "https://github.com/octocat/Hello-World/issues/1347", - "created_at": "2011-04-22T13:33:48Z", - "updated_at": "2011-04-22T13:33:48Z", - "user": { - "login": "octocat", - "id": 1, - "avatar_url": "https://github.com/images/error/octocat_happy.gif" - }, - "labels": [ - { "id": 208045946, "name": "bug", "color": "d73a4a" } - ], - "assignees": [ - { "login": "octocat", "id": 1 } - ], - "repository_url": "https://api.github.com/repos/octocat/Hello-World", - "comments": 5 - }, - { - "id": 2, - "number": 1348, - "title": "Feature request: dark mode", - "state": "open", - "html_url": "https://github.com/octocat/Hello-World/issues/1348", - "created_at": "2024-01-10T08:00:00Z", - "updated_at": "2024-01-12T14:30:00Z", - "user": { - "login": "contributor", - "id": 9999, - "avatar_url": "https://avatars.githubusercontent.com/u/9999?v=4" - }, - "labels": [ - { "id": 208045947, "name": "enhancement", "color": "a2eeef" } - ], - "assignees": [], - "repository_url": "https://api.github.com/repos/octocat/Hello-World", - "comments": 0 - }, - { - "id": 3, - "number": 42, - "title": "Fix authentication issue", - "state": "open", - "html_url": "https://github.com/acme-corp/acme-api/issues/42", - "created_at": "2024-01-14T09:00:00Z", - "updated_at": "2024-01-15T11:00:00Z", - "user": { - "login": "devuser", - "id": 8888, - "avatar_url": "https://avatars.githubusercontent.com/u/8888?v=4" - }, - "labels": [ - { "id": 300000001, "name": "bug", "color": "d73a4a" }, - { "id": 300000002, "name": "priority:high", "color": "b60205" } - ], - "assignees": [ - { "login": "devuser", "id": 8888 } - ], - "repository_url": "https://api.github.com/repos/acme-corp/acme-api", - "comments": 2 - } - ] -} diff --git a/tests/fixtures/github-search-prs.json b/tests/fixtures/github-search-prs.json deleted file mode 100644 index 01b1802..0000000 --- a/tests/fixtures/github-search-prs.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "total_count": 1, - "incomplete_results": false, - "items": [ - { - "id": 1001, - "number": 10, - "title": "Fix: resolve null pointer on startup", - "state": "open", - "html_url": "https://github.com/octocat/Hello-World/pull/10", - "created_at": "2024-01-08T10:00:00Z", - "updated_at": "2024-01-09T12:00:00Z", - "user": { - "login": "octocat", - "id": 1, - "avatar_url": "https://github.com/images/error/octocat_happy.gif" - }, - "labels": [], - "assignees": [ - { "login": "octocat", "id": 1 } - ], - "repository_url": "https://api.github.com/repos/octocat/Hello-World", - "pull_request": { - "url": "https://api.github.com/repos/octocat/Hello-World/pulls/10", - "html_url": "https://github.com/octocat/Hello-World/pull/10", - "diff_url": "https://github.com/octocat/Hello-World/pull/10.diff", - "patch_url": "https://github.com/octocat/Hello-World/pull/10.patch", - "merged_at": null - }, - "draft": false - } - ] -} diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx index 6a303a5..48ef311 100644 --- a/tests/helpers/index.tsx +++ b/tests/helpers/index.tsx @@ -48,7 +48,7 @@ export function makePullRequest(overrides: Partial = {}): PullReque deletions: 0, changedFiles: 0, comments: 0, - reviewComments: 0, + reviewThreads: 0, labels: [], reviewDecision: null, totalReviewCount: 0, diff --git a/tests/integration/data-pipeline.test.ts b/tests/integration/data-pipeline.test.ts index 9899827..80b0f1f 100644 --- a/tests/integration/data-pipeline.test.ts +++ b/tests/integration/data-pipeline.test.ts @@ -2,12 +2,12 @@ * True data pipeline integration test. * * Exercises the full fetch → cachedRequest → IDB → return pipeline. - * Only the HTTP layer (octokit.request) is mocked. + * Only the HTTP layer (octokit.request / octokit.graphql) is mocked. * Cache (cachedFetch, cachedRequest) and IDB (via fake-indexeddb) are real. * * Pipeline under test: * fetchWorkflowRuns → cachedRequest → cachedFetch → IDB (fake-indexeddb) - * fetchIssues → batchedSearch → octokit.request (search API, no IDB) + * fetchIssues → graphqlSearchIssues → octokit.graphql (no IDB) */ import "fake-indexeddb/auto"; // Must be first import import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; @@ -32,36 +32,57 @@ const rawRun = { head_sha: "abc1234", head_branch: "main", run_number: 1, + run_attempt: 1, html_url: "https://github.com/octocat/Hello-World/actions/runs/9001", created_at: "2024-01-15T09:00:00Z", updated_at: "2024-01-15T09:10:00Z", + run_started_at: "2024-01-15T09:00:00Z", + completed_at: "2024-01-15T09:10:00Z", + display_title: "CI", + actor: { login: "octocat", avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4" }, }; -const rawSearchIssue = { - id: 1, +const graphqlIssueNode = { + databaseId: 1, number: 1347, title: "Found a bug", state: "open", - html_url: "https://github.com/octocat/Hello-World/issues/1347", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-02T00:00:00Z", - user: { login: "octocat", avatar_url: "https://github.com/images/error/octocat_happy.gif" }, - labels: [{ name: "bug", color: "d73a4a" }], - assignees: [{ login: "octocat" }], - repository_url: "https://api.github.com/repos/octocat/Hello-World", - // No pull_request field → this is an issue + url: "https://github.com/octocat/Hello-World/issues/1347", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://github.com/images/error/octocat_happy.gif" }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }] }, + assignees: { nodes: [{ login: "octocat" }] }, + repository: { nameWithOwner: "octocat/Hello-World" }, + comments: { totalCount: 0 }, }; +function makeGraphqlSearchResponse(nodes = [graphqlIssueNode]) { + return { + search: { + issueCount: nodes.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes, + }, + rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }; +} + // ── Helpers ─────────────────────────────────────────────────────────────────── type OctokitLike = { request: ReturnType; + graphql: ReturnType; paginate: { iterator: ReturnType }; }; -function makeOctokit(requestImpl: (route: string, params?: unknown) => Promise): OctokitLike { +function makeOctokit( + requestImpl: (route: string, params?: unknown) => Promise, + graphqlImpl?: (query: string, variables?: unknown) => Promise +): OctokitLike { return { request: vi.fn(requestImpl), + graphql: vi.fn(graphqlImpl ?? (async () => makeGraphqlSearchResponse([]))), paginate: { iterator: vi.fn() }, }; } @@ -246,25 +267,16 @@ describe("data pipeline: fetch → IDB cache → return", () => { }); }); -describe("data pipeline: search API (no IDB cache) → return", () => { +describe("data pipeline: GraphQL search (no IDB cache) → return", () => { /** - * Search API does not use IDB — verifies the fetch→transform pipeline - * without the cache layer. Two calls always hit the API. + * GraphQL search does not use IDB — verifies the fetch→transform pipeline + * without the cache layer. Two calls always hit the GraphQL API. */ - it("fresh fetch: search results are mapped and returned correctly", async () => { - const octokit = makeOctokit(async (route) => { - if (route === "GET /search/issues") { - return { - data: { - total_count: 1, - incomplete_results: false, - items: [rawSearchIssue], - }, - headers: {}, - }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }); + it("fresh fetch: GraphQL search results are mapped and returned correctly", async () => { + const octokit = makeOctokit( + async () => ({ data: [], headers: {} }), + async () => makeGraphqlSearchResponse([graphqlIssueNode]) + ); const { issues, errors } = await fetchIssues( octokit as unknown as ReturnType, @@ -274,7 +286,7 @@ describe("data pipeline: search API (no IDB cache) → return", () => { expect(errors).toEqual([]); expect(issues).toHaveLength(1); - expect(issues[0].id).toBe(1); + expect(issues[0].id).toBe(1); // databaseId expect(issues[0].title).toBe("Found a bug"); expect(issues[0].userLogin).toBe("octocat"); expect(issues[0].labels).toEqual([{ name: "bug", color: "d73a4a" }]); @@ -282,11 +294,11 @@ describe("data pipeline: search API (no IDB cache) → return", () => { expect(issues[0].repoFullName).toBe("octocat/Hello-World"); }); - it("second fetch always calls API again (search is not cached in IDB)", async () => { - const octokit = makeOctokit(async () => ({ - data: { total_count: 1, incomplete_results: false, items: [rawSearchIssue] }, - headers: {}, - })); + it("second fetch calls GraphQL again (search is not cached in IDB)", async () => { + const octokit = makeOctokit( + async () => ({ data: [], headers: {} }), + async () => makeGraphqlSearchResponse([graphqlIssueNode]) + ); await fetchIssues( octokit as unknown as ReturnType, @@ -299,19 +311,20 @@ describe("data pipeline: search API (no IDB cache) → return", () => { "octocat" ); - // Two calls, two API hits — no IDB caching for search - expect(octokit.request).toHaveBeenCalledTimes(2); + // Two calls, two GraphQL hits — no IDB caching for search + expect(octokit.graphql).toHaveBeenCalledTimes(2); - // Also verify nothing written to IDB (search doesn't use cachedRequest) + // Verify nothing written to IDB (GraphQL search doesn't use cachedRequest) const entry = await getCacheEntry("search:octocat/Hello-World:issues"); expect(entry).toBeUndefined(); }); - it("search API error is returned as ApiError without throwing", async () => { + it("GraphQL search error is returned as ApiError without throwing", async () => { const err503 = Object.assign(new Error("Service Unavailable"), { status: 503 }); - const octokit = makeOctokit(async () => { - throw err503; - }); + const octokit = makeOctokit( + async () => ({ data: [], headers: {} }), + async () => { throw err503; } + ); const { issues, errors } = await fetchIssues( octokit as unknown as ReturnType, @@ -322,8 +335,6 @@ describe("data pipeline: search API (no IDB cache) → return", () => { expect(issues).toEqual([]); expect(errors).toHaveLength(1); expect(errors[0].statusCode).toBe(503); - // 503 has no statusCode >= 500 branch... let's check - // batchedSearch wraps chunk rejections: statusCode 503 → retryable (null or >=500) expect(errors[0].retryable).toBe(true); }); }); diff --git a/tests/lib/notifications.test.ts b/tests/lib/notifications.test.ts index c89caaf..bbf6c11 100644 --- a/tests/lib/notifications.test.ts +++ b/tests/lib/notifications.test.ts @@ -76,7 +76,7 @@ function makePr(id: number): PullRequest { deletions: 0, changedFiles: 0, comments: 0, - reviewComments: 0, + reviewThreads: 0, labels: [], reviewDecision: null, totalReviewCount: 0, diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index 2ee0f6f..aed3840 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -9,19 +9,31 @@ import { type RepoRef, } from "../../src/app/services/api"; import { clearCache } from "../../src/app/stores/cache"; +import { pushNotification } from "../../src/app/lib/errors"; import orgsFixture from "../fixtures/github-orgs.json"; import reposFixture from "../fixtures/github-repos.json"; -import searchIssuesFixture from "../fixtures/github-search-issues.json"; -import searchPrsFixture from "../fixtures/github-search-prs.json"; -import prsFixture from "../fixtures/github-prs.json"; import runsFixture from "../fixtures/github-runs.json"; +vi.mock("../../src/app/lib/errors", () => ({ + pushNotification: vi.fn(), + pushError: vi.fn(), + getErrors: vi.fn().mockReturnValue([]), + dismissError: vi.fn(), + getNotifications: vi.fn().mockReturnValue([]), + getUnreadCount: vi.fn().mockReturnValue(0), + markAllAsRead: vi.fn(), +})); + // ── Helpers ────────────────────────────────────────────────────────────────── -function makeOctokit(requestImpl: (route: string, params?: unknown) => Promise) { +function makeOctokit( + requestImpl: (route: string, params?: unknown) => Promise, + graphqlImpl?: (query: string, variables?: unknown) => Promise +) { return { request: vi.fn(requestImpl), + graphql: vi.fn(graphqlImpl ?? (async () => ({}))), paginate: { iterator: vi.fn((route: string, params?: unknown) => { void params; // captured for test assertions @@ -157,98 +169,86 @@ describe("fetchRepos", () => { }); }); -// ── fetchIssues (Search API) ───────────────────────────────────────────────── +// ── fetchIssues (GraphQL search) ───────────────────────────────────────────── + +const graphqlIssueNode = { + databaseId: 1347, + number: 1347, + title: "Found a bug", + state: "open", + url: "https://github.com/octocat/Hello-World/issues/1347", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://github.com/images/error/octocat_happy.gif" }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }] }, + assignees: { nodes: [{ login: "octocat" }] }, + repository: { nameWithOwner: "octocat/Hello-World" }, + comments: { totalCount: 3 }, +}; + +function makeGraphqlIssueResponse(nodes = [graphqlIssueNode], hasNextPage = false, issueCount?: number) { + return { + search: { + issueCount: issueCount ?? nodes.length, + pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor1" : null }, + nodes, + }, + rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }; +} describe("fetchIssues", () => { - function makeSearchOctokit(searchData?: unknown) { - return { - request: vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { - data: searchData ?? searchIssuesFixture, - headers: {}, - }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }), - paginate: { iterator: vi.fn() }, - }; + function makeIssueOctokit(graphqlImpl?: (query: string, variables?: unknown) => Promise) { + return makeOctokit(async () => ({ data: [], headers: {} }), graphqlImpl ?? (async () => makeGraphqlIssueResponse())); } - it("returns issues from search results", async () => { - const octokit = makeSearchOctokit(); + it("returns issues from GraphQL search results", async () => { + const octokit = makeIssueOctokit(); const result = await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - expect(result.issues.length).toBe(searchIssuesFixture.items.length); - expect(result.issues[0].id).toBe(searchIssuesFixture.items[0].id); + expect(result.issues.length).toBe(1); + expect(result.issues[0].id).toBe(1347); expect(result.errors).toEqual([]); }); - it("uses the Search API with involves qualifier", async () => { - const octokit = makeSearchOctokit(); + it("uses GraphQL search with involves qualifier", async () => { + const octokit = makeIssueOctokit(); await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - expect(octokit.request).toHaveBeenCalledWith( - "GET /search/issues", - expect.objectContaining({ - q: expect.stringContaining("involves:octocat"), - }) + expect(octokit.graphql).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ q: expect.stringContaining("involves:octocat") }) ); - expect(octokit.request).toHaveBeenCalledWith( - "GET /search/issues", - expect.objectContaining({ - q: expect.stringContaining("is:issue"), - }) + expect(octokit.graphql).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ q: expect.stringContaining("is:issue") }) ); }); - it("includes repo qualifiers in search query", async () => { - const octokit = makeSearchOctokit(); + it("includes repo qualifiers in GraphQL search query", async () => { + const octokit = makeIssueOctokit(); await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - expect(octokit.request).toHaveBeenCalledWith( - "GET /search/issues", - expect.objectContaining({ - q: expect.stringContaining("repo:octocat/Hello-World"), - }) - ); - }); - - it("filters out items with pull_request field", async () => { - const withPR = { - total_count: 2, - incomplete_results: false, - items: [ - searchIssuesFixture.items[0], - { ...searchIssuesFixture.items[1], pull_request: { url: "https://..." } }, - ], - }; - const octokit = makeSearchOctokit(withPR); - - const { issues } = await fetchIssues( - octokit as unknown as ReturnType, - [testRepo], - "octocat" + expect(octokit.graphql).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ q: expect.stringContaining("repo:octocat/Hello-World") }) ); - - expect(issues.length).toBe(1); - expect(issues[0].id).toBe(searchIssuesFixture.items[0].id); }); - it("maps search result fields to camelCase issue shape", async () => { - const octokit = makeSearchOctokit(); + it("maps GraphQL fields to camelCase issue shape", async () => { + const octokit = makeIssueOctokit(); const { issues } = await fetchIssues( octokit as unknown as ReturnType, [testRepo], @@ -256,17 +256,18 @@ describe("fetchIssues", () => { ); const issue = issues[0]; - expect(issue.htmlUrl).toBeDefined(); - expect(issue.createdAt).toBeDefined(); - expect(issue.updatedAt).toBeDefined(); - expect(issue.userLogin).toBeDefined(); - expect(issue.userAvatarUrl).toBeDefined(); - expect(issue.assigneeLogins).toBeDefined(); - expect(issue.repoFullName).toBe("octocat/Hello-World"); + expect(issue.id).toBe(1347); // databaseId → id + expect(issue.htmlUrl).toBe("https://github.com/octocat/Hello-World/issues/1347"); // url → htmlUrl + expect(issue.userLogin).toBe("octocat"); // author.login + expect(issue.userAvatarUrl).toBe("https://github.com/images/error/octocat_happy.gif"); // author.avatarUrl + expect(issue.repoFullName).toBe("octocat/Hello-World"); // repository.nameWithOwner + expect(issue.comments).toBe(3); // comments.totalCount + expect(issue.assigneeLogins).toEqual(["octocat"]); + expect(issue.labels).toEqual([{ name: "bug", color: "d73a4a" }]); }); it("returns empty result when repos is empty", async () => { - const octokit = makeSearchOctokit(); + const octokit = makeIssueOctokit(); const result = await fetchIssues( octokit as unknown as ReturnType, [], @@ -275,689 +276,777 @@ describe("fetchIssues", () => { expect(result.issues).toEqual([]); expect(result.errors).toEqual([]); - expect(octokit.request).not.toHaveBeenCalled(); + expect(octokit.graphql).not.toHaveBeenCalled(); }); it("batches repos into chunks of 30", async () => { - // Create 35 repos to force 2 batches const repos: RepoRef[] = Array.from({ length: 35 }, (_, i) => ({ owner: "org", name: `repo-${i}`, fullName: `org/repo-${i}`, })); - const octokit = makeSearchOctokit(); + const octokit = makeIssueOctokit(); await fetchIssues( octokit as unknown as ReturnType, repos, "octocat" ); - // Should make 2 search calls (30 + 5) - expect(octokit.request).toHaveBeenCalledTimes(2); + // Should make 2 GraphQL calls (30 + 5 repos) + expect(octokit.graphql).toHaveBeenCalledTimes(2); }); - it("deduplicates issues across batches", async () => { - // Same item returned by both batch calls - const sameItem = searchIssuesFixture.items[0]; - const searchData = { - total_count: 1, - incomplete_results: false, - items: [sameItem], - }; - + it("deduplicates issues across batches by databaseId", async () => { const repos: RepoRef[] = Array.from({ length: 35 }, (_, i) => ({ owner: "org", name: `repo-${i}`, fullName: `org/repo-${i}`, })); - const octokit = makeSearchOctokit(searchData); + // Both batches return the same databaseId + const octokit = makeIssueOctokit(async () => makeGraphqlIssueResponse([graphqlIssueNode])); const result = await fetchIssues( octokit as unknown as ReturnType, repos, "octocat" ); - // Should deduplicate: only 1 issue even though returned by 2 batches expect(result.issues.length).toBe(1); }); - it("throws when octokit is null", async () => { - await expect(fetchIssues(null, [testRepo], "octocat")).rejects.toThrow( - "No GitHub client available" - ); - }); -}); - -// ── fetchPullRequests (Search API + individual PR detail) ──────────────────── - -describe("fetchPullRequests", () => { - function makeOctokitForPRs() { - const request = vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { data: searchPrsFixture, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: prsFixture[0], headers: { etag: "etag-pr-detail" } }; + it("paginates GraphQL search across multiple pages", async () => { + let callCount = 0; + const octokit = makeIssueOctokit(async (_query, variables) => { + callCount++; + if (callCount === 1) { + // First page: has next page + expect((variables as Record).cursor).toBeNull(); + return makeGraphqlIssueResponse([{ ...graphqlIssueNode, databaseId: 1 }], true); } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; + // Second page: last page + expect((variables as Record).cursor).toBe("cursor1"); + return makeGraphqlIssueResponse([{ ...graphqlIssueNode, databaseId: 2 }], false); }); - const graphql = vi.fn(async () => ({ - pr0: { - object: { - statusCheckRollup: { state: "SUCCESS" }, - }, - pullRequest: { - reviewDecision: "APPROVED", - latestReviews: { totalCount: 1, nodes: [{ author: { login: "reviewer1" } }] }, - }, - }, - })); - return { request, graphql, paginate: { iterator: vi.fn() } }; - } - it("uses search API with involves and review-requested qualifiers", async () => { - const octokit = makeOctokitForPRs(); - - await fetchPullRequests( + const result = await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - // Should make 2 search calls: involves + review-requested - const searchCalls = octokit.request.mock.calls.filter( - (c) => c[0] === "GET /search/issues" - ); - expect(searchCalls.length).toBe(2); - - const queries = searchCalls.map((c) => ((c as unknown[])[1] as { q: string }).q); - expect(queries.some((q) => q.includes("involves:octocat"))).toBe(true); - expect(queries.some((q) => q.includes("review-requested:octocat"))).toBe(true); + expect(octokit.graphql).toHaveBeenCalledTimes(2); + expect(result.issues.length).toBe(2); }); - it("fetches full PR details for each search result", async () => { - const octokit = makeOctokitForPRs(); + it("caps at 1000 items and warns via pushNotification", async () => { + vi.mocked(pushNotification).mockClear(); - await fetchPullRequests( + // Return 1000 items in first response with issueCount > 1000 + const manyNodes = Array.from({ length: 1000 }, (_, i) => ({ ...graphqlIssueNode, databaseId: i + 1 })); + const octokit = makeIssueOctokit(async () => makeGraphqlIssueResponse(manyNodes, true, 1500)); + + const result = await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - const prDetailCalls = octokit.request.mock.calls.filter( - (c) => c[0] === "GET /repos/{owner}/{repo}/pulls/{pull_number}" + // Stopped at 1000 + expect(result.issues.length).toBe(1000); + // Only 1 graphql call (stopped before paginating) + expect(octokit.graphql).toHaveBeenCalledTimes(1); + // Warning notification sent + expect(pushNotification).toHaveBeenCalledWith( + "search/issues", + expect.stringContaining("capped at 1,000"), + "warning" ); - expect(prDetailCalls.length).toBe(1); // 1 unique PR in fixture }); - it("fetches check status via GraphQL batch call", async () => { - const octokit = makeOctokitForPRs(); + it("handles null nodes in GraphQL search results", async () => { + const octokit = makeIssueOctokit(async () => ({ + search: { + issueCount: 2, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [graphqlIssueNode, null, { ...graphqlIssueNode, databaseId: 999 }], + }, + rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + })); - const { pullRequests } = await fetchPullRequests( + const result = await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - // Should use GraphQL for check status, not REST - expect(octokit.graphql).toHaveBeenCalledTimes(1); - const graphqlQuery = (octokit.graphql.mock.calls as unknown[][])[0][0] as string; - expect(graphqlQuery).toContain("statusCheckRollup"); - - // No REST check status calls should be made - const restCheckCalls = octokit.request.mock.calls.filter( - (c) => - c[0] === "GET /repos/{owner}/{repo}/commits/{ref}/status" || - c[0] === "GET /repos/{owner}/{repo}/commits/{ref}/check-runs" - ); - expect(restCheckCalls.length).toBe(0); - - // Check status should be mapped from GraphQL response - for (const pr of pullRequests) { - expect(["success", "failure", "pending", null]).toContain(pr.checkStatus); - } + expect(result.issues.length).toBe(2); // null filtered out }); - it("maps GraphQL statusCheckRollup states correctly", async () => { - // Test FAILURE state - const octokitFailure = makeOctokitForPRs(); - (octokitFailure as Record).graphql = vi.fn(async () => ({ - pr0: { object: { statusCheckRollup: { state: "FAILURE" } }, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }, - })); - const failResult = await fetchPullRequests( - octokitFailure as unknown as ReturnType, - [testRepo], - "octocat" - ); - expect(failResult.pullRequests[0].checkStatus).toBe("failure"); + it("returns partial results and an error when second page throws mid-pagination", async () => { + vi.mocked(pushNotification).mockClear(); - await clearCache(); + let callCount = 0; + const octokit = makeIssueOctokit(async () => { + callCount++; + if (callCount === 1) { + // First page succeeds with more pages available + return makeGraphqlIssueResponse([{ ...graphqlIssueNode, databaseId: 1 }], true); + } + // Second page throws + throw new Error("GraphQL rate limit exceeded"); + }); - // Test PENDING state - const octokitPending = makeOctokitForPRs(); - (octokitPending as Record).graphql = vi.fn(async () => ({ - pr0: { object: { statusCheckRollup: { state: "PENDING" } }, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }, - })); - const pendResult = await fetchPullRequests( - octokitPending as unknown as ReturnType, + const result = await fetchIssues( + octokit as unknown as ReturnType, [testRepo], "octocat" ); - expect(pendResult.pullRequests[0].checkStatus).toBe("pending"); - await clearCache(); + // Issues from the first page are returned + expect(result.issues.length).toBe(1); + expect(result.issues[0].id).toBe(1); - // Test null (no checks) - const octokitNull = makeOctokitForPRs(); - (octokitNull as Record).graphql = vi.fn(async () => ({ - pr0: { object: null, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }, - })); - const nullResult = await fetchPullRequests( - octokitNull as unknown as ReturnType, - [testRepo], - "octocat" - ); - expect(nullResult.pullRequests[0].checkStatus).toBeNull(); + // An ApiError is included in the result's errors array + expect(result.errors.length).toBe(1); + expect(result.errors[0].message).toContain("GraphQL rate limit exceeded"); + expect(result.errors[0].repo).toBe("search-batch-1/1"); + + // pushNotification is NOT called (no 1000-item cap was reached) + expect(pushNotification).not.toHaveBeenCalled(); }); - it("falls back to REST when GraphQL fails", async () => { - const octokit = makeOctokitForPRs(); - (octokit as Record).graphql = vi.fn(async () => { - throw new Error("GraphQL rate limited"); + it("extracts partial data from GraphqlResponseError and stops pagination", async () => { + vi.mocked(pushNotification).mockClear(); + + // Simulate a GraphqlResponseError: has .data with valid nodes + errors + const partialError = Object.assign(new Error("Some nodes failed to resolve"), { + data: { + search: { + issueCount: 5, + pageInfo: { hasNextPage: true, endCursor: "cursor-partial" }, + nodes: [{ ...graphqlIssueNode, databaseId: 42 }, null], + }, + rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }, }); - // Mock REST fallback endpoints - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (octokit.request as any).mockImplementation(async (route: string) => { - if (route === "GET /search/issues") { - return { data: searchPrsFixture, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: prsFixture[0], headers: { etag: "etag-pr-detail" } }; - } - if (route === "GET /repos/{owner}/{repo}/commits/{ref}/status") { - return { data: { state: "success", total_count: 1 }, headers: { etag: "etag-status" } }; - } - if (route === "GET /repos/{owner}/{repo}/commits/{ref}/check-runs") { - return { data: { check_runs: [{ status: "completed", conclusion: "success" }] }, headers: { etag: "etag-check-runs" } }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews") { - return { - data: [ - { user: { login: "reviewer1" }, state: "APPROVED" }, - ], - headers: { etag: "etag-reviews" }, - }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; + const octokit = makeIssueOctokit(async () => { + throw partialError; }); - const { pullRequests } = await fetchPullRequests( + const result = await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - expect(pullRequests.length).toBe(1); - // REST fallback should provide check status from /commits/{sha}/status - expect(pullRequests[0].checkStatus).toBe("success"); - // REST fallback should derive review decision from /pulls/{number}/reviews - expect(pullRequests[0].reviewDecision).toBe("APPROVED"); - // REST fallback should provide reviewer logins - expect(pullRequests[0].reviewerLogins).toContain("reviewer1"); + // Valid node from partial data is returned + expect(result.issues.length).toBe(1); + expect(result.issues[0].id).toBe(42); + // Error is recorded + expect(result.errors.length).toBe(1); + expect(result.errors[0].message).toContain("Some nodes failed to resolve"); + // Only 1 graphql call — did NOT try to paginate after partial error + expect(octokit.graphql).toHaveBeenCalledTimes(1); }); - it("maps PR detail fields to camelCase shape", async () => { - const octokit = makeOctokitForPRs(); + it("catches unexpected response shapes without crashing", async () => { + // Return a malformed response missing search.nodes — would TypeError without catch-all + const octokit = makeIssueOctokit(async () => ({ + search: { issueCount: 0, pageInfo: null, nodes: null }, + rateLimit: null, + })); - const { pullRequests } = await fetchPullRequests( + const result = await fetchIssues( octokit as unknown as ReturnType, [testRepo], "octocat" ); - const pr = pullRequests[0]; - expect(pr).toMatchObject({ - id: expect.any(Number), - number: expect.any(Number), - title: expect.any(String), - state: expect.any(String), - draft: expect.any(Boolean), - htmlUrl: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - userLogin: expect.any(String), - headSha: expect.any(String), - headRef: expect.any(String), - baseRef: expect.any(String), - repoFullName: expect.any(String), - additions: expect.any(Number), - deletions: expect.any(Number), - changedFiles: expect.any(Number), - comments: expect.any(Number), - reviewComments: expect.any(Number), - labels: expect.any(Array), - reviewDecision: "APPROVED", - }); + // Function returns gracefully with an error, not a thrown TypeError + expect(result.issues).toEqual([]); + expect(result.errors.length).toBe(1); + expect(result.errors[0].retryable).toBe(false); }); - it("deduplicates PRs found by both involves and review-requested", async () => { - // Both search queries return the same PR - const octokit = makeOctokitForPRs(); - - const { pullRequests } = await fetchPullRequests( + it("rejects invalid userLogin with error instead of injecting into query", async () => { + const octokit = makeIssueOctokit(); + const result = await fetchIssues( octokit as unknown as ReturnType, [testRepo], - "octocat" + "bad user" // contains space — fails VALID_LOGIN ); - // Only 1 PR despite being found by potentially both search queries - expect(pullRequests.length).toBe(1); + expect(result.issues).toEqual([]); + expect(result.errors.length).toBe(1); + expect(result.errors[0].message).toContain("Invalid userLogin"); + expect(octokit.graphql).not.toHaveBeenCalled(); }); - it("returns empty result when repos is empty", async () => { - const octokit = makeOctokitForPRs(); - const result = await fetchPullRequests( + it("truncates to exactly 1000 when parallel chunks overshoot", async () => { + vi.mocked(pushNotification).mockClear(); + + // 35 repos → 2 chunks. Each chunk returns 600 items (total 1200, well over cap). + const repos: RepoRef[] = Array.from({ length: 35 }, (_, i) => ({ + owner: "org", + name: `repo-${i}`, + fullName: `org/repo-${i}`, + })); + + let callCount = 0; + const octokit = makeIssueOctokit(async () => { + callCount++; + const batchStart = (callCount - 1) * 600; + const nodes = Array.from({ length: 600 }, (_, i) => ({ + ...graphqlIssueNode, + databaseId: batchStart + i + 1, + })); + return makeGraphqlIssueResponse(nodes, false, 600); + }); + + const result = await fetchIssues( octokit as unknown as ReturnType, - [], + repos, "octocat" ); - expect(result.pullRequests).toEqual([]); - expect(result.errors).toEqual([]); + + // splice(1000) ensures exactly 1000 even with parallel overshoot + expect(result.issues.length).toBe(1000); + expect(pushNotification).toHaveBeenCalledWith( + "search/issues", + expect.stringContaining("capped at 1,000"), + "warning" + ); }); it("throws when octokit is null", async () => { - await expect( - fetchPullRequests(null, [testRepo], "octocat") - ).rejects.toThrow("No GitHub client available"); + await expect(fetchIssues(null, [testRepo], "octocat")).rejects.toThrow( + "No GitHub client available" + ); }); }); -// ── fetchWorkflowRuns (single endpoint per repo) ──────────────────────────── +// ── fetchPullRequests (GraphQL search) ─────────────────────────────────────── + +const graphqlPRNode = { + databaseId: 42, + number: 42, + title: "Add feature", + state: "open", + isDraft: false, + url: "https://github.com/octocat/Hello-World/pull/42", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + author: { login: "octocat", avatarUrl: "https://github.com/images/error/octocat_happy.gif" }, + headRefOid: "abc123", + headRefName: "feature-branch", + baseRefName: "main", + headRepository: { owner: { login: "octocat" }, nameWithOwner: "octocat/Hello-World" }, + repository: { nameWithOwner: "octocat/Hello-World" }, + assignees: { nodes: [{ login: "octocat" }] }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "reviewer2" } }] }, + labels: { nodes: [{ name: "feature", color: "a2eeef" }] }, + additions: 100, + deletions: 20, + changedFiles: 5, + comments: { totalCount: 3 }, + reviewThreads: { totalCount: 2 }, + reviewDecision: "APPROVED", + latestReviews: { totalCount: 1, nodes: [{ author: { login: "reviewer1" } }] }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, +}; -describe("fetchWorkflowRuns", () => { - function makeOctokitForRuns() { - const request = vi.fn(async (route: string) => { - if (route === "GET /repos/{owner}/{repo}/actions/runs") { - return { - data: { - workflow_runs: runsFixture.runs, - total_count: runsFixture.runs.length, - }, - headers: { etag: "etag-runs" }, - }; - } - return { data: [], headers: {} }; - }); - return { request, paginate: { iterator: vi.fn() } }; +function makeGraphqlPRResponse(nodes = [graphqlPRNode], hasNextPage = false, issueCount?: number) { + return { + search: { + issueCount: issueCount ?? nodes.length, + pageInfo: { hasNextPage, endCursor: hasNextPage ? "cursor1" : null }, + nodes, + }, + rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }; +} + +describe("fetchPullRequests", () => { + function makePROctokit(graphqlImpl?: (query: string, variables?: unknown) => Promise) { + return makeOctokit( + async () => ({ data: [], headers: {} }), + graphqlImpl ?? (async () => makeGraphqlPRResponse()) + ); } - it("returns runs grouped by workflow", async () => { - const octokit = makeOctokitForRuns(); + it("uses GraphQL search with involves and review-requested qualifiers", async () => { + const calls: string[] = []; + const octokit = makePROctokit(async (_query, variables) => { + calls.push((variables as Record).q as string); + return makeGraphqlPRResponse(); + }); - const { workflowRuns } = await fetchWorkflowRuns( + await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - 5, - 3 + "octocat" ); - expect(Array.isArray(workflowRuns)).toBe(true); - expect(workflowRuns.length).toBeGreaterThan(0); + // 2 query types × 1 batch = 2 calls + expect(calls.some((q) => q.includes("involves:octocat"))).toBe(true); + expect(calls.some((q) => q.includes("review-requested:octocat"))).toBe(true); }); - it("uses single actions/runs endpoint per repo", async () => { - const octokit = makeOctokitForRuns(); + it("maps GraphQL PR fields to camelCase shape", async () => { + const octokit = makePROctokit(); - await fetchWorkflowRuns( + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - 5, - 3 + "octocat" ); - // Should make exactly 1 API call (not separate workflows + per-workflow runs) - expect(octokit.request).toHaveBeenCalledTimes(1); - expect(octokit.request).toHaveBeenCalledWith( - "GET /repos/{owner}/{repo}/actions/runs", - expect.objectContaining({ - owner: "octocat", - repo: "Hello-World", - }) - ); + const pr = pullRequests[0]; + expect(pr.id).toBe(42); // databaseId + expect(pr.draft).toBe(false); // isDraft + expect(pr.htmlUrl).toBe("https://github.com/octocat/Hello-World/pull/42"); // url + expect(pr.headSha).toBe("abc123"); // headRefOid + expect(pr.headRef).toBe("feature-branch"); // headRefName + expect(pr.baseRef).toBe("main"); // baseRefName + expect(pr.comments).toBe(3); // comments.totalCount + expect(pr.reviewThreads).toBe(2); // reviewThreads.totalCount + expect(pr.totalReviewCount).toBe(1); // latestReviews.totalCount + expect(pr.additions).toBe(100); + expect(pr.changedFiles).toBe(5); + expect(pr.repoFullName).toBe("octocat/Hello-World"); }); - it("respects maxRuns per workflow", async () => { - const octokit = makeOctokitForRuns(); + it("maps statusCheckRollup states correctly", async () => { + const states: Array<[string | null, string | null]> = [ + ["SUCCESS", "success"], + ["FAILURE", "failure"], + ["ERROR", "failure"], + ["ACTION_REQUIRED", "failure"], + ["PENDING", "pending"], + ["EXPECTED", "pending"], + ["QUEUED", "pending"], + [null, null], + ]; - const maxWorkflows = 3; - const maxRuns = 1; - const { workflowRuns } = await fetchWorkflowRuns( + for (const [rawState, expected] of states) { + await clearCache(); + const node = { + ...graphqlPRNode, + databaseId: 100, + commits: { nodes: [{ commit: { statusCheckRollup: rawState ? { state: rawState } : null } }] }, + } as typeof graphqlPRNode; + const octokit = makePROctokit(async () => makeGraphqlPRResponse([node])); + const { pullRequests } = await fetchPullRequests( + octokit as unknown as ReturnType, + [testRepo], + "octocat" + ); + expect(pullRequests[0].checkStatus).toBe(expected); + } + }); + + it("merges reviewRequests and latestReviews into reviewerLogins with Set dedup", async () => { + const nodeWithOverlap = { + ...graphqlPRNode, + databaseId: 200, + reviewRequests: { nodes: [{ requestedReviewer: { login: "shared" } }] }, + latestReviews: { totalCount: 2, nodes: [{ author: { login: "shared" } }, { author: { login: "unique" } }] }, + }; + const octokit = makePROctokit(async () => makeGraphqlPRResponse([nodeWithOverlap])); + + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - maxWorkflows, - maxRuns + "octocat" ); - // Group result by workflowId and check each has at most maxRuns - const byWorkflow = new Map(); - for (const run of workflowRuns) { - byWorkflow.set(run.workflowId, (byWorkflow.get(run.workflowId) ?? 0) + 1); - } - for (const count of byWorkflow.values()) { - expect(count).toBeLessThanOrEqual(maxRuns); + // "shared" appears in both — should only appear once + expect(pullRequests[0].reviewerLogins).toEqual(expect.arrayContaining(["shared", "unique"])); + expect(pullRequests[0].reviewerLogins.filter((l) => l === "shared")).toHaveLength(1); + }); + + it("maps reviewDecision pass-through", async () => { + for (const decision of ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", null] as const) { + await clearCache(); + const node = { ...graphqlPRNode, databaseId: 300, reviewDecision: decision } as typeof graphqlPRNode; + const octokit = makePROctokit(async () => makeGraphqlPRResponse([node])); + const { pullRequests } = await fetchPullRequests( + octokit as unknown as ReturnType, + [testRepo], + "octocat" + ); + expect(pullRequests[0].reviewDecision).toBe(decision); } }); - it("respects maxWorkflows limit", async () => { - const octokit = makeOctokitForRuns(); + it("deduplicates PRs from involves and review-requested by databaseId", async () => { + // Both query types return the same PR node + const octokit = makePROctokit(async () => makeGraphqlPRResponse([graphqlPRNode])); - const maxWorkflows = 1; - const { workflowRuns } = await fetchWorkflowRuns( + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - maxWorkflows, - 10 + "octocat" ); - // All runs should be from a single workflow - const workflowIds = new Set(workflowRuns.map((r) => r.workflowId)); - expect(workflowIds.size).toBeLessThanOrEqual(maxWorkflows); + // Only 1 PR even though returned by both involves + review-requested queries + expect(pullRequests.length).toBe(1); }); - it("tags PR-triggered runs with isPrRun=true", async () => { - const octokit = makeOctokitForRuns(); + it("detects fork PR and fires fallback query when checkStatus is null", async () => { + const forkNode = { + ...graphqlPRNode, + databaseId: 500, + // Head repo owner differs from base repo owner → fork + headRepository: { owner: { login: "fork-owner" }, nameWithOwner: "fork-owner/Hello-World" }, + repository: { nameWithOwner: "octocat/Hello-World" }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + } as unknown as typeof graphqlPRNode; - const { workflowRuns } = await fetchWorkflowRuns( + let graphqlCallCount = 0; + const octokit = makePROctokit(async () => { + graphqlCallCount++; + if (graphqlCallCount <= 2) { + // Primary search queries + return makeGraphqlPRResponse([forkNode]); + } + // Fork fallback query + return { + fork0: { object: { statusCheckRollup: { state: "SUCCESS" } } }, + rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }; + }); + + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - 5, - 10 + "octocat" ); - // Run 9003 has event: "pull_request" - const prRun = workflowRuns.find((r) => r.id === 9003); - expect(prRun).toBeDefined(); - expect(prRun!.isPrRun).toBe(true); - - // Run 9001 has event: "push" - const pushRun = workflowRuns.find((r) => r.id === 9001); - expect(pushRun).toBeDefined(); - expect(pushRun!.isPrRun).toBe(false); + // Fork fallback was triggered + expect(graphqlCallCount).toBeGreaterThan(2); + // checkStatus populated from fork fallback + expect(pullRequests[0].checkStatus).toBe("success"); }); - it("maps raw run fields to camelCase shape", async () => { - const octokit = makeOctokitForRuns(); + it("handles deleted fork (null headRepository) gracefully — no fallback", async () => { + const deletedForkNode = { + ...graphqlPRNode, + databaseId: 600, + headRepository: null, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + } as unknown as typeof graphqlPRNode; + let graphqlCallCount = 0; + const octokit = makePROctokit(async () => { + graphqlCallCount++; + return makeGraphqlPRResponse([deletedForkNode]); + }); - const { workflowRuns } = await fetchWorkflowRuns( + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - 5, - 3 + "octocat" ); - const run = workflowRuns[0]; - expect(run).toMatchObject({ - id: expect.any(Number), - name: expect.any(String), - status: expect.any(String), - event: expect.any(String), - workflowId: expect.any(Number), - headSha: expect.any(String), - headBranch: expect.any(String), - runNumber: expect.any(Number), - htmlUrl: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - repoFullName: expect.any(String), - isPrRun: expect.any(Boolean), - }); + // No extra graphql call for the fork fallback + expect(graphqlCallCount).toBe(2); // 2 query types × 1 batch + expect(pullRequests[0].checkStatus).toBeNull(); }); - it("sorts workflows by most recent activity descending", async () => { - const octokit = makeOctokitForRuns(); + it("preserves PR when headRepository.nameWithOwner is malformed", async () => { + const malformedNode = { + ...graphqlPRNode, + databaseId: 700, + headRepository: { + owner: { login: "fork-owner" }, + nameWithOwner: "no-slash-here", // malformed — missing "/" + }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + } as unknown as typeof graphqlPRNode; + const octokit = makePROctokit(async () => makeGraphqlPRResponse([malformedNode])); - const { workflowRuns } = await fetchWorkflowRuns( + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - 5, - 10 + "octocat" ); - // CI workflow (id 1001) has latestAt 2024-01-15T10:05 (from run 9002) - // Deploy workflow (id 1002) has latestAt 2024-01-15T09:25 (from run 9004) - // CI should appear first (more recent) - const firstCiIndex = workflowRuns.findIndex((r) => r.workflowId === 1001); - const firstDeployIndex = workflowRuns.findIndex((r) => r.workflowId === 1002); - expect(firstCiIndex).toBeLessThan(firstDeployIndex); + // PR is NOT silently dropped — it's returned with null checkStatus + expect(pullRequests.length).toBe(1); + expect(pullRequests[0].id).toBe(700); + expect(pullRequests[0].checkStatus).toBeNull(); }); - it("sorts runs within a workflow by created_at descending", async () => { - const octokit = makeOctokitForRuns(); + it("stops pagination when hasNextPage is true but endCursor is null", async () => { + let callCount = 0; + const octokit = makePROctokit(async () => { + callCount++; + return { + search: { + issueCount: 100, + pageInfo: { hasNextPage: true, endCursor: null }, // degenerate response + nodes: [{ ...graphqlPRNode, databaseId: callCount }], + }, + rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }; + }); - const { workflowRuns } = await fetchWorkflowRuns( + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - 5, - 10 + "octocat" ); - // CI runs: 9002 (10:00), 9001 (09:00), 9003 (Jan 14 15:00) — descending by created_at - const ciRuns = workflowRuns.filter((r) => r.workflowId === 1001); - expect(ciRuns[0].id).toBe(9002); - expect(ciRuns[1].id).toBe(9001); - expect(ciRuns[2].id).toBe(9003); - }); - - it("throws when octokit is null", async () => { - await expect( - fetchWorkflowRuns(null, [testRepo], 5, 3) - ).rejects.toThrow("No GitHub client available"); + // Should NOT loop infinitely — breaks after first page per query type + expect(pullRequests.length).toBeGreaterThan(0); + // 2 query types × 1 page each (stopped by null endCursor) = 2 calls + expect(callCount).toBe(2); }); - it("returns runs sorted newest-first within each workflow", async () => { - const octokit = makeOctokitForRuns(); + it("surfaces pushNotification when fork fallback query fails", async () => { + vi.mocked(pushNotification).mockClear(); - const { workflowRuns } = await fetchWorkflowRuns( - octokit as unknown as ReturnType, - [testRepo], - 5, - 10 - ); - - // Workflow 1001 has 3 runs: 9002 (10:00), 9001 (09:00), 9003 (14:15:00 prev day) - const w1001Runs = workflowRuns.filter((r) => r.workflowId === 1001); - for (let i = 1; i < w1001Runs.length; i++) { - expect(w1001Runs[i - 1].createdAt >= w1001Runs[i].createdAt).toBe(true); - } - }); + // PR with fork head that differs from base owner + const forkNode = { + ...graphqlPRNode, + databaseId: 800, + headRepository: { + owner: { login: "fork-user" }, + nameWithOwner: "fork-user/some-repo", + }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + } as unknown as typeof graphqlPRNode; - it("selects workflows with most recent activity first", async () => { - const octokit = makeOctokitForRuns(); + let callCount = 0; + const octokit = makePROctokit(async () => { + callCount++; + if (callCount <= 2) { + // First 2 calls: involves + review-requested search queries + return makeGraphqlPRResponse([forkNode]); + } + // 3rd call: fork fallback query — throw to simulate failure + throw new Error("Fork repo not accessible"); + }); - const { workflowRuns } = await fetchWorkflowRuns( + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], - 5, - 10 + "octocat" ); - // First run in results should be from the workflow with the most recent updatedAt - // Workflow 1001 latestAt=2024-01-15T10:05:00Z > Workflow 1002 latestAt=2024-01-15T09:25:00Z - expect(workflowRuns[0].workflowId).toBe(1001); + expect(pullRequests.length).toBe(1); + expect(pullRequests[0].checkStatus).toBeNull(); // fallback failed, stays null + expect(pushNotification).toHaveBeenCalledWith( + "graphql", + expect.stringContaining("Fork PR check status unavailable"), + "warning" + ); }); -}); -// ── searchAllPages pagination ───────────────────────────────────────────────── + it("recovers partial data from fork fallback GraphqlResponseError", async () => { + vi.mocked(pushNotification).mockClear(); -describe("searchAllPages (via fetchIssues)", () => { - // Build a search octokit whose response depends on the `page` param - function makePaginatingOctokit(totalCount: number, pageOneItems: number, pageTwoItems: number) { - let callCount = 0; - return { - request: vi.fn(async (_route: string, params?: Record) => { - callCount++; - const page = (params?.page as number) ?? 1; - const items = Array.from({ length: page === 1 ? pageOneItems : pageTwoItems }, (_, i) => ({ - id: (page - 1) * 100 + i + 1, - number: (page - 1) * 100 + i + 1, - title: `Issue ${(page - 1) * 100 + i + 1}`, - state: "open", - html_url: `https://github.com/org/repo/issues/${(page - 1) * 100 + i + 1}`, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - user: { login: "octocat", avatar_url: "https://github.com/images/error/octocat_happy.gif" }, - labels: [], - assignees: [], - repository_url: "https://api.github.com/repos/org/repo", - })); - return { - data: { - total_count: totalCount, - incomplete_results: false, - items, - }, - headers: {}, - }; - }), - paginate: { iterator: vi.fn() }, - }; - } + // Two fork PRs: one resolves in partial data, one doesn't + const forkNodes = [ + { + ...graphqlPRNode, databaseId: 901, + headRepository: { owner: { login: "fork-a" }, nameWithOwner: "fork-a/repo" }, + repository: { nameWithOwner: "octocat/Hello-World" }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + }, + { + ...graphqlPRNode, databaseId: 902, + headRepository: { owner: { login: "fork-b" }, nameWithOwner: "fork-b/repo" }, + repository: { nameWithOwner: "octocat/Hello-World" }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + }, + ] as unknown as (typeof graphqlPRNode)[]; - it("fetches both pages when total_count > 100 items", async () => { - // total_count: 150, page 1 returns 100 items, page 2 returns 50 - const octokit = makePaginatingOctokit(150, 100, 50); - const repos: RepoRef[] = [{ owner: "org", name: "repo", fullName: "org/repo" }]; + let graphqlCallCount = 0; + const octokit = makePROctokit(async () => { + graphqlCallCount++; + if (graphqlCallCount <= 2) { + return makeGraphqlPRResponse(forkNodes); + } + // Fork fallback: GraphqlResponseError with partial data — fork0 resolves, fork1 doesn't + throw Object.assign(new Error("Partial fork resolution failure"), { + data: { + fork0: { object: { statusCheckRollup: { state: "SUCCESS" } } }, + // fork1 is missing — that fork repo was deleted/inaccessible + rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }, + }); + }); - const result = await fetchIssues( + const { pullRequests } = await fetchPullRequests( octokit as unknown as ReturnType, - repos, + [testRepo], "octocat" ); - // All 150 items should be returned (100 + 50) - expect(result.issues.length).toBe(150); - // Two pages should have been fetched - const searchCalls = octokit.request.mock.calls.filter( - (c) => c[0] === "GET /search/issues" + const pr901 = pullRequests.find((pr) => pr.id === 901); + const pr902 = pullRequests.find((pr) => pr.id === 902); + // fork0 resolved from partial data + expect(pr901?.checkStatus).toBe("success"); + // fork1 not in partial data — stays null + expect(pr902?.checkStatus).toBeNull(); + // Notification still fires for the partial failure + expect(pushNotification).toHaveBeenCalledWith( + "graphql", + expect.stringContaining("Fork PR check status unavailable"), + "warning" ); - expect(searchCalls.length).toBe(2); }); - it("caps at 1000 items and warns when total_count > 1000", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - // Return 100 items per page, total_count = 2000 - let callCount = 0; - const octokit = { - request: vi.fn(async (_route: string) => { - callCount++; - const items = Array.from({ length: 100 }, (_, i) => ({ - id: (callCount - 1) * 100 + i + 1, - number: (callCount - 1) * 100 + i + 1, - title: `Issue ${(callCount - 1) * 100 + i + 1}`, - state: "open", - html_url: "https://github.com/org/repo/issues/1", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - user: { login: "octocat", avatar_url: "https://github.com/images/error/octocat_happy.gif" }, - labels: [], - assignees: [], - repository_url: "https://api.github.com/repos/org/repo", - })); - return { - data: { total_count: 2000, incomplete_results: false, items }, - headers: {}, - }; - }), - paginate: { iterator: vi.fn() }, - }; + it("returns partial results and an error when second page throws mid-pagination", async () => { + vi.mocked(pushNotification).mockClear(); + + // Track calls per query type: involves query fires first, then review-requested + // Each query type paginates independently. We want the involves query to: + // page 1: success with hasNextPage=true + // page 2: throw an error + // The review-requested query should succeed normally. + let involvesCallCount = 0; + const octokit = makePROctokit(async (_query, variables) => { + const q = (variables as Record).q as string; + if (q.includes("involves:")) { + involvesCallCount++; + if (involvesCallCount === 1) { + // First page succeeds with more pages available + return makeGraphqlPRResponse([{ ...graphqlPRNode, databaseId: 101 }], true); + } + // Second page throws + throw new Error("GraphQL connection error"); + } + // review-requested query returns empty — avoid duplicate databaseId confusion + return makeGraphqlPRResponse([]); + }); - const repos: RepoRef[] = [{ owner: "org", name: "repo", fullName: "org/repo" }]; - const result = await fetchIssues( + const result = await fetchPullRequests( octokit as unknown as ReturnType, - repos, + [testRepo], "octocat" ); - // Should be capped at 1000 items (10 pages × 100) - expect(result.issues.length).toBe(1000); - // Should warn about the cap - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("capped at 1000") - ); + // PR from the first page is returned + expect(result.pullRequests.some((pr) => pr.id === 101)).toBe(true); + + // An ApiError is included in the result's errors array + expect(result.errors.length).toBe(1); + expect(result.errors[0].message).toContain("GraphQL connection error"); + expect(result.errors[0].repo).toBe("pr-search-batch-1/1"); - warnSpy.mockRestore(); + // pushNotification is NOT called (no 1000-item cap was reached) + expect(pushNotification).not.toHaveBeenCalled(); }); - it("warns on incomplete_results: true", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + it("extracts partial PR data from GraphqlResponseError and stops pagination", async () => { + vi.mocked(pushNotification).mockClear(); - const octokit = { - request: vi.fn(async () => ({ - data: { - total_count: 2, - incomplete_results: true, - items: [searchIssuesFixture.items[0]], + const partialError = Object.assign(new Error("Partial node resolution failure"), { + data: { + search: { + issueCount: 3, + pageInfo: { hasNextPage: true, endCursor: "cursor-partial" }, + nodes: [{ ...graphqlPRNode, databaseId: 77 }], }, - headers: {}, - })), - paginate: { iterator: vi.fn() }, - }; + rateLimit: { remaining: 4990, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }, + }); + const octokit = makePROctokit(async (_query, variables) => { + const q = (variables as Record).q as string; + if (q.includes("involves:")) throw partialError; + return makeGraphqlPRResponse([]); + }); - const repos: RepoRef[] = [{ owner: "octocat", name: "Hello-World", fullName: "octocat/Hello-World" }]; - await fetchIssues( + const result = await fetchPullRequests( octokit as unknown as ReturnType, - repos, + [testRepo], "octocat" ); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("incomplete") + expect(result.pullRequests.length).toBe(1); + expect(result.pullRequests[0].id).toBe(77); + expect(result.errors.length).toBe(1); + expect(result.errors[0].message).toContain("Partial node resolution failure"); + // involves query: 1 call (threw partial, stopped). review-requested: 1 call = 2 total + expect(octokit.graphql).toHaveBeenCalledTimes(2); + }); + + it("catches unexpected PR response shapes without crashing", async () => { + const octokit = makePROctokit(async () => ({ + search: { issueCount: 0, pageInfo: null, nodes: null }, + rateLimit: null, + })); + + const result = await fetchPullRequests( + octokit as unknown as ReturnType, + [testRepo], + "octocat" ); - warnSpy.mockRestore(); + expect(result.pullRequests).toEqual([]); + expect(result.errors.length).toBeGreaterThanOrEqual(1); + expect(result.errors[0].retryable).toBe(false); }); -}); - -// ── GraphQL ERROR and EXPECTED state mapping ────────────────────────────────── -describe("batchFetchCheckStatuses state mapping (via fetchPullRequests)", () => { - function makeOctokitWithGraphQL(graphqlResponse: Record) { - return { - request: vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { data: searchPrsFixture, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: prsFixture[0], headers: { etag: "etag-pr-detail" } }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }), - graphql: vi.fn(async () => graphqlResponse), - paginate: { iterator: vi.fn() }, - }; - } + it("caps at 1000 PRs and warns via pushNotification", async () => { + vi.mocked(pushNotification).mockClear(); - it('maps state "ERROR" to "failure"', async () => { - const octokit = makeOctokitWithGraphQL({ - pr0: { object: { statusCheckRollup: { state: "ERROR" } }, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }, + const manyNodes = Array.from({ length: 1000 }, (_, i) => ({ ...graphqlPRNode, databaseId: i + 1 })); + const octokit = makePROctokit(async (_query, variables) => { + const q = (variables as Record).q as string; + if (q.includes("involves:")) { + return makeGraphqlPRResponse(manyNodes, true, 1500); + } + return makeGraphqlPRResponse([]); }); - const { pullRequests } = await fetchPullRequests( + const result = await fetchPullRequests( octokit as unknown as ReturnType, [testRepo], "octocat" ); - expect(pullRequests[0].checkStatus).toBe("failure"); + expect(result.pullRequests.length).toBe(1000); + expect(pushNotification).toHaveBeenCalledWith( + "search/prs", + expect.stringContaining("capped at 1,000"), + "warning" + ); }); - it('maps state "EXPECTED" to "pending"', async () => { - await clearCache(); - const octokit = makeOctokitWithGraphQL({ - pr0: { object: { statusCheckRollup: { state: "EXPECTED" } }, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }, + it("fork fallback handles >50 fork PRs across multiple batches", async () => { + // Create 55 fork PRs — should split into 50 + 5 fork fallback batches + const forkNodes = Array.from({ length: 55 }, (_, i) => ({ + ...graphqlPRNode, + databaseId: 2000 + i, + headRepository: { owner: { login: "fork-owner" }, nameWithOwner: `fork-owner/repo-${i}` }, + repository: { nameWithOwner: "octocat/Hello-World" }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + })) as unknown as (typeof graphqlPRNode)[]; + + let graphqlCallCount = 0; + const octokit = makePROctokit(async (_query, variables) => { + graphqlCallCount++; + const q = (variables as Record).q as string | undefined; + if (q) { + // Primary search queries return all 55 fork PRs (involves only) + if (q.includes("involves:")) return makeGraphqlPRResponse(forkNodes); + return makeGraphqlPRResponse([]); + } + // Fork fallback queries + const response: Record = { + rateLimit: { remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() }, + }; + const indices = Object.keys(variables as Record) + .filter((k) => k.startsWith("owner")) + .map((k) => parseInt(k.replace("owner", ""), 10)); + for (const i of indices) { + response[`fork${i}`] = { object: { statusCheckRollup: { state: "SUCCESS" } } }; + } + return response; }); const { pullRequests } = await fetchPullRequests( @@ -966,307 +1055,249 @@ describe("batchFetchCheckStatuses state mapping (via fetchPullRequests)", () => "octocat" ); - expect(pullRequests[0].checkStatus).toBe("pending"); + // All 55 PRs should have check status resolved from fork fallback + const forkPRs = pullRequests.filter((pr) => pr.id >= 2000 && pr.id < 2055); + expect(forkPRs.length).toBe(55); + for (const pr of forkPRs) { + expect(pr.checkStatus).toBe("success"); + } }); - it("maps statusCheckRollup: null (object exists but no rollup) to null", async () => { - await clearCache(); - const octokit = makeOctokitWithGraphQL({ - pr0: { object: { statusCheckRollup: null }, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }, - }); - - const { pullRequests } = await fetchPullRequests( + it("returns empty result when repos is empty", async () => { + const octokit = makePROctokit(); + const result = await fetchPullRequests( octokit as unknown as ReturnType, - [testRepo], + [], "octocat" ); + expect(result.pullRequests).toEqual([]); + expect(result.errors).toEqual([]); + expect(octokit.graphql).not.toHaveBeenCalled(); + }); - expect(pullRequests[0].checkStatus).toBeNull(); + it("throws when octokit is null", async () => { + await expect( + fetchPullRequests(null, [testRepo], "octocat") + ).rejects.toThrow("No GitHub client available"); }); }); -// ── Partial batch failure ───────────────────────────────────────────────────── +// ── fetchWorkflowRuns (single endpoint per repo) ──────────────────────────── -describe("batchedSearch partial batch failure (via fetchIssues)", () => { - it("returns items from successful chunks and errors from failed chunks", async () => { - // 35 repos → 2 chunks (30 + 5). First chunk rejects, second succeeds. - let callCount = 0; - const octokit = { - request: vi.fn(async () => { - callCount++; - if (callCount === 1) { - throw Object.assign(new Error("search timeout"), { status: 503 }); - } +describe("fetchWorkflowRuns", () => { + function makeOctokitForRuns() { + const request = vi.fn(async (route: string) => { + if (route === "GET /repos/{owner}/{repo}/actions/runs") { return { data: { - total_count: 1, - incomplete_results: false, - items: [searchIssuesFixture.items[0]], + workflow_runs: runsFixture.runs, + total_count: runsFixture.runs.length, }, - headers: {}, + headers: { etag: "etag-runs" }, }; - }), - paginate: { iterator: vi.fn() }, - }; + } + return { data: [], headers: {} }; + }); + return { request, paginate: { iterator: vi.fn() } }; + } - const repos: RepoRef[] = Array.from({ length: 35 }, (_, i) => ({ - owner: "org", - name: `repo-${i}`, - fullName: `org/repo-${i}`, - })); + it("returns runs grouped by workflow", async () => { + const octokit = makeOctokitForRuns(); - const result = await fetchIssues( + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, - repos, - "octocat" + [testRepo], + 5, + 3 ); - // Items from the successful chunk (chunk 2) should be present - expect(result.issues.length).toBeGreaterThan(0); - // Error from the failed chunk (chunk 1) should be reported - expect(result.errors.length).toBe(1); - expect(result.errors[0].repo).toContain("search-batch-1/2"); - // Total items less than if both succeeded - expect(result.issues.length).toBeLessThan(35); + expect(Array.isArray(workflowRuns)).toBe(true); + expect(workflowRuns.length).toBeGreaterThan(0); }); -}); -// ── GraphQL batch boundary (51 PRs → 2 chunks) ─────────────────────────────── - -describe("batchFetchCheckStatuses with 51 PRs (2 GraphQL chunks)", () => { - it("calls GraphQL twice and maps all 51 results correctly", async () => { - // Build 51 unique search items (as PRs) - const prSearchItems = Array.from({ length: 51 }, (_, i) => ({ - id: 2000 + i, - number: 100 + i, - title: `PR ${i}`, - state: "open", - html_url: `https://github.com/octocat/Hello-World/pull/${100 + i}`, - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - user: { login: "octocat", avatar_url: "https://github.com/images/error/octocat_happy.gif" }, - labels: [], - assignees: [], - repository_url: "https://api.github.com/repos/octocat/Hello-World", - pull_request: { url: `https://api.github.com/repos/octocat/Hello-World/pulls/${100 + i}` }, - })); - - const prDetail = { - ...prsFixture[0], - }; + it("uses single actions/runs endpoint per repo", async () => { + const octokit = makeOctokitForRuns(); - let graphqlCallCount = 0; + await fetchWorkflowRuns( + octokit as unknown as ReturnType, + [testRepo], + 5, + 3 + ); - const octokit = { - request: vi.fn(async (route: string, params?: Record) => { - if (route === "GET /search/issues") { - return { - data: { - total_count: 51, - incomplete_results: false, - items: prSearchItems, - }, - headers: {}, - }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - const num = (params?.pull_number as number) ?? 100; - return { - data: { - ...prDetail, - id: 2000 + (num - 100), - number: num, - head: { - ...prDetail.head, - sha: `sha-${num}`, - }, - }, - headers: { etag: `etag-pr-${num}` }, - }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }), - graphql: vi.fn(async (_query: string, variables: Record) => { - graphqlCallCount++; - // Build a response with all prN keys present in this chunk - const response: Record = {}; - // Count how many prN aliases are in variables - const indices = Object.keys(variables) - .filter((k) => k.startsWith("owner")) - .map((k) => parseInt(k.replace("owner", ""), 10)); - for (const i of indices) { - response[`pr${i}`] = { object: { statusCheckRollup: { state: "SUCCESS" } }, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }; - } - return response; - }), - paginate: { iterator: vi.fn() }, - }; + // Should make exactly 1 API call (not separate workflows + per-workflow runs) + expect(octokit.request).toHaveBeenCalledTimes(1); + expect(octokit.request).toHaveBeenCalledWith( + "GET /repos/{owner}/{repo}/actions/runs", + expect.objectContaining({ + owner: "octocat", + repo: "Hello-World", + }) + ); + }); - const { pullRequests } = await fetchPullRequests( + it("respects maxRuns per workflow", async () => { + const octokit = makeOctokitForRuns(); + + const maxWorkflows = 3; + const maxRuns = 1; + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, [testRepo], - "octocat" + maxWorkflows, + maxRuns ); - // GraphQL should have been called twice (50 + 1) - expect(graphqlCallCount).toBe(2); - // All 51 PRs should be returned - expect(pullRequests.length).toBe(51); - // All should have success check status - for (const pr of pullRequests) { - expect(pr.checkStatus).toBe("success"); + // Group result by workflowId and check each has at most maxRuns + const byWorkflow = new Map(); + for (const run of workflowRuns) { + byWorkflow.set(run.workflowId, (byWorkflow.get(run.workflowId) ?? 0) + 1); + } + for (const count of byWorkflow.values()) { + expect(count).toBeLessThanOrEqual(maxRuns); } }); -}); -// ── qa-9: REST fallback CHANGES_REQUESTED and REVIEW_REQUIRED branches ──────── + it("respects maxWorkflows limit", async () => { + const octokit = makeOctokitForRuns(); -describe("REST fallback review decision (via fetchPullRequests)", () => { - function makeOctokitWithRestFallback(reviews: { user: { login: string } | null; state: string }[]) { - const request = vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { data: searchPrsFixture, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: prsFixture[0], headers: { etag: "etag-pr-detail" } }; - } - if (route === "GET /repos/{owner}/{repo}/commits/{ref}/status") { - return { data: { state: "success", total_count: 1 }, headers: { etag: "etag-status" } }; - } - if (route === "GET /repos/{owner}/{repo}/commits/{ref}/check-runs") { - // Return empty check runs so we don't interfere with review decision testing - return { data: { check_runs: [] }, headers: { etag: "etag-check-runs" } }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews") { - return { data: reviews, headers: { etag: "etag-reviews" } }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }); - // GraphQL always fails to force REST fallback - const graphql = vi.fn(async () => { - throw new Error("GraphQL unavailable"); - }); - return { request, graphql, paginate: { iterator: vi.fn() } }; - } + const maxWorkflows = 1; + const { workflowRuns } = await fetchWorkflowRuns( + octokit as unknown as ReturnType, + [testRepo], + maxWorkflows, + 10 + ); - it("REST fallback: single CHANGES_REQUESTED review → reviewDecision === CHANGES_REQUESTED", async () => { - await clearCache(); - const reviews = [ - { user: { login: "reviewer1" }, state: "CHANGES_REQUESTED" }, - ]; - const octokit = makeOctokitWithRestFallback(reviews); + // All runs should be from a single workflow + const workflowIds = new Set(workflowRuns.map((r) => r.workflowId)); + expect(workflowIds.size).toBeLessThanOrEqual(maxWorkflows); + }); - const { pullRequests } = await fetchPullRequests( + it("tags PR-triggered runs with isPrRun=true", async () => { + const octokit = makeOctokitForRuns(); + + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, [testRepo], - "octocat" + 5, + 10 ); - expect(pullRequests[0].reviewDecision).toBe("CHANGES_REQUESTED"); + // Run 9003 has event: "pull_request" + const prRun = workflowRuns.find((r) => r.id === 9003); + expect(prRun).toBeDefined(); + expect(prRun!.isPrRun).toBe(true); + + // Run 9001 has event: "push" + const pushRun = workflowRuns.find((r) => r.id === 9001); + expect(pushRun).toBeDefined(); + expect(pushRun!.isPrRun).toBe(false); }); - it("REST fallback: CHANGES_REQUESTED wins over APPROVED from another reviewer", async () => { - await clearCache(); - const reviews = [ - { user: { login: "reviewer1" }, state: "APPROVED" }, - { user: { login: "reviewer2" }, state: "CHANGES_REQUESTED" }, - ]; - const octokit = makeOctokitWithRestFallback(reviews); + it("maps raw run fields to camelCase shape", async () => { + const octokit = makeOctokitForRuns(); - const { pullRequests } = await fetchPullRequests( + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, [testRepo], - "octocat" + 5, + 3 ); - expect(pullRequests[0].reviewDecision).toBe("CHANGES_REQUESTED"); + const run = workflowRuns[0]; + expect(run).toMatchObject({ + id: expect.any(Number), + name: expect.any(String), + status: expect.any(String), + event: expect.any(String), + workflowId: expect.any(Number), + headSha: expect.any(String), + headBranch: expect.any(String), + runNumber: expect.any(Number), + htmlUrl: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + repoFullName: expect.any(String), + isPrRun: expect.any(Boolean), + }); }); - it("REST fallback: mix of COMMENTED reviews (no APPROVED or CHANGES_REQUESTED) → REVIEW_REQUIRED", async () => { - await clearCache(); - const reviews = [ - { user: { login: "reviewer1" }, state: "COMMENTED" }, - { user: { login: "reviewer2" }, state: "COMMENTED" }, - ]; - const octokit = makeOctokitWithRestFallback(reviews); + it("sorts workflows by most recent activity descending", async () => { + const octokit = makeOctokitForRuns(); - const { pullRequests } = await fetchPullRequests( + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, [testRepo], - "octocat" + 5, + 10 ); - expect(pullRequests[0].reviewDecision).toBe("REVIEW_REQUIRED"); + // CI workflow (id 1001) has latestAt 2024-01-15T10:05 (from run 9002) + // Deploy workflow (id 1002) has latestAt 2024-01-15T09:25 (from run 9004) + // CI should appear first (more recent) + const firstCiIndex = workflowRuns.findIndex((r) => r.workflowId === 1001); + const firstDeployIndex = workflowRuns.findIndex((r) => r.workflowId === 1002); + expect(firstCiIndex).toBeLessThan(firstDeployIndex); }); - it("REST fallback: APPROVED from reviewer + COMMENTED from another → REVIEW_REQUIRED (not all approved)", async () => { - await clearCache(); - const reviews = [ - { user: { login: "reviewer1" }, state: "APPROVED" }, - { user: { login: "reviewer2" }, state: "COMMENTED" }, - ]; - const octokit = makeOctokitWithRestFallback(reviews); + it("sorts runs within a workflow by created_at descending", async () => { + const octokit = makeOctokitForRuns(); - const { pullRequests } = await fetchPullRequests( + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, [testRepo], - "octocat" + 5, + 10 ); - expect(pullRequests[0].reviewDecision).toBe("REVIEW_REQUIRED"); + // CI runs: 9002 (10:00), 9001 (09:00), 9003 (Jan 14 15:00) — descending by created_at + const ciRuns = workflowRuns.filter((r) => r.workflowId === 1001); + expect(ciRuns[0].id).toBe(9002); + expect(ciRuns[1].id).toBe(9001); + expect(ciRuns[2].id).toBe(9003); + }); + + it("throws when octokit is null", async () => { + await expect( + fetchWorkflowRuns(null, [testRepo], 5, 3) + ).rejects.toThrow("No GitHub client available"); }); - it("REST fallback: no reviews → reviewDecision === null", async () => { - await clearCache(); - const octokit = makeOctokitWithRestFallback([]); + it("returns runs sorted newest-first within each workflow", async () => { + const octokit = makeOctokitForRuns(); - const { pullRequests } = await fetchPullRequests( + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, [testRepo], - "octocat" + 5, + 10 ); - expect(pullRequests[0].reviewDecision).toBeNull(); + // Workflow 1001 has 3 runs: 9002 (10:00), 9001 (09:00), 9003 (14:15:00 prev day) + const w1001Runs = workflowRuns.filter((r) => r.workflowId === 1001); + for (let i = 1; i < w1001Runs.length; i++) { + expect(w1001Runs[i - 1].createdAt >= w1001Runs[i].createdAt).toBe(true); + } }); -}); -// ── REST fallback: no CI configured → checkStatus null ──────────────────────── - -describe("REST fallback no-CI detection (via fetchPullRequests)", () => { - it("REST fallback: no legacy statuses (total_count:0) and no check runs → checkStatus === null", async () => { - await clearCache(); - const request = vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { data: searchPrsFixture, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: prsFixture[0], headers: { etag: "etag-pr-detail" } }; - } - if (route === "GET /repos/{owner}/{repo}/commits/{ref}/status") { - return { data: { state: "pending", total_count: 0 }, headers: { etag: "etag-status" } }; - } - if (route === "GET /repos/{owner}/{repo}/commits/{ref}/check-runs") { - return { data: { check_runs: [] }, headers: { etag: "etag-check-runs" } }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews") { - return { data: [], headers: { etag: "etag-reviews" } }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }); - const graphql = vi.fn(async () => { throw new Error("GraphQL unavailable"); }); - const octokit = { request, graphql, paginate: { iterator: vi.fn() } }; + it("selects workflows with most recent activity first", async () => { + const octokit = makeOctokitForRuns(); - const { pullRequests } = await fetchPullRequests( + const { workflowRuns } = await fetchWorkflowRuns( octokit as unknown as ReturnType, [testRepo], - "octocat" + 5, + 10 ); - expect(pullRequests.length).toBe(1); - expect(pullRequests[0].checkStatus).toBeNull(); + // First run in results should be from the workflow with the most recent updatedAt + // Workflow 1001 latestAt=2024-01-15T10:05:00Z > Workflow 1002 latestAt=2024-01-15T09:25:00Z + expect(workflowRuns[0].workflowId).toBe(1001); }); }); + // ── qa-10: Empty userLogin short-circuit ────────────────────────────────────── describe("empty userLogin short-circuit", () => { @@ -1392,260 +1423,3 @@ describe("fetchWorkflowRuns pagination", () => { expect(requestCount).toBe(1); }); }); - -// ── qa-1: Fork PR path in GraphQL batch ─────────────────────────────────────── - -describe("batchFetchCheckStatuses fork PR path (via fetchPullRequests)", () => { - it("queries statusCheckRollup from base repo for fork PRs", async () => { - await clearCache(); - - // Search returns a PR whose base repo is "octocat/Hello-World" - const forkSearchData = { - total_count: 1, - incomplete_results: false, - items: [ - { - id: 3001, - number: 42, - title: "Fork PR", - state: "open", - html_url: "https://github.com/octocat/Hello-World/pull/42", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - user: { login: "fork-user", avatar_url: "https://github.com/images/error/octocat_happy.gif" }, - labels: [], - assignees: [], - repository_url: "https://api.github.com/repos/octocat/Hello-World", - pull_request: { url: "https://api.github.com/repos/octocat/Hello-World/pulls/42" }, - draft: false, - }, - ], - }; - - // PR detail: head.repo.full_name is the fork, NOT the base repo - const forkPrDetail = { - ...prsFixture[0], - id: 3001, - number: 42, - head: { - sha: "forksha1234567890abcdef1234567890abcdef12", - ref: "feat/fork-branch", - repo: { - full_name: "fork-user/Hello-World", // fork! - }, - }, - }; - - let capturedQuery = ""; - const graphql = vi.fn(async (query: string, variables: Record) => { - capturedQuery = query; - // Base repo alias includes both statusCheckRollup and pullRequest data - const response: Record = {}; - const indices = Object.keys(variables) - .filter((k) => k.startsWith("owner")) - .map((k) => parseInt(k.replace("owner", ""), 10)); - for (const i of indices) { - response[`pr${i}`] = { - object: { - statusCheckRollup: { state: "SUCCESS" }, - }, - pullRequest: { - reviewDecision: "APPROVED", - latestReviews: { totalCount: 1, nodes: [{ author: { login: "reviewer1" } }] }, - }, - }; - } - return response; - }); - - const request = vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { data: forkSearchData, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: forkPrDetail, headers: { etag: "etag-fork-pr" } }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }); - - const octokit = { request, graphql, paginate: { iterator: vi.fn() } }; - - const { pullRequests } = await fetchPullRequests( - octokit as unknown as ReturnType, - [testRepo], - "octocat" - ); - - // GraphQL should NOT have a prHead0 alias — base repo has the check suites - expect(capturedQuery).not.toContain("prHead0"); - // No headOwner/headRepo variables needed - expect(graphql).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - owner0: "octocat", - repo0: "Hello-World", - }) - ); - // checkStatus comes from the base repo's statusCheckRollup - expect(pullRequests).toHaveLength(1); - expect(pullRequests[0].checkStatus).toBe("success"); - expect(pullRequests[0].reviewDecision).toBe("APPROVED"); - }); -}); - -// ── qa-2: Deleted fork (null head.repo) ─────────────────────────────────────── - -describe("fetchPullRequests with null head.repo (deleted fork)", () => { - it("returns the PR without error and uses base repo full_name as fallback", async () => { - await clearCache(); - - const deletedForkPrDetail = { - ...prsFixture[0], - id: 4001, - number: 55, - head: { - sha: "deadbeef1234567890abcdef1234567890abcdef", - ref: "feat/deleted-fork", - repo: null, // deleted fork - }, - }; - - const deletedForkSearchData = { - total_count: 1, - incomplete_results: false, - items: [ - { - id: 4001, - number: 55, - title: "Deleted Fork PR", - state: "open", - html_url: "https://github.com/octocat/Hello-World/pull/55", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - user: { login: "gone-user", avatar_url: "https://github.com/images/error/octocat_happy.gif" }, - labels: [], - assignees: [], - repository_url: "https://api.github.com/repos/octocat/Hello-World", - pull_request: { url: "https://api.github.com/repos/octocat/Hello-World/pulls/55" }, - draft: false, - }, - ], - }; - - const graphql = vi.fn(async (_query: string, variables: Record) => { - const response: Record = {}; - const indices = Object.keys(variables) - .filter((k) => k.startsWith("owner")) - .map((k) => parseInt(k.replace("owner", ""), 10)); - for (const i of indices) { - response[`pr${i}`] = { - object: { statusCheckRollup: { state: "SUCCESS" } }, - pullRequest: { - reviewDecision: null, - latestReviews: { totalCount: 0, nodes: [] }, - }, - }; - } - return response; - }); - - const request = vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { data: deletedForkSearchData, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: deletedForkPrDetail, headers: { etag: "etag-deleted-fork" } }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }); - - const octokit = { request, graphql, paginate: { iterator: vi.fn() } }; - - const { pullRequests, errors } = await fetchPullRequests( - octokit as unknown as ReturnType, - [testRepo], - "octocat" - ); - - // Must not error out - expect(errors.filter((e) => e.repo === "pr-detail")).toHaveLength(0); - // PR must be returned - expect(pullRequests).toHaveLength(1); - // Base repo full_name is used as fallback for head.repo.full_name - expect(pullRequests[0].repoFullName).toBe("octocat/Hello-World"); - }); -}); - -// ── Query-aware search mock verification ────────────────────────────────────── - -describe("search query qualifiers", () => { - it("fetchIssues sends is:issue qualifier (not is:pr)", async () => { - const octokit = { - request: vi.fn(async (route: string, params?: Record) => { - if (route === "GET /search/issues") { - const q = (params?.q as string) ?? ""; - // Return matching items only for issue queries so we can verify - return { - data: { - total_count: q.includes("is:issue") ? 1 : 0, - incomplete_results: false, - items: q.includes("is:issue") ? [searchIssuesFixture.items[0]] : [], - }, - headers: {}, - }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }), - paginate: { iterator: vi.fn() }, - }; - - await fetchIssues( - octokit as unknown as ReturnType, - [testRepo], - "octocat" - ); - - const calls = octokit.request.mock.calls.filter((c) => c[0] === "GET /search/issues"); - expect(calls.length).toBeGreaterThan(0); - for (const call of calls) { - const q = ((call as unknown[])[1] as { q: string }).q; - expect(q).toContain("is:issue"); - expect(q).not.toContain("is:pr"); - expect(q).toContain("repo:octocat/Hello-World"); - } - }); - - it("fetchPullRequests sends is:pr qualifier (not is:issue)", async () => { - const octokit = { - request: vi.fn(async (route: string) => { - if (route === "GET /search/issues") { - return { data: searchPrsFixture, headers: {} }; - } - if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { - return { data: prsFixture[0], headers: { etag: "etag-pr-detail" } }; - } - return { data: { total_count: 0, incomplete_results: false, items: [] }, headers: {} }; - }), - graphql: vi.fn(async () => ({ - pr0: { object: { statusCheckRollup: { state: "SUCCESS" } }, pullRequest: { reviewDecision: null, latestReviews: { totalCount: 0, nodes: [] } } }, - })), - paginate: { iterator: vi.fn() }, - }; - - await fetchPullRequests( - octokit as unknown as ReturnType, - [testRepo], - "octocat" - ); - - const searchCalls = octokit.request.mock.calls.filter( - (c) => c[0] === "GET /search/issues" - ); - expect(searchCalls.length).toBe(2); - for (const call of searchCalls) { - const q = ((call as unknown[])[1] as { q: string }).q; - expect(q).toContain("is:pr"); - expect(q).toContain("repo:octocat/Hello-World"); - } - }); -}); diff --git a/tests/services/github.test.ts b/tests/services/github.test.ts index a31b6d6..412eabb 100644 --- a/tests/services/github.test.ts +++ b/tests/services/github.test.ts @@ -1,7 +1,7 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { createRoot } from "solid-js"; -import { createGitHubClient, cachedRequest, getRateLimit, getClient, initClientWatcher } from "../../src/app/services/github"; +import { createGitHubClient, cachedRequest, getClient, initClientWatcher, getGraphqlRateLimit, updateGraphqlRateLimit } from "../../src/app/services/github"; import { clearCache } from "../../src/app/stores/cache"; // ── createGitHubClient ─────────────────────────────────────────────────────── @@ -257,49 +257,6 @@ describe("cachedRequest", () => { }); }); -// ── getRateLimit ───────────────────────────────────────────────────────────── - -describe("getRateLimit", () => { - beforeEach(async () => { - await clearCache(); - vi.resetAllMocks(); - }); - - it("returns null before any requests", () => { - // Note: rate limit signal is module-level and may be set from prior tests. - // This test just verifies the function is callable. - const rl = getRateLimit(); - // Either null or a valid object - expect(rl === null || (typeof rl === "object" && "remaining" in rl)).toBe(true); - }); - - it("returns rate limit info after a successful request", async () => { - const resetTs = Math.floor(Date.now() / 1000) + 3600; - const mockOctokit = { - request: vi.fn().mockResolvedValue({ - data: [], - headers: { - etag: "etag-rl", - "x-ratelimit-remaining": "3999", - "x-ratelimit-reset": String(resetTs), - }, - status: 200, - }), - }; - - await cachedRequest( - mockOctokit as unknown as ReturnType, - "test:ratelimit-update", - "GET /user/orgs", - {} - ); - - const rl = getRateLimit(); - expect(rl).not.toBeNull(); - expect(rl!.remaining).toBe(3999); - expect(rl!.resetAt).toBeInstanceOf(Date); - }); -}); // ── getClient / initClientWatcher ──────────────────────────────────────────── @@ -335,3 +292,31 @@ describe("getClient / initClientWatcher", () => { void authModule; }); }); + +// ── getGraphqlRateLimit / updateGraphqlRateLimit ───────────────────────────── + +describe("getGraphqlRateLimit", () => { + it("returns null before any update", () => { + // May be non-null if a prior test called updateGraphqlRateLimit; + // verify the function is callable and returns the expected shape + const rl = getGraphqlRateLimit(); + expect(rl === null || (typeof rl === "object" && "remaining" in rl)).toBe(true); + }); + + it("converts ISO 8601 resetAt string to Date", () => { + const iso = "2024-06-01T12:00:00Z"; + updateGraphqlRateLimit({ remaining: 4500, resetAt: iso }); + const rl = getGraphqlRateLimit(); + expect(rl).not.toBeNull(); + expect(rl!.remaining).toBe(4500); + expect(rl!.resetAt).toBeInstanceOf(Date); + expect(rl!.resetAt.getTime()).toBe(new Date(iso).getTime()); + }); + + it("overwrites previous value on subsequent updates", () => { + updateGraphqlRateLimit({ remaining: 5000, resetAt: "2024-06-01T12:00:00Z" }); + updateGraphqlRateLimit({ remaining: 3000, resetAt: "2024-06-01T13:00:00Z" }); + const rl = getGraphqlRateLimit(); + expect(rl!.remaining).toBe(3000); + }); +}); diff --git a/tests/services/poll-fetchAllData.test.ts b/tests/services/poll-fetchAllData.test.ts index afdc934..0d926b0 100644 --- a/tests/services/poll-fetchAllData.test.ts +++ b/tests/services/poll-fetchAllData.test.ts @@ -580,3 +580,48 @@ describe("fetchAllData — notification gate 403 auto-disable", () => { expect(fetchWorkflowRuns).toHaveBeenCalled(); }); }); + +// ── qa-4: Concurrency verification ──────────────────────────────────────────── + +describe("fetchAllData — parallel execution", () => { + it("initiates all three fetches before any resolves", async () => { + vi.resetModules(); + + const { getClient } = await import("../../src/app/services/github"); + const { fetchIssues, fetchPullRequests, fetchWorkflowRuns } = await import("../../src/app/services/api"); + const mockOctokit = makeMockOctokit(); + vi.mocked(getClient).mockReturnValue(mockOctokit as unknown as ReturnType); + + const callOrder: string[] = []; + const resolvers: Array<(v: unknown) => void> = []; + + // Each mock records when it's called but doesn't resolve until manually triggered + vi.mocked(fetchIssues).mockImplementation(() => { + callOrder.push("issues-start"); + return new Promise((resolve) => { resolvers.push(() => resolve(emptyIssueResult)); }); + }); + vi.mocked(fetchPullRequests).mockImplementation(() => { + callOrder.push("prs-start"); + return new Promise((resolve) => { resolvers.push(() => resolve(emptyPrResult)); }); + }); + vi.mocked(fetchWorkflowRuns).mockImplementation(() => { + callOrder.push("runs-start"); + return new Promise((resolve) => { resolvers.push(() => resolve(emptyRunResult)); }); + }); + + const { fetchAllData } = await import("../../src/app/services/poll"); + + const promise = fetchAllData(); + + // Yield to allow Promise.allSettled to initiate all three + await new Promise((r) => setTimeout(r, 0)); + + // All three should have been called BEFORE any resolved + expect(callOrder).toEqual(["issues-start", "prs-start", "runs-start"]); + expect(resolvers.length).toBe(3); + + // Now resolve all + for (const resolve of resolvers) resolve(undefined); + await promise; + }); +}); diff --git a/tests/stores/cache.test.ts b/tests/stores/cache.test.ts index 8545594..b45083b 100644 --- a/tests/stores/cache.test.ts +++ b/tests/stores/cache.test.ts @@ -7,7 +7,6 @@ import { deleteCacheEntry, clearCache, evictStaleEntries, - evictByPrefix, cachedFetch, } from "../../src/app/stores/cache"; @@ -203,64 +202,6 @@ describe("evictStaleEntries", () => { }); }); -// ── qa-8: evictByPrefix ────────────────────────────────────────────────────── - -describe("evictByPrefix", () => { - it("deletes prefix entries not in keepKeys, retains kept and non-prefix entries", async () => { - // Seed two "pr-detail:" entries and one non-prefixed entry - await setCacheEntry("pr-detail:octocat/Hello-World:42", { pr: 42 }, "etag-42"); - await setCacheEntry("pr-detail:octocat/Hello-World:99", { pr: 99 }, "etag-99"); - await setCacheEntry("other:key", { other: true }, "etag-other"); - - // Keep pr-detail:octocat/Hello-World:42 but evict :99 - const keepKeys = new Set(["pr-detail:octocat/Hello-World:42"]); - const count = await evictByPrefix("pr-detail:", keepKeys); - - // Only :99 should have been evicted - expect(count).toBe(1); - // Kept prefix entry must still exist - expect(await getCacheEntry("pr-detail:octocat/Hello-World:42")).toBeDefined(); - // Evicted prefix entry must be gone - expect(await getCacheEntry("pr-detail:octocat/Hello-World:99")).toBeUndefined(); - // Non-prefix entry must be untouched - expect(await getCacheEntry("other:key")).toBeDefined(); - }); - - it("evicts all prefix entries when keepKeys is empty", async () => { - await setCacheEntry("pr-detail:a/b:1", { pr: 1 }, null); - await setCacheEntry("pr-detail:a/b:2", { pr: 2 }, null); - await setCacheEntry("keep:this", { keep: true }, null); - - const count = await evictByPrefix("pr-detail:", new Set()); - - expect(count).toBe(2); - expect(await getCacheEntry("pr-detail:a/b:1")).toBeUndefined(); - expect(await getCacheEntry("pr-detail:a/b:2")).toBeUndefined(); - expect(await getCacheEntry("keep:this")).toBeDefined(); - }); - - it("returns 0 when no entries match the prefix", async () => { - await setCacheEntry("other:entry", { data: true }, null); - - const count = await evictByPrefix("pr-detail:", new Set()); - - expect(count).toBe(0); - expect(await getCacheEntry("other:entry")).toBeDefined(); - }); - - it("returns 0 when all prefix entries are in keepKeys", async () => { - await setCacheEntry("pr-detail:x/y:1", { pr: 1 }, null); - await setCacheEntry("pr-detail:x/y:2", { pr: 2 }, null); - - const keepKeys = new Set(["pr-detail:x/y:1", "pr-detail:x/y:2"]); - const count = await evictByPrefix("pr-detail:", keepKeys); - - expect(count).toBe(0); - expect(await getCacheEntry("pr-detail:x/y:1")).toBeDefined(); - expect(await getCacheEntry("pr-detail:x/y:2")).toBeDefined(); - }); -}); - describe("cachedFetch", () => { it("calls fetchFn with null headers when no cache entry exists", async () => { const fetchFn = vi.fn().mockResolvedValue({