1021 lines
32 KiB
TypeScript
1021 lines
32 KiB
TypeScript
/**
|
||
* 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<string> =>
|
||
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<string> => {
|
||
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<string, string>; // Key: "owner/repo#number", Value: ETag
|
||
commitStatus: LRUCache<string, string>; // 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<string, PREnrichmentData>;
|
||
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<string, PREnrichmentData>(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<PREnrichmentRefreshPlan> {
|
||
const details: string[] = [];
|
||
const prsToRefresh: PRInfo[] = [];
|
||
const cachedResults = new Map<string, PREnrichmentData>();
|
||
|
||
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<string, PREnrichmentData> {
|
||
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<void> {
|
||
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<boolean> {
|
||
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<boolean> {
|
||
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<string, unknown>;
|
||
} {
|
||
// Handle empty array - return empty query to be handled by caller
|
||
if (prs.length === 0) {
|
||
return {
|
||
query: "",
|
||
variables: {},
|
||
};
|
||
}
|
||
|
||
const selections: string[] = [];
|
||
const variables: Record<string, unknown> = {};
|
||
|
||
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<Record<string, unknown>> {
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* 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<string, unknown>)["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<string, unknown>;
|
||
|
||
// 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<string, unknown>;
|
||
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<string, unknown>;
|
||
|
||
// 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<string, unknown> } }> }
|
||
| 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<string, unknown>
|
||
| undefined;
|
||
const pageInfo = contextsField?.["pageInfo"];
|
||
const contextsHasNextPage =
|
||
pageInfo !== null &&
|
||
pageInfo !== undefined &&
|
||
typeof pageInfo === "object" &&
|
||
(pageInfo as Record<string, unknown>)["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<Map<string, PREnrichmentData>> {
|
||
const result = new Map<string, PREnrichmentData>();
|
||
|
||
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 };
|