/** * 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, recordActivityEvent, type BatchObserver, type CICheck, type CIStatus, type PREnrichmentData, type PRInfo, type PRState, type 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. * * NOTE: This bypasses the gh tracer (execGhObserved). Tests that need to * verify tracer behavior should mock execGhObserved directly or use * setExecGhAsync instead. */ 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(); }; } /** * Set execGhAsync for testing — preserves tracer in the call chain. * Use this when testing code that should exercise the traced execution path. */ export function setExecGhAsync( fn: (args: string[], timeout: number, operation: string) => Promise, ): void { execGhAsync = fn; } /** * Configuration constants for cache sizing. * LRU cache automatically evicts oldest entries when these limits are reached. */ const MAX_PR_LIST_ETAGS = 100; // Number of repos to cache const MAX_COMMIT_STATUS_ETAGS = 500; // Number of commits to cache const MAX_REVIEW_COMMENTS_ETAGS = 500; // Number of PRs to cache review ETags 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 list: "prList:{owner}/{repo}" * - Commit status: "commit:{owner}/{repo}#{sha}" */ interface ETagCache { prList: LRUCache; // Key: "owner/repo", Value: ETag commitStatus: LRUCache; // Key: "owner/repo#sha", Value: ETag reviewComments: LRUCache; // Key: "owner/repo#number", 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 = { prList: new LRUCache(MAX_PR_LIST_ETAGS), commitStatus: new LRUCache(MAX_COMMIT_STATUS_ETAGS), reviewComments: new LRUCache(MAX_REVIEW_COMMENTS_ETAGS), }; /** * Result of checking if PR data has changed via ETag guards. */ interface ETagGuardResult { shouldRefresh: boolean; details: string[]; /** Repos where Guard 1 returned 304 — no PR list changes, detectPR can be skipped. */ prListUnchangedRepos: Set; } /** Result of enrichSessionsPRBatch including Guard 1 PR-discovery info. */ export interface BatchEnrichmentResult { enrichment: Map; /** Repos where Guard 1 returned 304 — safe to skip detectPR. */ prListUnchangedRepos: Set; } /** * Clear all ETag cache entries. * Useful for testing or when forcing a refresh. */ export function clearETagCache(): void { etagCache.prList.clear(); etagCache.commitStatus.clear(); etagCache.reviewComments.clear(); } /** * Get PR list ETag for a repository. */ export function getPRListETag(owner: string, repo: string): string | undefined { return etagCache.prList.get(`${owner}/${repo}`); } /** * 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 list ETag for a repository. * Exported for testing. */ export function setPRListETag(owner: string, repo: string, etag: string): void { etagCache.prList.set(`${owner}/${repo}`, 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( 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: Check if PR enrichment cache needs refreshing. * * Before running expensive GraphQL batch queries, use two lightweight REST API * ETag checks to detect if anything actually changed: * * Guard 1: PR List ETag Check (per repo) * - Detects: New commits, PR title/body edits, labels changes, reviews, PR state changes * - Misses: CI status changes * * Guard 2: Commit Status ETag Check (per PR with cached metadata) * - Checks ALL PRs with cached metadata and head SHA * - Detects: CI check starts, passes, fails, or external status updates * - Critical for catching CI transitions (failing -> passing, passing -> failing, etc.) * * @param prs - PRs to check * @returns true if GraphQL batch should run, false if nothing changed */ export async function shouldRefreshPREnrichment( prs: PRInfo[], extraRepos: string[] = [], observer?: BatchObserver, ): Promise { const details: string[] = []; let shouldRefresh = false; // Group PRs by repository for Guard 1 (PR list check) const repos = new Map(); for (const pr of prs) { const repoKey = `${pr.owner}/${pr.repo}`; if (!repos.has(repoKey)) { repos.set(repoKey, []); } const repoPrs = repos.get(repoKey); if (repoPrs) { repoPrs.push(pr); } } // Include repos from PR-less sessions so Guard 1 runs for them too for (const repoKey of extraRepos) { if (!repos.has(repoKey)) { repos.set(repoKey, []); } } if (repos.size === 0) { return { shouldRefresh: false, details: ["No repos to check"], prListUnchangedRepos: new Set(), }; } // Guard 1: Check PR list ETag for each repository let guard1DetectedChanges = false; const prListUnchangedRepos = new Set(); for (const [repoKey] of repos) { const [owner, repo] = repoKey.split("/"); const prListChanged = await checkPRListETag(owner, repo, observer); if (prListChanged) { guard1DetectedChanges = true; shouldRefresh = true; details.push(`PR list changed for ${repoKey} (Guard 1)`); } else { prListUnchangedRepos.add(repoKey); } } // Guard 2: Check commit status ETag only when Guard 1 didn't detect changes // We check ALL PRs (not just pending) to catch CI status transitions: // - failing -> passing (PR becomes merge-ready) // - passing -> failing (PR becomes unmergeable) // - pending -> passing/failing (CI completes) // - passing -> pending (new CI run starts) // // Guard 2 is only needed when Guard 1 returns 304 (no PR list changes). // If Guard 1 detected changes, we're going to refresh all PRs anyway. if (!guard1DetectedChanges) { for (const pr of prs) { const prKey = `${pr.owner}/${pr.repo}#${pr.number}`; const cached = prMetadataCache.get(prKey); // Check for incomplete cache (cached but no headSha) // This happens when PR was cached but headSha wasn't captured // We need to refresh to get complete data including headSha if (cached && cached.headSha === null) { shouldRefresh = true; details.push(`First time seeing PR #${pr.number} (Guard 2: no cached head SHA)`); continue; } // Only check commit status ETag if we have cached data with a non-null head SHA if (!cached || !cached.headSha) { // No cached metadata - skip Guard 2. Since Guard 1 didn't detect changes // and we have no cached data, there's nothing to check. continue; } const statusChanged = await checkCommitStatusETag( pr.owner, pr.repo, cached.headSha, observer, ); if (statusChanged) { shouldRefresh = true; details.push(`CI status changed for ${pr.owner}/${pr.repo}#${pr.number} (Guard 2)`); } } } return { shouldRefresh, details, prListUnchangedRepos }; } /** * Get cached PR metadata for testing. */ export function getPRMetadataCache(): Map { 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; } // Module-level guard so we only emit gh_unavailable once per process. // The error is system-wide (gh missing globally), not session-specific. let ghUnavailableEmitted = false; const batchEnrichPRFailedEmitted = new Set(); /** * 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 (err) { if (!ghUnavailableEmitted) { ghUnavailableEmitted = true; const errorMessage = err instanceof Error ? err.message : String(err); recordActivityEvent({ source: "scm", kind: "scm.gh_unavailable", level: "error", summary: "gh CLI not available or not authenticated", data: { plugin: "scm-github", errorMessage, }, }); } 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; } } /** Test-only: reset the once-per-process gh_unavailable guard. */ export function _resetGhUnavailableEmittedForTesting(): void { ghUnavailableEmitted = false; } /** Test-only: reset the once-per-PR batch extraction failure guard. */ export function _resetBatchEnrichPRFailedEmittedForTesting(): void { batchEnrichPRFailedEmitted.clear(); } /** * 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; } /** * Extract ETag from HTTP response output. * Used on both 200 and 304 paths — RFC 7232 allows servers to rotate * the validator on a 304, so we must re-read the ETag even when unchanged. */ function extractETag(output: string): string | undefined { const match = output.match(/etag:\s*(.+)/i); return match ? match[1].trim() : undefined; } /** * Guard 1: PR List ETag Check (per repo) * * Detects if PR metadata has changed in a repository using REST ETag. * * - Endpoint: GET /repos/{owner}/{repo}/pulls?state=open&sort=updated&direction=desc * - 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 list has changed (200 OK), false if unchanged (304 Not Modified) */ async function checkPRListETag( owner: string, repo: string, observer?: BatchObserver, ): Promise { const repoKey = `${owner}/${repo}`; const cachedETag = etagCache.prList.get(repoKey); // Build gh CLI args for REST API call const url = `repos/${repoKey}/pulls?state=open&sort=updated&direction=desc&per_page=1`; 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-list"); // Check for HTTP 304 Not Modified response if (is304(output)) { // Re-read ETag on 304 — RFC 7232 allows rotated validators const rotatedETag = extractETag(output); if (rotatedETag) setPRListETag(owner, repo, rotatedETag); return false; } // Extract new ETag from response headers const newETag = extractETag(output); if (newETag) { setPRListETag(owner, repo, newETag); } // PR list 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)) { const rotatedETag = extractETag(output); if (rotatedETag) setPRListETag(owner, repo, rotatedETag); return false; } const errorMsg = err instanceof Error ? err.message : String(err); // HTTP 304 may surface as an error message without stdout/stderr (e.g. gh cli versions // that don't populate stdout on non-zero exit). Use is304() anchored to the HTTP status // line to avoid false positives from URL paths like "pulls/304/comments". if (is304(errorMsg)) { return false; } observer?.log("warn", `[ETag Guard 1] PR list check failed for ${repoKey}: ${errorMsg}`); return true; // Assume changed to be safe } } /** * Guard 2: Check-Runs ETag Check (per PR with cached head SHA) * * Detects if CI status has changed for a specific commit using REST ETag. * * - Endpoint: GET /repos/{owner}/{repo}/commits/{head_sha}/check-runs * Uses the check-runs endpoint (not legacy /status) because the batch * query reads `statusCheckRollup` which aggregates check-runs. Pure-Actions * repos only update check-runs, not the legacy combined-status endpoint. * - Detects: CI check starts, passes, fails, or external status updates * * @returns true if CI status has changed (200 OK), false if unchanged (304 Not Modified) */ async function checkCommitStatusETag( owner: string, repo: string, sha: string, observer?: BatchObserver, ): Promise { const commitKey = `${owner}/${repo}#${sha}`; const cachedETag = etagCache.commitStatus.get(commitKey); // Use check-runs endpoint (not legacy /status) to match statusCheckRollup // data source. per_page=1 keeps the response small — we only need the ETag. const url = `repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=1`; 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)) { const rotatedETag = extractETag(output); if (rotatedETag) setCommitStatusETag(owner, repo, sha, rotatedETag); return false; } // Extract new ETag from response headers const newETag = extractETag(output); if (newETag) { 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)) { const rotatedETag = extractETag(output); if (rotatedETag) setCommitStatusETag(owner, repo, sha, rotatedETag); return false; } const errorMsg = err instanceof Error ? err.message : String(err); if (is304(errorMsg)) { return false; } observer?.log( "warn", `[ETag Guard 2] Commit status check failed for ${commitKey}: ${errorMsg}`, ); return true; // Assume changed to be safe } } /** * Guard 3: Review Comments ETag Check (per PR) * * Detects if inline review comments have changed on a PR. * Used to gate the getReviewThreads GraphQL call — if no new comments * exist (304), the cached result is reused without a GraphQL call. * * - Endpoint: GET /repos/{owner}/{repo}/pulls/{number}/comments * No per_page limit — the ETag covers the full resource. With per_page=1, * the ETag only covers the first page, so new comments on page 2+ would * never bust the validator. Typical PR comment counts are small (<100) * so the unbounded list is fine. * - Detects: New review comments, edited comments, deleted comments * - Cost: 0 REST points on 304, 1 REST point on 200 */ export async function checkReviewCommentsETag( owner: string, repo: string, prNumber: number, observer?: BatchObserver, ): Promise { const cacheKey = `${owner}/${repo}#${prNumber}`; const cachedETag = etagCache.reviewComments.get(cacheKey); const url = `repos/${owner}/${repo}/pulls/${prNumber}/comments`; const args = ["api", "--method", "GET", url, "-i"]; if (cachedETag) { args.push("-H", `If-None-Match: ${cachedETag}`); } try { const output = await execGhAsync(args, 10_000, "gh.api.guard-review-comments"); if (is304(output)) { const rotatedETag = extractETag(output); if (rotatedETag) etagCache.reviewComments.set(cacheKey, rotatedETag); return false; } const newETag = extractETag(output); if (newETag) { etagCache.reviewComments.set(cacheKey, newETag); } return true; } catch (err) { const output = extractErrorOutput(err); if (output && is304(output)) { const rotatedETag = extractETag(output); if (rotatedETag) etagCache.reviewComments.set(cacheKey, rotatedETag); return false; } const errorMsg = err instanceof Error ? err.message : String(err); if (is304(errorMsg)) { return false; } observer?.log( "warn", `[ETag Guard 3] Review comments check failed for ${cacheKey}: ${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 commits(last: 1) { nodes { commit { statusCheckRollup { state # 11 keeps per-PR node cost under budget for 25-PR batch queries # (total cost ≤5000). Repos with >11 checks lose individual check # visibility, but the rollup "state" still reflects all checks — # overall pass/fail detection remains correct. contexts(first: 11) { 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["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. * * Before running expensive GraphQL batch queries, uses two lightweight REST API * ETag checks to detect if anything actually changed: * * 1. Guard 1: PR List ETag Check (per repo) * - Detects PR metadata changes (commits, reviews, labels, state) * - Cost: 1 REST point if changed, 0 if unchanged (304) * * 2. Guard 2: Commit Status ETag Check (per PR with pending CI) * - Detects CI status changes for PRs with pending CI * - Cost: 1 REST point if changed, 0 if unchanged (304) * * If guards indicate no changes, skips GraphQL entirely (saves ~50 points per batch). * If any guard detects a change, runs GraphQL batch queries. * * Returns a Map keyed by "${owner}/${repo}#${number}" for efficient lookup. */ export async function enrichSessionsPRBatch( prs: PRInfo[], observer?: BatchObserver, repos: string[] = [], ): Promise { const result = new Map(); // Step 1: Check if we need to refresh using 2-Guard ETag Strategy // Guard 1 runs for all repos (including those with no PRs yet) so the // lifecycle manager knows whether detectPR can be skipped. const guardResult = await shouldRefreshPREnrichment(prs, repos, observer); // Report which repos had no PR list changes so the lifecycle can skip detectPR observer?.reportPRListUnchangedRepos?.(guardResult.prListUnchangedRepos); if (!guardResult.shouldRefresh) { // No changes detected - try to return cached data // If any PRs are missing from cache, we need to fetch them via GraphQL const missingPRs: PRInfo[] = []; for (const pr of prs) { const prKey = `${pr.owner}/${pr.repo}#${pr.number}`; const cachedData = prEnrichmentDataCache.get(prKey); if (cachedData) { result.set(prKey, cachedData); } else { missingPRs.push(pr); } } if (missingPRs.length === 0) { // All PRs cached - return cached data observer?.log( "info", `[ETag Guard] Skipping GraphQL batch - all ${result.size} PRs cached. Reasons: ${guardResult.details.join(", ")}`, ); return { enrichment: result, prListUnchangedRepos: guardResult.prListUnchangedRepos }; } // Some PRs not cached - fetch missing PRs via GraphQL observer?.log( "info", `[ETag Guard] Partial cache: ${result.size} cached, ${missingPRs.length} missing. Fetching missing PRs via GraphQL.`, ); prs = missingPRs; // Update to only fetch missing PRs // Continue to GraphQL batch processing below } // Step 2: Split into batches if we have too many PRs const batches: PRInfo[][] = []; for (let i = 0; i < prs.length; i += MAX_BATCH_SIZE) { batches.push(prs.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 { // GraphQL returned a PR object but extractPREnrichment couldn't // parse it (missing fields, schema drift). Distinct from the // whole-batch failure D02 catches further down. if (!batchEnrichPRFailedEmitted.has(prKey)) { batchEnrichPRFailedEmitted.add(prKey); recordActivityEvent({ source: "scm", kind: "scm.batch_enrich_pr_failed", level: "warn", summary: `batch enrich extraction failed for PR #${pr.number}`, data: { plugin: "scm-github", prNumber: pr.number, prOwner: pr.owner, prRepo: pr.repo, }, }); } } } 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 observer?.log("error", `[GraphQL Batch] 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 { enrichment: result, prListUnchangedRepos: guardResult.prListUnchangedRepos }; } // Export internal functions for testing export { parseCIState, parseReviewDecision, parsePRState, extractPREnrichment, checkPRListETag, checkCommitStatusETag, // shouldRefreshPREnrichment is already exported as async function updatePRMetadataCache, }; // Export types for testing export type { ETagCache, ETagGuardResult };