/** * GraphQL Batch PR Enrichment * * Efficiently fetches data for multiple PRs using GraphQL aliases. * Reduces API calls from N×3 to 1 (or a few if batching needed). */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { execGhObserved } from "@aoagents/ao-core"; import type { BatchObserver, CICheck, CIStatus, PREnrichmentData, PRInfo, PRState, ReviewDecision, } from "@aoagents/ao-core"; import { LRUCache } from "./lru-cache.js"; let execFileAsync = promisify(execFile); let execGhAsync = async (args: string[], timeout: number, operation: string): Promise => execGhObserved(args, { component: "scm-github-batch", operation }, timeout); /** * Set execFileAsync for testing. * Allows mocking the underlying execFile in unit tests. */ export function setExecFileAsync(fn: typeof execFileAsync): void { execFileAsync = fn; execGhAsync = async (args: string[], timeout: number): Promise => { const { stdout } = await execFileAsync("gh", args, { maxBuffer: 10 * 1024 * 1024, timeout, }); return stdout.trim(); }; } /** * Configuration constants for cache sizing. * LRU cache automatically evicts oldest entries when these limits are reached. */ const MAX_PR_RESOURCE_ETAGS = 500; // Number of PRs to cache (per-PR resource ETags) const MAX_COMMIT_STATUS_ETAGS = 500; // Number of commits to cache const MAX_PR_METADATA = 200; // Number of PRs to cache full data /** * ETag cache for REST API endpoints. * Used to avoid expensive GraphQL queries when nothing has changed. * * Keys: * - PR resource: "owner/repo#number" * - Commit status: "owner/repo#sha" */ interface ETagCache { prResource: LRUCache; // Key: "owner/repo#number", Value: ETag commitStatus: LRUCache; // Key: "owner/repo#sha", Value: ETag } /** * Global ETag cache instance. * This is shared across all batch enrichment calls within the process lifecycle. * The cache persists between polling cycles to avoid redundant REST/GraphQL calls. * * Uses LRU eviction to ensure bounded memory usage. */ const etagCache: ETagCache = { prResource: new LRUCache(MAX_PR_RESOURCE_ETAGS), commitStatus: new LRUCache(MAX_COMMIT_STATUS_ETAGS), }; /** * Refresh plan returned by shouldRefreshPREnrichment. * Tells enrichSessionsPRBatch which PRs need GraphQL and which are cached. */ interface PREnrichmentRefreshPlan { prsToRefresh: PRInfo[]; cachedResults: Map; details: string[]; } /** * Clear all ETag cache entries. * Useful for testing or when forcing a refresh. */ export function clearETagCache(): void { etagCache.prResource.clear(); etagCache.commitStatus.clear(); } /** * Get PR resource ETag for a specific PR. */ export function getPRResourceETag(owner: string, repo: string, number: number): string | undefined { return etagCache.prResource.get(`${owner}/${repo}#${number}`); } /** * Get commit status ETag for a specific commit. */ export function getCommitStatusETag( owner: string, repo: string, sha: string, ): string | undefined { return etagCache.commitStatus.get(`${owner}/${repo}#${sha}`); } /** * Set PR resource ETag for a specific PR. * Exported for testing. */ export function setPRResourceETag(owner: string, repo: string, number: number, etag: string): void { etagCache.prResource.set(`${owner}/${repo}#${number}`, etag); } /** * Set commit status ETag for a specific commit. * Exported for testing. */ export function setCommitStatusETag( owner: string, repo: string, sha: string, etag: string, ): void { etagCache.commitStatus.set(`${owner}/${repo}#${sha}`, etag); } /** * Cache for PR metadata needed for ETag guard decisions. * Stores head SHA and CI status for each PR. * Key: "${owner}/${repo}#${number}" * * Uses LRU eviction to ensure bounded memory usage. */ const prMetadataCache = new LRUCache< string, { headSha: string | null; ciStatus: CIStatus } >(MAX_PR_METADATA); /** * Cache for full PR enrichment data. * Stores the complete PREnrichmentData object for each PR. * Used when ETag guard indicates no refresh is needed. * Key: "${owner}/${repo}#${number}" * * Uses LRU eviction to ensure bounded memory usage. */ const prEnrichmentDataCache = new LRUCache(MAX_PR_METADATA); /** * Update PR metadata cache with latest enrichment data. * Called after successful GraphQL batch enrichment. */ function updatePRMetadataCache( prKey: string, enrichment: PREnrichmentData, headSha: string | null, ): void { prMetadataCache.set(prKey, { headSha, ciStatus: enrichment.ciStatus, }); // Also cache the full enrichment data for ETag guard bypass prEnrichmentDataCache.set(prKey, enrichment); } /** * 2-Guard ETag Strategy: Build a per-PR refresh plan. * * For each PR, runs two lightweight REST ETag checks to decide whether it * needs a GraphQL refresh or can be served from cache: * * Guard 1: PR Resource ETag Check (per PR) * - Endpoint: GET /repos/{owner}/{repo}/pulls/{number} * - Detects: New commits, PR title/body edits, label changes, reviews, state changes * - 304 = unchanged, 200 = changed → refresh that PR * * Guard 2: Commit Status ETag Check (per PR, only when Guard 1 returns 304) * - Detects: CI check starts, passes, fails, or external status updates * - 304 = unchanged → serve from cache, 200 = changed → refresh that PR * * @param prs - PRs to check * @returns refresh plan: which PRs need GraphQL, which are cached */ export async function shouldRefreshPREnrichment( prs: PRInfo[], ): Promise { const details: string[] = []; const prsToRefresh: PRInfo[] = []; const cachedResults = new Map(); if (prs.length === 0) { return { prsToRefresh: [], cachedResults, details: ["No PRs to check"] }; } for (const pr of prs) { const prKey = `${pr.owner}/${pr.repo}#${pr.number}`; // Guard 1: PR resource ETag check const prChanged = await checkPRResourceETag(pr.owner, pr.repo, pr.number); if (prChanged) { prsToRefresh.push(pr); details.push(`PR resource changed for ${prKey} (Guard 1)`); continue; } // Guard 1 returned 304 — check if we have cached data to serve const cachedEnrichment = prEnrichmentDataCache.get(prKey); const cachedMeta = prMetadataCache.get(prKey); if (!cachedEnrichment || !cachedMeta || cachedMeta.headSha === null) { prsToRefresh.push(pr); details.push(`No cached data for ${prKey} (Guard 1: 304 but cache miss)`); continue; } // Guard 2: commit status ETag check const statusChanged = await checkCommitStatusETag( pr.owner, pr.repo, cachedMeta.headSha, ); if (statusChanged) { prsToRefresh.push(pr); details.push(`CI status changed for ${prKey} (Guard 2)`); } else { cachedResults.set(prKey, cachedEnrichment); } } return { prsToRefresh, cachedResults, details }; } /** * Get cached PR metadata for testing. */ export function getPRMetadataCache(): Map< string, { headSha: string | null; ciStatus: CIStatus } > { return prMetadataCache.toMap(); } /** * Get cached PR enrichment data for testing. */ export function getPREnrichmentDataCache(): Map { return prEnrichmentDataCache.toMap(); } /** * Set PR metadata for testing. */ export function setPRMetadata( key: string, metadata: { headSha: string | null; ciStatus: CIStatus }, ): void { prMetadataCache.set(key, metadata); } /** * Clear PR metadata cache for testing. */ export function clearPRMetadataCache(): void { prMetadataCache.clear(); prEnrichmentDataCache.clear(); } /** * Interface for errors with cause property (ES2022+). * Used for better error tracking when cause is not available in older environments. */ interface ErrorWithCause extends Error { cause?: unknown; } /** * Pre-flight check to verify gh CLI is available and authenticated. * This prevents silent failures during GraphQL batch queries. */ async function verifyGhCLI(): Promise { try { await execFileAsync("gh", ["--version"], { timeout: 5000 }); } catch { const error = new Error( "gh CLI not available or not authenticated. GraphQL batch enrichment requires gh CLI to be installed and configured.", ) as ErrorWithCause; error.cause = "GH_CLI_UNAVAILABLE"; throw error; } } /** * Maximum number of PRs to query in a single GraphQL batch. * GitHub has limits on query complexity and we stay well under this limit. */ export const MAX_BATCH_SIZE = 25; /** * Check if an HTTP response contains a 304 Not Modified status. * Handles HTTP/1.1, HTTP/2, and HTTP/2.0 status lines. */ function is304(output: string): boolean { return /HTTP\/[\d.]+ 304/i.test(output); } /** * Extract stdout/stderr from an execFile error object. * gh cli puts the HTTP response in stdout even on exit code 1 (e.g. 304). */ function extractErrorOutput(err: unknown): string | null { const e = err as { stdout?: unknown; stderr?: unknown }; const stdout = typeof e.stdout === "string" ? e.stdout : ""; const stderr = typeof e.stderr === "string" ? e.stderr : ""; const combined = stdout + stderr; return combined.length > 0 ? combined : null; } /** * Guard 1: PR Resource ETag Check (per PR) * * Detects if a specific PR has changed using REST ETag. * * - Endpoint: GET /repos/{owner}/{repo}/pulls/{number} * - Detects: New commits, PR title/body edits, label changes, reviews, PR state changes * - Misses: CI status changes (handled by Guard 2) * * @returns true if PR has changed (200 OK), false if unchanged (304 Not Modified) */ async function checkPRResourceETag( owner: string, repo: string, number: number, ): Promise { const prKey = `${owner}/${repo}#${number}`; const cachedETag = etagCache.prResource.get(prKey); // Build gh CLI args for REST API call const url = `repos/${owner}/${repo}/pulls/${number}`; const args = ["api", "--method", "GET", url, "-i"]; // -i includes headers // Add If-None-Match header if we have a cached ETag if (cachedETag) { args.push("-H", `If-None-Match: ${cachedETag}`); } try { const output = await execGhAsync(args, 10_000, "gh.api.guard-pr-resource"); // Check for HTTP 304 Not Modified response if (is304(output)) { return false; } // Extract new ETag from response headers const etagMatch = output.match(/etag:\s*(.+)/i); if (etagMatch) { const newETag = etagMatch[1].trim(); setPRResourceETag(owner, repo, number, newETag); } // PR resource changed - cost: 1 REST point return true; } catch (err) { // gh exits code 1 on 304 Not Modified — check stdout/stderr for the status line const output = extractErrorOutput(err); if (output && is304(output)) { return false; } const errorMsg = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console -- Observability logging for ETag errors console.warn(`[ETag Guard 1] PR resource check failed for ${prKey}: ${errorMsg}`); return true; // Assume changed to be safe } } /** * Guard 2: Commit Status ETag Check (per PR with pending CI) * * Detects if CI status has changed for a specific commit using REST ETag. * * - Endpoint: GET /repos/{owner}/{repo}/commits/{head_sha}/status * - Detects: CI check starts, passes, fails, or external status updates * - Only checked for PRs with ciStatus === "pending" to minimize calls * * @returns true if CI status has changed (200 OK), false if unchanged (304 Not Modified) */ async function checkCommitStatusETag( owner: string, repo: string, sha: string, ): Promise { const commitKey = `${owner}/${repo}#${sha}`; const cachedETag = etagCache.commitStatus.get(commitKey); // Build gh CLI args for REST API call const url = `repos/${owner}/${repo}/commits/${sha}/status`; const args = ["api", "--method", "GET", url, "-i"]; // -i includes headers // Add If-None-Match header if we have a cached ETag if (cachedETag) { args.push("-H", `If-None-Match: ${cachedETag}`); } try { const output = await execGhAsync(args, 10_000, "gh.api.guard-commit-status"); // Check for HTTP 304 Not Modified response if (is304(output)) { return false; } // Extract new ETag from response headers const etagMatch = output.match(/etag:\s*(.+)/i); if (etagMatch) { const newETag = etagMatch[1].trim(); setCommitStatusETag(owner, repo, sha, newETag); } // CI status changed - cost: 1 REST point return true; } catch (err) { // gh exits code 1 on 304 Not Modified — check stdout/stderr for the status line const output = extractErrorOutput(err); if (output && is304(output)) { return false; } const errorMsg = err instanceof Error ? err.message : String(err); // eslint-disable-next-line no-console -- Observability logging for ETag errors console.warn( `[ETag Guard 2] Commit status check failed for ${commitKey}: ${errorMsg}`, ); return true; // Assume changed to be safe } } /** * GraphQL fields to fetch for each PR. * This includes all data needed for orchestrator status detection. * Includes head SHA for ETag Guard 2 (commit status checks). */ const PR_FIELDS = ` title state additions deletions isDraft mergeable mergeStateStatus reviewDecision headRefName headRefOid reviews(last: 5) { nodes { author { login } state submittedAt } } commits(last: 1) { nodes { commit { statusCheckRollup { state contexts(first: 20) { nodes { ... on CheckRun { name status conclusion detailsUrl } ... on StatusContext { context state targetUrl } } pageInfo { hasNextPage } } } } } } `; /** * Generate a GraphQL batch query for multiple PRs using aliases. * * Each PR gets a unique alias (pr0, pr1, pr2...) and the query * fetches the same fields for each PR. */ export function generateBatchQuery(prs: PRInfo[]): { query: string; variables: Record; } { // Handle empty array - return empty query to be handled by caller if (prs.length === 0) { return { query: "", variables: {}, }; } const selections: string[] = []; const variables: Record = {}; prs.forEach((pr, i) => { const alias = `pr${i}`; // Using inline fragments to handle nullable repository type selections.push(` ${alias}: repository(owner: $${alias}Owner, name: $${alias}Name) { ... on Repository { pullRequest(number: $${alias}Number) { ${PR_FIELDS} } } } `); variables[`${alias}Owner`] = pr.owner; variables[`${alias}Name`] = pr.repo; variables[`${alias}Number`] = pr.number; }); const variableDefs = Object.entries(variables) .map(([key, value]) => `$${key}: ${typeof value === "number" ? "Int!" : "String!"}`) .join(", "); return { query: `query BatchPRs(${variableDefs}) { ${selections.join("\n")} rateLimit { cost remaining resetAt } }`, variables, }; } /** * Execute a GraphQL batch query using the gh CLI. * * @throws Error if the query fails with GraphQL errors or parsing issues. */ async function executeBatchQuery( prs: PRInfo[], ): Promise> { const { query, variables } = generateBatchQuery(prs); // Handle empty array - no query needed if (!query || prs.length === 0) { return {}; } // Pre-flight check to verify gh CLI is available await verifyGhCLI(); // Build gh CLI arguments with variables const varArgs: string[] = []; for (const [key, value] of Object.entries(variables)) { if (typeof value === "string") { varArgs.push("-f", `${key}=${value}`); } else { varArgs.push("-F", `${key}=${value}`); } } const args = ["api", "graphql", "-i", ...varArgs, "-f", `query=${query}`]; // Scale timeout based on batch size to prevent large batches from timing out // Base: 30s, +2s per PR beyond first 10 const batchSize = prs.length; const adaptiveTimeout = 30_000 + Math.max(0, (batchSize - 10) * 2000); const stdout = await execGhAsync(args, adaptiveTimeout, "gh.api.graphql-batch"); // With -i, stdout contains HTTP headers + blank line + JSON body. // Split at first blank line to get the JSON body for parsing. // The tracer (execGhObserved) already parses the headers for its trace row. const blankLineIdx = stdout.indexOf("\r\n\r\n"); const altBlankLineIdx = stdout.indexOf("\n\n"); const splitIdx = blankLineIdx >= 0 && (altBlankLineIdx < 0 || blankLineIdx < altBlankLineIdx) ? blankLineIdx + 4 : altBlankLineIdx >= 0 ? altBlankLineIdx + 2 : 0; const body = splitIdx > 0 ? stdout.slice(splitIdx) : stdout; const result: { data?: Record; errors?: Array<{ message: string; path?: string[] }>; } = JSON.parse(body.trim()); // Check for GraphQL errors and throw to allow individual API fallback if (result.errors && result.errors.length > 0) { const errorMsg = result.errors.map((e) => e.message).join("; "); throw new Error(`GraphQL query errors: ${errorMsg}`); } return (result.data ?? {}) as Record; } /** * Parse individual CI check contexts from statusCheckRollup.contexts.nodes. * Handles both CheckRun (GitHub Actions) and StatusContext (legacy status checks). */ function parseCheckContexts(contexts: unknown): CICheck[] { if (!contexts || typeof contexts !== "object") return []; const nodes = (contexts as Record)["nodes"]; if (!Array.isArray(nodes)) return []; const checks: CICheck[] = []; for (const node of nodes) { if (!node || typeof node !== "object") continue; const n = node as Record; // CheckRun node (GitHub Actions) if (typeof n["name"] === "string" && typeof n["status"] === "string") { const rawStatus = (n["status"] as string).toUpperCase(); // Uppercase conclusion to match REST getCIChecks/getCIChecksFromStatusRollup format // so fingerprints are consistent regardless of which data source is used. const rawConclusion = typeof n["conclusion"] === "string" ? (n["conclusion"] as string).toUpperCase() : null; let status: CICheck["status"]; if (rawStatus === "COMPLETED") { if (rawConclusion === "SUCCESS") { status = "passed"; } else if ( rawConclusion === "SKIPPED" || rawConclusion === "NEUTRAL" || rawConclusion === "STALE" || rawConclusion === "NOT_REQUIRED" || rawConclusion === "NONE" ) { // Mirror mapRawCheckStateToStatus() in the REST path: all non-failure // terminal conclusions that are not SUCCESS map to "skipped". status = "skipped"; } else if ( rawConclusion === "FAILURE" || rawConclusion === "TIMED_OUT" || rawConclusion === "CANCELLED" || rawConclusion === "ACTION_REQUIRED" || rawConclusion === "ERROR" ) { // Explicit failure conclusions — mirrors the failure list in mapRawCheckStateToStatus() status = "failed"; } else { // STARTUP_FAILURE and any other unrecognized conclusion → "skipped", // matching mapRawCheckStateToStatus()'s default return "skipped" in the REST path. status = "skipped"; } } else if (rawStatus === "IN_PROGRESS") { // Only IN_PROGRESS maps to "running" — matches mapRawCheckStateToStatus() in REST path status = "running"; } else { // QUEUED, WAITING, and any other non-COMPLETED status → "pending" // (REST path maps QUEUED/WAITING to "pending", not "running") status = "pending"; } checks.push({ name: n["name"] as string, status, // Store the uppercased conclusion to match REST format conclusion: rawConclusion ?? undefined, url: typeof n["detailsUrl"] === "string" ? (n["detailsUrl"] as string) : undefined, }); continue; } // StatusContext node (legacy commit statuses) if (typeof n["context"] === "string" && typeof n["state"] === "string") { const rawState = (n["state"] as string).toUpperCase(); let status: CICheck["status"]; if (rawState === "SUCCESS") { status = "passed"; } else if (rawState === "FAILURE" || rawState === "ERROR") { status = "failed"; } else { status = "pending"; } // Set conclusion to match the REST getCIChecksFromStatusRollup format // (which sets conclusion = rawState.toUpperCase()) so fingerprints are // consistent regardless of which data source is used. checks.push({ name: n["context"] as string, status, conclusion: rawState, url: typeof n["targetUrl"] === "string" ? (n["targetUrl"] as string) : undefined, }); } } return checks; } /** * Parse raw CI state from status check rollup. * * Uses only the top-level aggregate state to determine overall CI status. * Individual check details are parsed separately via parseCheckContexts(). */ function parseCIState( statusCheckRollup: unknown, ): CIStatus { if (!statusCheckRollup || typeof statusCheckRollup !== "object") { return "none"; } const rollup = statusCheckRollup as Record; const state = typeof rollup["state"] === "string" ? rollup["state"].toUpperCase() : ""; // Map GitHub's statusCheckRollup.state to our CIStatus enum // This top-level state aggregates all individual checks and is // significantly cheaper than fetching contexts (10 points vs 50+ per PR) if (state === "SUCCESS") return "passing"; if (state === "FAILURE") return "failing"; if (state === "ERROR") return "failing"; if (state === "PENDING" || state === "EXPECTED") return "pending"; if (state === "TIMED_OUT" || state === "CANCELLED" || state === "ACTION_REQUIRED") return "failing"; if (state === "QUEUED" || state === "IN_PROGRESS" || state === "WAITING") return "pending"; return "none"; } /** * Parse review decision from GraphQL response. */ function parseReviewDecision(reviewDecision: unknown): ReviewDecision { const decision = typeof reviewDecision === "string" ? reviewDecision.toUpperCase() : ""; if (decision === "APPROVED") return "approved"; if (decision === "CHANGES_REQUESTED") return "changes_requested"; if (decision === "REVIEW_REQUIRED") return "pending"; return "none"; } /** * Parse PR state from GraphQL response. */ function parsePRState(state: unknown): PRState { const s = typeof state === "string" ? state.toUpperCase() : ""; if (s === "MERGED") return "merged"; if (s === "CLOSED") return "closed"; return "open"; } /** * Extract enrichment data from a single PR result. * * Returns the enrichment data along with the head SHA for ETag caching. */ function extractPREnrichment( pullRequest: unknown, ): { data: PREnrichmentData; headSha: string | null } | null { if (!pullRequest || typeof pullRequest !== "object") { return null; } const pr = pullRequest as Record; // Check for at least one required field to validate this is a valid PR object if ( pr["state"] === undefined && pr["title"] === undefined && pr["reviews"] === undefined && pr["commits"] === undefined ) { return null; } const state = parsePRState(pr["state"]); // Extract basic info const title = typeof pr["title"] === "string" ? pr["title"] : undefined; const additions = typeof pr["additions"] === "number" ? pr["additions"] : 0; const deletions = typeof pr["deletions"] === "number" ? pr["deletions"] : 0; const isDraft = pr["isDraft"] === true; // Extract head SHA for ETag Guard 2 const headSha = typeof pr["headRefOid"] === "string" ? pr["headRefOid"] : typeof pr["headSha"] === "string" ? pr["headSha"] : null; // Extract merge info const mergeable = pr["mergeable"]; const mergeStateStatus = typeof pr["mergeStateStatus"] === "string" ? pr["mergeStateStatus"].toUpperCase() : ""; const hasConflicts = mergeable === "CONFLICTING"; const isBehind = mergeStateStatus === "BEHIND"; // Extract review decision const reviewDecision = parseReviewDecision(pr["reviewDecision"]); // Extract CI status and individual checks from commits const commits = pr["commits"] as | { nodes?: Array<{ commit?: { statusCheckRollup?: Record } }> } | undefined; const statusCheckRollup = commits?.nodes?.[0]?.commit?.statusCheckRollup; const ciStatus = statusCheckRollup ? parseCIState(statusCheckRollup) : "none"; // Only include ciChecks when the list is complete (no truncation). // contexts(first: 20) silently truncates PRs with >20 checks — when truncated, // the failing check may be missing, so we set ciChecks to undefined to force // the getCIChecks() REST fallback in maybeDispatchCIFailureDetails. const contextsField = statusCheckRollup?.["contexts"] as | Record | undefined; const pageInfo = contextsField?.["pageInfo"]; const contextsHasNextPage = pageInfo !== null && pageInfo !== undefined && typeof pageInfo === "object" && (pageInfo as Record)["hasNextPage"] === true; const ciChecks = contextsField && !contextsHasNextPage ? parseCheckContexts(contextsField) : undefined; // Build blockers list const blockers: string[] = []; if (ciStatus === "failing") blockers.push("CI is failing"); if (reviewDecision === "changes_requested") blockers.push("Changes requested in review"); if (reviewDecision === "pending") blockers.push("Review required"); if (hasConflicts) blockers.push("Merge conflicts"); if (isBehind) blockers.push("Branch is behind base branch"); if (isDraft) blockers.push("PR is still a draft"); // Determine if mergeable based on all conditions // Merged/closed PRs are not considered mergeable for new changes const isMergeableState = state === "open"; // Treat ciStatus "none" as passing (no CI checks configured), matching individual getMergeability const ciPassing = ciStatus === "passing" || ciStatus === "none"; const mergeReady = isMergeableState && ciPassing && (reviewDecision === "approved" || reviewDecision === "none") && !hasConflicts && !isBehind && !isDraft; const data: PREnrichmentData = { state, ciStatus, reviewDecision, mergeable: mergeReady, title, additions, deletions, isDraft, hasConflicts, isBehind, blockers, ...(ciChecks !== undefined ? { ciChecks } : {}), }; return { data, headSha }; } /** * Main batch enrichment function with 2-Guard ETag Strategy. * * Uses per-PR ETag checks to build a selective refresh plan: * * 1. Guard 1: PR Resource ETag Check (per PR) * - Detects PR metadata changes (commits, reviews, labels, state) * - Cost: 0 if unchanged (304), 1 REST point if changed (200) * * 2. Guard 2: Commit Status ETag Check (per PR, only when Guard 1 returns 304) * - Detects CI status changes * - Cost: 0 if unchanged (304), 1 REST point if changed (200) * * Only changed PRs go into the GraphQL batch. Unchanged PRs are served from cache. * * Returns a Map keyed by "${owner}/${repo}#${number}" for efficient lookup. */ export async function enrichSessionsPRBatch( prs: PRInfo[], observer?: BatchObserver, ): Promise> { const result = new Map(); if (prs.length === 0) { return result; } // Step 1: Build per-PR refresh plan using 2-Guard ETag Strategy const plan = await shouldRefreshPREnrichment(prs); // Seed result with cached data from the plan for (const [key, data] of plan.cachedResults) { result.set(key, data); } if (plan.prsToRefresh.length === 0) { observer?.log( "info", `[ETag Guard] Skipping GraphQL batch - all ${result.size} PRs cached. Reasons: ${plan.details.join(", ")}`, ); return result; } observer?.log( "info", `[ETag Guard] ${result.size} cached, ${plan.prsToRefresh.length} need refresh. Reasons: ${plan.details.join(", ")}`, ); // Step 2: Split prsToRefresh into batches const batches: PRInfo[][] = []; for (let i = 0; i < plan.prsToRefresh.length; i += MAX_BATCH_SIZE) { batches.push(plan.prsToRefresh.slice(i, i + MAX_BATCH_SIZE)); } // Step 3: Execute each batch for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { const batch = batches[batchIndex]; const prCountBefore = result.size; const batchStartTime = Date.now(); let batchDuration: number; try { const data = await executeBatchQuery(batch); batchDuration = Date.now() - batchStartTime; // Extract results for each PR in the batch batch.forEach((pr, index) => { const alias = `pr${index}`; const prKey = `${pr.owner}/${pr.repo}#${pr.number}`; const repositoryData = data[alias] as { pullRequest?: unknown } | undefined; if (repositoryData?.pullRequest) { const extracted = extractPREnrichment(repositoryData.pullRequest); if (extracted) { const { data: enrichment, headSha } = extracted; result.set(prKey, enrichment); // Update PR metadata cache for future ETag checks updatePRMetadataCache(prKey, enrichment, headSha); } } else { // PR not found (deleted/closed/permission issue) // Don't add to result or cache. // This allows lifecycle-manager to fall back to individual API calls // which can better handle permissions/edge cases. // The batch will succeed with fewer PRs, and missing PRs // will trigger the fallback path on the next poll. } }); // Log observability metric for successful batch const prCountAfter = result.size; if (prCountAfter > prCountBefore) { const successData = { batchIndex, totalBatches: batches.length, prCount: prCountAfter - prCountBefore, durationMs: batchDuration, }; observer?.recordSuccess(successData); observer?.log("info", `[GraphQL Batch Success] Batch ${batchIndex + 1}/${batches.length} succeeded: added ${prCountAfter - prCountBefore} PRs to cache (${batchDuration}ms)`); } } catch (err) { // Calculate duration even on failure batchDuration = Date.now() - batchStartTime; // Record failure for observability const errorMsg = err instanceof Error ? err.message : String(err); observer?.recordFailure({ batchIndex, totalBatches: batches.length, prCount: batch.length, error: errorMsg, durationMs: batchDuration, }); // Log error for observability but don't fail entirely // eslint-disable-next-line no-console -- Observability logging for batch errors console.error(`[GraphQL Batch Warning] Batch enrichment partially failed: ${errorMsg}`); // Don't add placeholder entries to result or cache. // This allows lifecycle-manager to fall back to individual API calls // for PRs in the failed batch on the next poll. // Return only the partial results we successfully fetched. // Continue to next batch instead of throwing to allow partial success. // Continue with next batch } } return result; } // Export internal functions for testing export { parseCIState, parseReviewDecision, parsePRState, extractPREnrichment, checkPRResourceETag, checkCommitStatusETag, // shouldRefreshPREnrichment is already exported as async function updatePRMetadataCache, }; // Export types for testing export type { ETagCache, PREnrichmentRefreshPlan };