diff --git a/src/env.d.ts b/src/env.d.ts index f9225832cf..fd2d8b551a 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -227,6 +227,12 @@ declare global { * recording are wired, reading a promoted override into the live gate is a noted follow-up that must not * risk loosening the gate. See src/review/selftune-wire.ts. */ GITTENSORY_REVIEW_SELFTUNE?: string; + /** #1941: route the live CI aggregate (the gate's check/status read) through ONE GraphQL statusCheckRollup + * query instead of the paginated /check-runs + /status + /check-suites REST reads, moving that hot path onto + * the separate GraphQL rate-limit bucket. Default OFF (byte-identical, proven REST aggregate); when ON the + * GraphQL path reuses the REST-resolved required contexts and falls back to REST on any error, unexpected + * shape, or >100 rollup contexts. See fetchLiveCiAggregateViaGraphQl. */ + GITHUB_STATUS_ROLLUP_GRAPHQL?: string; /** Convergence (#issue-coding-plan): the `@gittensory plan` command. Default OFF — `@gittensory plan` falls * through to the existing mention path, so the worker is byte-identical to today. Hosted planning is retired * with the Cloudflare AI binding; self-host can run planning through the configured AI provider. */ diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 4a150ddc3a..59d0d864bc 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -2046,7 +2046,7 @@ const GITHUB_ACTIONS_VALIDATE_AGGREGATE_PREREQUISITES = new Set([ "validate-code", ]); -function isOwnGitHubAppCheckRun(env: Env, run: GitHubCheckRunPayload): boolean { +function isOwnGitHubAppCheckRun(env: Env, run: { name: string; app?: { slug?: string | null } | null }): boolean { const appSlug = typeof run.app?.slug === "string" ? run.app.slug.trim().toLowerCase() : ""; const ownSlug = env.GITHUB_APP_SLUG.trim().toLowerCase(); return ownSlug.length > 0 && appSlug === ownSlug && BOT_OWNED_CHECK_NAMES.has(run.name); @@ -2111,6 +2111,118 @@ export async function fetchRequiredStatusContexts(env: Env, repoFullName: string return names; } +// Minimal structural shape the CI reducer needs from a check-run — a superset of the REST GitHubCheckRunPayload +// (so REST payloads assign directly) AND buildable from the GraphQL CheckRun node (which has no `id`). +type LiveCiCheckRun = { + name: string; + status?: string | null; + conclusion?: string | null; + details_url?: string | null; + output?: { title?: unknown; summary?: unknown }; + app?: { slug?: string | null } | null; +}; +type LiveCiStatus = { context?: string | null; state?: string | null; description?: string | null; target_url?: string | null }; +type LiveCiSuite = { status?: string | null; app?: { slug?: string | null } | null }; + +/** + * Pure reduction of a head SHA's check-runs + classic statuses (+ a lazily-fetched check-suite backstop) into the + * gate's LiveCiAggregate. Extracted so the REST fetch path (fetchLiveCiAggregate) and the GraphQL rollup path + * (fetchLiveCiAggregateViaGraphQl) produce BYTE-IDENTICAL verdicts from ONE set of rules — only the data source + * differs (#1941), which is what keeps the flag-gated GraphQL path semantically equivalent to the proven REST one. + * `fetchSuites` is invoked ONLY when the cheaper sources are fully settled (no failure, no pending, no incomplete + * read), mirroring the REST path's conditional suites read so neither path pays for it on an already-decided PR; it + * returns the suite list, or null when that read is unreadable (fail-closed). + */ +async function reduceLiveCiAggregate( + env: Env, + inputs: { + checkRuns: ReadonlyArray; + statuses: ReadonlyArray; + requiredContexts: ReadonlySet | null | undefined; + checkRunsIncomplete: boolean; + statusIncomplete: boolean; + fetchSuites: () => Promise | null>; + }, +): Promise { + const { checkRuns, statuses, requiredContexts, checkRunsIncomplete, statusIncomplete, fetchSuites } = inputs; + const enforceRequiredOnly = requiredContexts != null && requiredContexts.size > 0; + const isRequired = (name: string): boolean => !enforceRequiredOnly || requiredContexts!.has(name); + const failingDetails: LiveCiAggregate["failingDetails"] = []; + const nonRequiredFailingDetails: LiveCiAggregate["nonRequiredFailingDetails"] = []; + let total = 0; + let anyPending = false; + let anyVisiblePending = false; + let sawFirstPartyCheckRun = false; + const seenContextNames = new Set(); + + // 1) Check-runs (GitHub Actions jobs, CodeQL, app checks). + for (const run of checkRuns) { + seenContextNames.add(run.name); // mark BEFORE bot-check skip: a bot-owned required context is "seen" + if ((run.app?.slug ?? "").toLowerCase() === "github-actions") sawFirstPartyCheckRun = true; + if (isOwnGitHubAppCheckRun(env, run)) continue; // never wait on the bot's own Gate/Context check-runs + total += 1; + const conclusion = (run.conclusion ?? "").toLowerCase(); + const status = (run.status ?? "").toLowerCase(); + if (conclusion ? CI_FAILING_CONCLUSIONS.has(conclusion) : false) { + const summary = [run.output?.title, run.output?.summary].find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim().slice(0, 200); + failingDetails.push({ name: run.name, ...(summary ? { summary } : {}), ...(run.details_url ? { detailsUrl: run.details_url } : {}) }); + } else if (conclusion ? CI_PASSING_CONCLUSIONS.has(conclusion) : status === "completed") { + // concluded and not failing → passing + } else { + anyVisiblePending = true; + if (isRequired(run.name)) anyPending = true; // queued / in_progress / not yet concluded — only a REQUIRED check holds the gate + } + } + + // 2) Classic commit-statuses (codecov/patch, codecov/project, and any other status-API context). + for (const ctx of statuses) { + const name = ctx.context ?? "status"; + total += 1; + seenContextNames.add(name); + const state = (ctx.state ?? "").toLowerCase(); + if (state === "failure" || state === "error") { + const summary = typeof ctx.description === "string" ? ctx.description.trim().slice(0, 200) : ""; + failingDetails.push({ name, ...(summary ? { summary } : {}), ...(ctx.target_url ? { detailsUrl: ctx.target_url } : {}) }); + } else if (state === "success") { + // passing + } else { + anyVisiblePending = true; + if (isRequired(name)) anyPending = true; // pending — only a REQUIRED context holds the gate + } + } + + // A required context that never appeared in any result is not safe to treat as passed — count it as pending. + if (enforceRequiredOnly) { + for (const ctx of requiredContexts!) { + if (!seenContextNames.has(ctx)) anyPending = true; + } + } + + // Fold-all mode: a dependent aggregate check can briefly be absent after its prerequisites settled → pending. + if (!enforceRequiredOnly && missingConventionalValidateAggregate(seenContextNames)) { + anyPending = true; + } + + // Check-suite hardening: read the check-SUITES too before certifying a commit settled (only when the cheaper + // sources found no failure, no pending, and no incomplete page, so it never adds a call to an already-decided PR). + if (failingDetails.length === 0 && !anyPending && !anyVisiblePending && !checkRunsIncomplete && !statusIncomplete) { + const suites = await fetchSuites(); + if (!suites) { + // Unreadable suites: fail CLOSED (pending) only when we ALSO never saw a first-party run and checks exist. + if (!enforceRequiredOnly && !sawFirstPartyCheckRun && total > 0) anyPending = true; + } else if (suites.some((suite) => (suite.app?.slug ?? "").toLowerCase() === "github-actions" && (suite.status ?? "").toLowerCase() !== "completed")) { + anyPending = true; // a first-party GitHub Actions workflow has not completed + anyVisiblePending = true; + } + } + + let ciState: LiveCiAggregate["ciState"] = failingDetails.length > 0 ? "failed" : anyPending ? "pending" : total > 0 ? "passed" : "unverified"; + // Fail CLOSED on incomplete visibility: an OBSERVED failure is authoritative and preserved. + if ((checkRunsIncomplete || statusIncomplete) && ciState !== "failed") ciState = "pending"; + const hasPending = anyVisiblePending || anyPending || checkRunsIncomplete || statusIncomplete || ciState === "pending"; + return { ciState, hasPending, hasVisiblePending: anyVisiblePending, failingDetails, nonRequiredFailingDetails }; +} + /** * Fetch the head SHA's LIVE CI aggregate over BOTH GitHub Check-runs AND classic commit-statuses. This is the * reviewbot `getAllChecksState` parity that the converged auto-maintain path needs: codecov (codecov/patch, @@ -2130,31 +2242,12 @@ export async function fetchLiveCiAggregate( requiredContexts?: ReadonlySet | null, ): Promise { if (!headSha) return { ciState: "unverified", hasPending: false, hasVisiblePending: false, failingDetails: [], nonRequiredFailingDetails: [] }; - const enforceRequiredOnly = requiredContexts != null && requiredContexts.size > 0; - const isRequired = (name: string): boolean => !enforceRequiredOnly || requiredContexts.has(name); - const failingDetails: LiveCiAggregate["failingDetails"] = []; - const nonRequiredFailingDetails: LiveCiAggregate["nonRequiredFailingDetails"] = []; - let total = 0; - let anyPending = false; - let anyVisiblePending = false; - // CI visibility flags: a failed/short read of either source means we did NOT enumerate the commit's full check - // set, so we must not certify it "passed". They drive the fail-CLOSED degrade below. In enforce-required mode - // the absent-context guard already catches this; this additionally closes the fold-all (unknown-required) seam - // where a transient fetch failure plus one green check could otherwise read as "passed". + // Check-runs + classic statuses are accumulated across pages here; the single classification lives in + // reduceLiveCiAggregate so the REST and GraphQL paths reach byte-identical verdicts (#1941). + const checkRuns: LiveCiCheckRun[] = []; let checkRunsIncomplete = false; - let statusIncomplete = false; - // Whether a FIRST-PARTY (GitHub Actions) check-run was observed at all. Used by the fold-all suites backstop: - // if the suites read is unreadable AND we never saw a first-party run, we cannot confirm the workflow ran, so - // we must fail CLOSED rather than certify "passed" off only always-on third-party checks (#review-audit, #1799). - let sawFirstPartyCheckRun = false; - // Track which context names actually appear in any API result. Required contexts use this to catch an absent - // required check, and fold-all mode uses it to catch this repo family's late-materializing aggregate `validate` - // check when branch-protection/ruleset contexts are not readable. - const seenContextNames = new Set(); - - // 1) Check-runs (GitHub Actions jobs, CodeQL, app checks). for (let page = 1; page <= PR_DETAIL_MAX_PAGES; page += 1) { - const result = await githubJsonWithHeaders<{ check_runs?: Array }>( + const result = await githubJsonWithHeaders<{ check_runs?: Array }>( env, repoFullName, `/commits/${headSha}/check-runs?per_page=100&page=${page}`, @@ -2165,123 +2258,167 @@ export async function fetchLiveCiAggregate( checkRunsIncomplete = true; break; } - for (const run of result.data.check_runs ?? []) { - seenContextNames.add(run.name); // mark BEFORE bot-check skip: a bot-owned required context is "seen" - if ((run.app?.slug ?? "").toLowerCase() === "github-actions") sawFirstPartyCheckRun = true; - if (isOwnGitHubAppCheckRun(env, run)) continue; // never wait on the bot's own Gate/Context check-runs (see above) - total += 1; - const conclusion = (run.conclusion ?? "").toLowerCase(); - const status = (run.status ?? "").toLowerCase(); - if (conclusion ? CI_FAILING_CONCLUSIONS.has(conclusion) : false) { - const summary = [run.output?.title, run.output?.summary].find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim().slice(0, 200); - failingDetails.push({ name: run.name, ...(summary ? { summary } : {}), ...(run.details_url ? { detailsUrl: run.details_url } : {}) }); - } else if (conclusion ? CI_PASSING_CONCLUSIONS.has(conclusion) : status === "completed") { - // concluded and not failing → passing - } else { - anyVisiblePending = true; - if (isRequired(run.name)) anyPending = true; // queued / in_progress / not yet concluded — only a REQUIRED check holds the gate - } - } + checkRuns.push(...(result.data.check_runs ?? [])); if (!hasNextPage(result.link)) break; } - - // 2) Classic commit-statuses (codecov/patch, codecov/project, and any other status-API context). The - // combined endpoint returns the LATEST status per context, so a context that flipped red→green is counted - // once at its current state. Paginated: the endpoint caps at 100 statuses/page, so a head with >100 contexts - // would silently drop the overflow (including a failing one) — accumulate every page before processing. - const commitStatuses: Array<{ context?: string | null; state?: string | null; description?: string | null; target_url?: string | null }> = []; + // The combined status endpoint caps at 100/page, so accumulate every page before the reducer processes them. + const statuses: LiveCiStatus[] = []; + let statusIncomplete = false; for (let page = 1; page <= PR_DETAIL_MAX_PAGES; page += 1) { - const statusResult = await githubJsonWithHeaders<{ statuses?: Array<{ context?: string | null; state?: string | null; description?: string | null; target_url?: string | null }> }>( + const statusResult = await githubJsonWithHeaders<{ statuses?: Array }>( env, repoFullName, `/commits/${headSha}/status?per_page=100&page=${page}`, token, ).catch(() => undefined); - // A failed status fetch leaves the status set partially read — fail closed (see the degrade below). if (!statusResult) { statusIncomplete = true; break; } - commitStatuses.push(...(statusResult.data.statuses ?? [])); + statuses.push(...(statusResult.data.statuses ?? [])); if (!hasNextPage(statusResult.link)) break; } - for (const ctx of commitStatuses) { - const name = ctx.context ?? "status"; - total += 1; - seenContextNames.add(name); - const state = (ctx.state ?? "").toLowerCase(); - if (state === "failure" || state === "error") { - const summary = typeof ctx.description === "string" ? ctx.description.trim().slice(0, 200) : ""; - failingDetails.push({ name, ...(summary ? { summary } : {}), ...(ctx.target_url ? { detailsUrl: ctx.target_url } : {}) }); - } else if (state === "success") { - // passing - } else { - anyVisiblePending = true; - if (isRequired(name)) anyPending = true; // pending — only a REQUIRED context holds the gate - } - } - - // A required context that never appeared in any result is not safe to treat as passed — count it as pending - // so the gate waits rather than approving a PR whose required CI never ran (e.g. a workflow that doesn't - // trigger on forks, or a check that was skipped). - if (enforceRequiredOnly) { - for (const ctx of requiredContexts!) { - if (!seenContextNames.has(ctx)) { - anyPending = true; - } - } - } + return reduceLiveCiAggregate(env, { + checkRuns, + statuses, + requiredContexts, + checkRunsIncomplete, + statusIncomplete, + // Lazily read the check-SUITES backstop only when the reducer finds the cheaper sources fully settled; a fetch + // error returns null so the reducer fails closed exactly as the inline path did. + fetchSuites: async () => { + const suitesResult = await githubJsonWithHeaders<{ check_suites?: Array }>( + env, + repoFullName, + `/commits/${headSha}/check-suites?per_page=100`, + token, + ).catch(() => undefined); + return suitesResult ? (suitesResult.data.check_suites ?? []) : null; + }, + }); +} - // Branch-protection reads can be unavailable for installation tokens/rulesets. In fold-all mode, a dependent - // aggregate check can briefly be absent after its prerequisites have settled; treat that as pending so the Orb - // check does not publish before the actual required `validate` context exists. - if (!enforceRequiredOnly && missingConventionalValidateAggregate(seenContextNames)) { - anyPending = true; - } +/** #1941 flag: route the live CI aggregate through the GraphQL status rollup. OFF by default (byte-identical + * deploy); a truthy value opts a deployment in, and the GraphQL path still falls back to REST on any uncertainty. */ +export function isStatusRollupGraphQlEnabled(env: { GITHUB_STATUS_ROLLUP_GRAPHQL?: string | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.GITHUB_STATUS_ROLLUP_GRAPHQL ?? ""); +} - // Check-suite hardening (#ci-foldall-checksuites / #dependent-ci-materialization): the check-run/status scan can - // read "settled" before GitHub materializes downstream jobs whose `needs:` dependencies just completed - // (`coverage-upload` and then `validate` are the common shape). Read the check-SUITES too before certifying a - // commit settled: a GitHub-Actions suite still `queued`/`requested`/`waiting`/`in_progress` means first-party CI - // has not finished, even if every currently-visible check-run is completed. This runs only when the cheaper - // sources found no failure, no pending check, and no incomplete page, so it does not add a call to already-pending - // or already-failing PRs. - if (headSha && failingDetails.length === 0 && !anyPending && !anyVisiblePending && !checkRunsIncomplete && !statusIncomplete) { - const suitesResult = await githubJsonWithHeaders<{ check_suites?: Array<{ status?: string | null; app?: { slug?: string | null } | null }> }>( - env, - repoFullName, - `/commits/${headSha}/check-suites?per_page=100`, - token, - ).catch(() => undefined); - // Downgrade on an AFFIRMATIVE incomplete suite. AND: if the suites read itself is UNREADABLE (it 403s under the - // very same missing administration:read that forced fold-all, or rate-limits) we can no longer rely on it — so - // fail CLOSED (pending) when we ALSO never saw a first-party GitHub Actions check-run, i.e. we cannot confirm the - // required workflow ran at all (the #1799 false-green: a fork PR awaiting approval with only an always-on - // third-party status). A readable, all-completed suites result still certifies "passed" (no mass false-pending). - if (!suitesResult) { - // total === 0 means the commit has NO checks at all → genuinely unverified (no CI), not a missing first-party - // run, so leave it; only pend when checks DO exist but none of them is a confirmed first-party run. - if (!enforceRequiredOnly && !sawFirstPartyCheckRun && total > 0) anyPending = true; - } else if ((suitesResult.data.check_suites ?? []).some((suite) => (suite.app?.slug ?? "").toLowerCase() === "github-actions" && (suite.status ?? "").toLowerCase() !== "completed")) { - anyPending = true; // a first-party GitHub Actions workflow has not completed (or downstream jobs are pending materialization) - anyVisiblePending = true; +/** + * GraphQL equivalent of {@link fetchLiveCiAggregate}: ONE bounded query returns the head commit's statusCheckRollup + * (check-runs AND classic statuses, unified) plus its check-suites — replacing the paginated /check-runs + /status + * + /check-suites REST reads with a single call against the SEPARATE GraphQL points bucket (#1941). It reuses the + * caller's REST-resolved `requiredContexts` (so required-context semantics are identical) and the SAME + * reduceLiveCiAggregate rules, so the verdict is byte-identical to the REST path. Returns null — so the caller + * falls back to the proven REST path — on ANY uncertainty: missing token/owner, a GraphQL error, an unexpected + * shape, or >100 rollup contexts (a single page cannot enumerate them; the REST path paginates). + */ +export async function fetchLiveCiAggregateViaGraphQl( + env: Env, + repoFullName: string, + headSha: string | null | undefined, + token: string | undefined, + requiredContexts?: ReadonlySet | null, +): Promise { + if (!headSha || !token) return null; + const [owner, name] = repoFullName.split("/"); + if (!owner || !name) return null; + const query = `query GittensoryLiveCiRollup { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { object(oid: ${JSON.stringify(headSha)}) { ... on Commit { statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { name conclusion status detailsUrl title summary checkSuite { app { slug } } } ... on StatusContext { context state description targetUrl } } pageInfo { hasNextPage } } } checkSuites(first: 100) { nodes { status app { slug } } } } } } }`; + const result = await githubGraphQl<{ + data?: { + repository?: { + object?: { + statusCheckRollup?: { + contexts?: { + nodes?: Array<{ + __typename?: string; + name?: string | null; + conclusion?: string | null; + status?: string | null; + detailsUrl?: string | null; + title?: string | null; + summary?: string | null; + checkSuite?: { app?: { slug?: string | null } | null } | null; + context?: string | null; + state?: string | null; + description?: string | null; + targetUrl?: string | null; + }>; + pageInfo?: { hasNextPage?: boolean }; + } | null; + } | null; + checkSuites?: { nodes?: Array<{ status?: string | null; app?: { slug?: string | null } | null }> }; + } | null; + } | null; + }; + errors?: unknown[]; + }>(env, query, token).catch(() => null); + if (!result) return null; // GraphQL fetch/HTTP error → fall back to REST + // A 200 with a top-level `errors` array is a PARTIAL result (a field resolver failed): the data is half-populated + // and must NOT be read as a settled/empty rollup — fall back so a partial error can't mask a failing or pending + // check as "no checks" and let the gate merge on it. + if (Array.isArray(result.errors) && result.errors.length > 0) return null; + const commit = result.data?.repository?.object; + // A resolved Commit ALWAYS returns a `checkSuites` connection whose `nodes` is an array. If it is absent or not an + // array, the object is not a Commit (or the shape is unexpected) → fall back rather than normalize the gap to + // empty inputs (the exact failure the doc above promises to avoid). + const suiteNodes = commit?.checkSuites?.nodes; + if (!commit || !Array.isArray(suiteNodes)) return null; + const rollup = commit.statusCheckRollup; + const contexts = rollup?.contexts; + // statusCheckRollup is null for a check-less commit (legitimate → empty inputs → "unverified"). A NON-null rollup + // must carry a well-formed `contexts.nodes` array; a present-but-malformed connection → fall back to REST. + if (rollup && !Array.isArray(contexts?.nodes)) return null; + if (contexts?.pageInfo?.hasNextPage) return null; // >100 contexts: not fully enumerated → let REST paginate + const checkRuns: LiveCiCheckRun[] = []; + const statuses: LiveCiStatus[] = []; + for (const node of contexts?.nodes ?? []) { + if (node.__typename === "CheckRun") { + // Field-name mapping only (detailsUrl→details_url, title/summary→output.*, checkSuite.app→app); the reducer + // lowercases GraphQL's UPPERCASE conclusion/status enums, so no case handling is needed here. + checkRuns.push({ + name: node.name ?? "", + conclusion: node.conclusion ?? null, + status: node.status ?? null, + details_url: node.detailsUrl ?? null, + output: { title: node.title ?? undefined, summary: node.summary ?? undefined }, + app: { slug: node.checkSuite?.app?.slug ?? null }, + }); + } else if (node.__typename === "StatusContext") { + statuses.push({ context: node.context ?? null, state: node.state ?? null, description: node.description ?? null, target_url: node.targetUrl ?? null }); } } + const suites: LiveCiSuite[] = suiteNodes.map((suite) => ({ status: suite.status ?? null, app: { slug: suite.app?.slug ?? null } })); + return reduceLiveCiAggregate(env, { + checkRuns, + statuses, + requiredContexts, + checkRunsIncomplete: false, + statusIncomplete: false, + fetchSuites: async () => suites, // already fetched in the same query — never a second round-trip + }); +} - // ciState reflects every completed red check/status. Required contexts additionally prevent absent or pending - // required checks from being treated as passed. - let ciState: LiveCiAggregate["ciState"] = failingDetails.length > 0 ? "failed" : anyPending ? "pending" : total > 0 ? "passed" : "unverified"; - // Fail CLOSED on incomplete visibility: if either CI source could not be fully read, we cannot certify the - // commit as passed/clean — hold (pending) so the gate waits and re-evaluates on the next sweep instead of - // auto-merging on partial data. An OBSERVED failure ("failed") is authoritative and preserved. - if ((checkRunsIncomplete || statusIncomplete) && ciState !== "failed") ciState = "pending"; - const hasPending = - anyVisiblePending || - anyPending || - checkRunsIncomplete || - statusIncomplete || - ciState === "pending"; - return { ciState, hasPending, hasVisiblePending: anyVisiblePending, failingDetails, nonRequiredFailingDetails }; +/** + * The gate's CI-aggregate entrypoint. When the #1941 flag is ON, try the GraphQL statusCheckRollup path and use it + * UNLESS it returns null (any uncertainty — see fetchLiveCiAggregateViaGraphQl), otherwise the proven REST + * aggregate; flag OFF → always REST (byte-identical). Kept as its own function (not inline at the call site) so the + * flag + fallback branches are unit-testable in isolation. + */ +export async function fetchLiveCiAggregatePreferGraphQl( + env: Env, + repoFullName: string, + headSha: string | null | undefined, + token: string | undefined, + requiredContexts?: ReadonlySet | null, +): Promise { + if (isStatusRollupGraphQlEnabled(env)) { + // fetchLiveCiAggregateViaGraphQl handles all its own errors and returns null on any uncertainty (it never + // rejects), so a null result — not a throw — is the fall-back-to-REST signal. + const rollup = await fetchLiveCiAggregateViaGraphQl(env, repoFullName, headSha, token, requiredContexts); + if (rollup) return rollup; + } + return fetchLiveCiAggregate(env, repoFullName, headSha, token, requiredContexts); } /** diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 34827cc587..c55f232f96 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -74,7 +74,7 @@ import { enqueueRepositoryOpenDataBackfill, fetchAndStorePullRequestFilesForReview, fetchLinkedIssueFacts, - fetchLiveCiAggregate, + fetchLiveCiAggregatePreferGraphQl, type LiveCiAggregate, fetchLivePullRequest, fetchLivePullRequestHeadSha, @@ -503,11 +503,13 @@ function fetchLiveCiAggregateWithRequiredContexts( baseRef: string | null | undefined, token: string | undefined, ): Promise { - // CI refresh callers need fresh check/status state; branch protection contexts move slowly enough to stay request-cached. + // CI refresh callers need fresh check/status state; branch protection contexts move slowly enough to stay + // request-cached. When the #1941 flag is on, fetchLiveCiAggregatePreferGraphQl collapses the check/status reads + // into one GraphQL rollup (reusing these requiredContexts), else it uses the proven REST aggregate. return cachedRequiredStatusContexts(env, repoFullName, facts, baseRef, token) .catch(() => null) .then((requiredContexts) => - fetchLiveCiAggregate(env, repoFullName, headSha, token, requiredContexts), + fetchLiveCiAggregatePreferGraphQl(env, repoFullName, headSha, token, requiredContexts), ); } diff --git a/test/unit/graphql-status-rollup.test.ts b/test/unit/graphql-status-rollup.test.ts new file mode 100644 index 0000000000..78153ba3a6 --- /dev/null +++ b/test/unit/graphql-status-rollup.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + fetchLiveCiAggregate, + fetchLiveCiAggregatePreferGraphQl, + fetchLiveCiAggregateViaGraphQl, + isStatusRollupGraphQlEnabled, +} from "../../src/github/backfill"; +import { createTestEnv } from "../helpers/d1"; + +const REPO = "JSONbored/gittensory"; +const SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const TOKEN = "test-token"; + +type Run = { name: string; conclusion?: string | null; status?: string | null; appSlug?: string | null; detailsUrl?: string | null; title?: string | null; summary?: string | null }; +type Status = { context: string; state: string; description?: string | null; targetUrl?: string | null }; +type Suite = { status: string; appSlug: string }; + +// GraphQL statusCheckRollup nodes use UPPERCASE enums (SUCCESS/FAILURE/COMPLETED/IN_PROGRESS) — the reducer +// lowercases them, so the fixtures below deliberately use GitHub's real GraphQL casing. +const runNode = (r: Run) => ({ __typename: "CheckRun", name: r.name, conclusion: r.conclusion ?? null, status: r.status ?? null, detailsUrl: r.detailsUrl ?? null, title: r.title ?? null, summary: r.summary ?? null, checkSuite: { app: { slug: r.appSlug ?? null } } }); +const statusNode = (s: Status) => ({ __typename: "StatusContext", context: s.context, state: s.state, description: s.description ?? null, targetUrl: s.targetUrl ?? null }); + +function graphqlBody(opts: { runs?: Run[]; statuses?: Status[]; suites?: Suite[]; hasNextPage?: boolean; object?: unknown } = {}): unknown { + const object = + "object" in opts + ? opts.object + : { + statusCheckRollup: { contexts: { nodes: [...(opts.runs ?? []).map(runNode), ...(opts.statuses ?? []).map(statusNode)], pageInfo: { hasNextPage: opts.hasNextPage ?? false } } }, + checkSuites: { nodes: (opts.suites ?? []).map((s) => ({ status: s.status, app: { slug: s.appSlug } })) }, + }; + return { data: { repository: { object } } }; +} + +// Stub ONLY the GraphQL endpoint; any REST call falling through 404s, proving the rollup path made no REST read. +function stubGraphql(body: unknown, opts: { status?: number } = {}): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + if (input.toString() === "https://api.github.com/graphql") { + return opts.status && opts.status !== 200 ? new Response("boom", { status: opts.status }) : Response.json(body); + } + return new Response("not found", { status: 404 }); + }); +} + +// The REST equivalent, for the direct REST↔GraphQL equivalence checks. REST enums are lowercase. +function stubRest(opts: { runs?: Run[]; statuses?: Status[]; suites?: Suite[] }): void { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/check-runs")) return Response.json({ check_runs: (opts.runs ?? []).map((r) => ({ id: 1, name: r.name, status: (r.status ?? "").toLowerCase(), conclusion: r.conclusion ? r.conclusion.toLowerCase() : null, details_url: r.detailsUrl ?? null, output: { title: r.title ?? null, summary: r.summary ?? null }, app: { slug: r.appSlug ?? null } })) }); + if (url.includes("/status")) return Response.json({ statuses: (opts.statuses ?? []).map((s) => ({ context: s.context, state: s.state.toLowerCase(), description: s.description ?? null, target_url: s.targetUrl ?? null })) }); + if (url.includes("/check-suites")) return Response.json({ check_suites: (opts.suites ?? []).map((s) => ({ status: s.status.toLowerCase(), app: { slug: s.appSlug } })) }); + return new Response("not found", { status: 404 }); + }); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("isStatusRollupGraphQlEnabled", () => { + it("is OFF by default and for falsy/absent values, ON for the truthy set", () => { + expect(isStatusRollupGraphQlEnabled({})).toBe(false); + expect(isStatusRollupGraphQlEnabled({ GITHUB_STATUS_ROLLUP_GRAPHQL: "" })).toBe(false); + expect(isStatusRollupGraphQlEnabled({ GITHUB_STATUS_ROLLUP_GRAPHQL: "false" })).toBe(false); + expect(isStatusRollupGraphQlEnabled({ GITHUB_STATUS_ROLLUP_GRAPHQL: "0" })).toBe(false); + for (const v of ["1", "true", "yes", "on", "TRUE", "On"]) { + expect(isStatusRollupGraphQlEnabled({ GITHUB_STATUS_ROLLUP_GRAPHQL: v })).toBe(true); + } + }); +}); + +describe("fetchLiveCiAggregateViaGraphQl — verdicts", () => { + const env = createTestEnv(); + + it("returns null (→ REST fallback) on missing headSha, token, or malformed repo", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }] })); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, null, TOKEN)).toBeNull(); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, undefined)).toBeNull(); + expect(await fetchLiveCiAggregateViaGraphQl(env, "no-slash", SHA, TOKEN)).toBeNull(); + }); + + it("returns null on a GraphQL error or an unexpected/absent commit (→ REST fallback)", async () => { + stubGraphql(graphqlBody(), { status: 500 }); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).toBeNull(); + stubGraphql(graphqlBody({ object: null })); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).toBeNull(); + }); + + it("returns null when the rollup has >100 contexts (a single page cannot enumerate them)", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }], hasNextPage: true })); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).toBeNull(); + }); + + it("passes when a required check-run and status are green", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", appSlug: "github-actions" }], statuses: [{ context: "codecov/patch", state: "SUCCESS" }], suites: [{ status: "COMPLETED", appSlug: "github-actions" }] })); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"]))).toMatchObject({ ciState: "passed", hasPending: false, failingDetails: [] }); + }); + + it("fails on a red check-run and surfaces its name/summary", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "FAILURE", status: "COMPLETED", summary: "boom", detailsUrl: "https://ci/build" }] })); + const agg = await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"])); + expect(agg?.ciState).toBe("failed"); + expect(agg?.failingDetails).toEqual([{ name: "build", summary: "boom", detailsUrl: "https://ci/build" }]); + }); + + it("fails on a red classic status (e.g. codecov) even when not required", async () => { + stubGraphql(graphqlBody({ statuses: [{ context: "codecov/patch", state: "FAILURE" }] })); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"])))?.ciState).toBe("failed"); + }); + + it("holds pending on a REQUIRED check still in progress, but PASSES a pending NON-required one", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: null, status: "IN_PROGRESS" }] })); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"])))?.ciState).toBe("pending"); + stubGraphql(graphqlBody({ runs: [{ name: "flaky-optional", conclusion: null, status: "IN_PROGRESS" }, { name: "build", conclusion: "SUCCESS", status: "COMPLETED" }] })); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"]))).toMatchObject({ ciState: "passed", hasPending: true, hasVisiblePending: true }); + }); + + it("holds pending when a required context never appears", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }] })); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build", "e2e"])))?.ciState).toBe("pending"); + }); + + it("treats a skipped conclusion as passing", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "SKIPPED", status: "COMPLETED" }], suites: [] })); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"])))?.ciState).toBe("passed"); + }); + + it("holds pending via the check-suite backstop when a first-party suite has not completed", async () => { + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }], suites: [{ status: "IN_PROGRESS", appSlug: "github-actions" }] })); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"])))?.ciState).toBe("pending"); + }); + + it("defensively defaults every missing field on sparse rollup nodes and suites", async () => { + // Every optional field absent on each node/suite → exercises the nullish (??) fallbacks in the normalization + // AND (via the null-valued normalized fields) the reducer's own status/context/state fallbacks. + stubGraphql({ data: { repository: { object: { statusCheckRollup: { contexts: { nodes: [{ __typename: "CheckRun" }, { __typename: "StatusContext" }] } }, checkSuites: { nodes: [{}] } } } } }); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).not.toBeNull(); + }); + + it("is unverified for a legitimate check-less commit (statusCheckRollup null)", async () => { + // Real GitHub shape for a commit with no CI: the whole rollup is null (not an empty connection). This is the + // ONLY empty-input case that must NOT fall back — it is genuinely "no checks". + stubGraphql({ data: { repository: { object: { statusCheckRollup: null, checkSuites: { nodes: [] } } } } }); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN))?.ciState).toBe("unverified"); + }); + + it("falls back to REST on a PARTIAL GraphQL response (top-level errors), never normalizing it to empty", async () => { + // A field resolver failed: statusCheckRollup came back null but the top-level `errors` array is populated. This + // must NOT be read as "no checks" (which could merge a PR whose CI is actually failing) — return null → REST. + stubGraphql({ data: { repository: { object: { statusCheckRollup: null, checkSuites: { nodes: [] } } } }, errors: [{ message: "timeout resolving statusCheckRollup" }] }); + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).toBeNull(); + }); + + it("falls back when the object is not a resolved Commit (checkSuites connection absent/malformed)", async () => { + stubGraphql({ data: { repository: { object: { statusCheckRollup: null } } } }); // no checkSuites key + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).toBeNull(); + stubGraphql({ data: { repository: { object: { statusCheckRollup: null, checkSuites: {} } } } }); // checkSuites.nodes not an array + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).toBeNull(); + }); + + it("falls back when a NON-null statusCheckRollup carries a malformed contexts connection", async () => { + stubGraphql({ data: { repository: { object: { statusCheckRollup: { contexts: {} }, checkSuites: { nodes: [] } } } } }); // contexts.nodes not an array + expect(await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN)).toBeNull(); + }); + + it("(REST) defaults a check-runs/status response that omits its array", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/check-runs") || url.includes("/status") || url.includes("/check-suites")) return Response.json({}); + return new Response("not found", { status: 404 }); + }); + expect((await fetchLiveCiAggregate(env, REPO, SHA, TOKEN))?.ciState).toBe("unverified"); + }); + + it("ignores rollup nodes that are neither CheckRun nor StatusContext", async () => { + stubGraphql({ data: { repository: { object: { statusCheckRollup: { contexts: { nodes: [runNode({ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }), { __typename: "Unknown" }], pageInfo: { hasNextPage: false } } }, checkSuites: { nodes: [] } } } } }); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"])))?.ciState).toBe("passed"); + }); + + it("is unverified when the commit has no checks at all", async () => { + stubGraphql(graphqlBody({ runs: [], statuses: [], suites: [] })); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, new Set(["build"])))?.ciState).toBe("pending"); // required 'build' absent → pending + stubGraphql(graphqlBody({ runs: [], statuses: [], suites: [] })); + expect((await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN))?.ciState).toBe("unverified"); // fold-all, nothing seen + }); +}); + +describe("fetchLiveCiAggregateViaGraphQl — equivalence with the REST path", () => { + const env = createTestEnv(); + const scenarios: Array<{ name: string; runs?: Run[]; statuses?: Status[]; suites?: Suite[]; required?: string[] }> = [ + { name: "all green", runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED", appSlug: "github-actions" }], statuses: [{ context: "codecov/patch", state: "SUCCESS" }], suites: [{ status: "COMPLETED", appSlug: "github-actions" }], required: ["build"] }, + { name: "failed check", runs: [{ name: "build", conclusion: "FAILURE", status: "COMPLETED", summary: "nope", detailsUrl: "https://x" }], required: ["build"] }, + { name: "failed status", statuses: [{ context: "codecov/patch", state: "ERROR", description: "coverage drop" }], required: ["build"] }, + { name: "pending required", runs: [{ name: "build", conclusion: null, status: "QUEUED" }], required: ["build"] }, + { name: "pending non-required passes", runs: [{ name: "opt", conclusion: null, status: "IN_PROGRESS" }, { name: "build", conclusion: "SUCCESS", status: "COMPLETED" }], required: ["build"] }, + { name: "missing required", runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }], required: ["build", "e2e"] }, + { name: "suite in progress", runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }], suites: [{ status: "IN_PROGRESS", appSlug: "github-actions" }], required: ["build"] }, + ]; + + for (const s of scenarios) { + it(`matches REST for: ${s.name}`, async () => { + const required = s.required ? new Set(s.required) : undefined; + stubRest({ runs: s.runs ?? [], statuses: s.statuses ?? [], suites: s.suites ?? [] }); + const rest = await fetchLiveCiAggregate(env, REPO, SHA, TOKEN, required); + stubGraphql(graphqlBody({ runs: s.runs ?? [], statuses: s.statuses ?? [], suites: s.suites ?? [] })); + const graphql = await fetchLiveCiAggregateViaGraphQl(env, REPO, SHA, TOKEN, required); + expect(graphql).toEqual(rest); + }); + } +}); + +describe("fetchLiveCiAggregatePreferGraphQl — flag routing + fallback", () => { + it("uses the REST aggregate when the flag is OFF (never issues a GraphQL query)", async () => { + const env = createTestEnv(); + stubRest({ runs: [{ name: "build", conclusion: "FAILURE", status: "COMPLETED" }], statuses: [], suites: [] }); + expect((await fetchLiveCiAggregatePreferGraphQl(env, REPO, SHA, TOKEN, new Set(["build"]))).ciState).toBe("failed"); + }); + + it("uses the GraphQL rollup when the flag is ON and the query succeeds", async () => { + const env = createTestEnv({ GITHUB_STATUS_ROLLUP_GRAPHQL: "true" }); + stubGraphql(graphqlBody({ runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }], suites: [{ status: "COMPLETED", appSlug: "github-actions" }] })); + expect((await fetchLiveCiAggregatePreferGraphQl(env, REPO, SHA, TOKEN, new Set(["build"]))).ciState).toBe("passed"); + }); + + it("falls back to REST when the flag is ON but the GraphQL rollup returns null (e.g. >100 contexts)", async () => { + const env = createTestEnv({ GITHUB_STATUS_ROLLUP_GRAPHQL: "true" }); + // Both endpoints stubbed: GraphQL says hasNextPage (→null), so the aggregate must come from the REST reads. + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.github.com/graphql") return Response.json(graphqlBody({ runs: [{ name: "build", conclusion: "SUCCESS", status: "COMPLETED" }], hasNextPage: true })); + if (url.includes("/check-runs")) return Response.json({ check_runs: [{ id: 1, name: "build", status: "completed", conclusion: "failure" }] }); + if (url.includes("/status")) return Response.json({ statuses: [] }); + if (url.includes("/check-suites")) return Response.json({ check_suites: [] }); + return new Response("not found", { status: 404 }); + }); + // REST sees a FAILURE → 'failed', proving the fallback path ran rather than the GraphQL SUCCESS. + expect((await fetchLiveCiAggregatePreferGraphQl(env, REPO, SHA, TOKEN, new Set(["build"]))).ciState).toBe("failed"); + }); +}); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 57bad5cf45..90cadf5069 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1058,7 +1058,7 @@ describe("queue processors", () => { await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto", update_branch: "auto" }, aiReviewMode: "off", gatePack: "oss-anti-slop", gateCheckMode: "enabled", checkRunMode: "off", commentMode: "off", publicSurface: "off" }); await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 7, title: "Missing aggregate CI", state: "open", user: { login: "contributor" }, head: { sha: "a7" }, base: { ref: "main" }, labels: [], body: "Closes #1" }); const requiredContextsSpy = vi.spyOn(backfillModule, "fetchRequiredStatusContexts").mockResolvedValue(null); - const liveCiSpy = vi.spyOn(backfillModule, "fetchLiveCiAggregate").mockResolvedValue({ + const liveCiSpy = vi.spyOn(backfillModule, "fetchLiveCiAggregatePreferGraphQl").mockResolvedValue({ ciState: "pending", hasPending: true, hasVisiblePending: false, @@ -1106,7 +1106,7 @@ describe("queue processors", () => { 7 * 24 * 3600, ); const requiredContextsSpy = vi.spyOn(backfillModule, "fetchRequiredStatusContexts").mockResolvedValue(null); - const liveCiSpy = vi.spyOn(backfillModule, "fetchLiveCiAggregate").mockResolvedValue({ + const liveCiSpy = vi.spyOn(backfillModule, "fetchLiveCiAggregatePreferGraphQl").mockResolvedValue({ ciState: "pending", hasPending: true, hasVisiblePending: false, @@ -5720,7 +5720,7 @@ describe("queue processors", () => { let gateFinalized = false; let failedPostGateMint = false; const liveCiSpy = vi - .spyOn(backfillModule, "fetchLiveCiAggregate") + .spyOn(backfillModule, "fetchLiveCiAggregatePreferGraphQl") .mockRejectedValueOnce(new Error("transient CI read failed")) .mockResolvedValue({ ciState: "passed", diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 039c7cd281..3ade3856b5 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 354e3d68e43acee221539019f838cdf2) +// Generated by Wrangler by running `wrangler types` (hash: 91fadccb351d39f2f35073218774fda6) // Runtime types generated with workerd@1.20260617.1 2026-05-28 nodejs_compat interface __BaseEnv_Env { DB: D1Database; @@ -27,6 +27,7 @@ interface __BaseEnv_Env { GITTENSORY_REVIEW_RAG: "false"; GITTENSORY_REVIEW_CONTENT_LANE: "false"; GITTENSORY_REVIEW_SELFTUNE: "false"; + GITHUB_STATUS_ROLLUP_GRAPHQL: "false"; GITTENSORY_REVIEW_PLANNER: "false"; GITTENSORY_REVIEW_DRAFT: "false"; GITTENSORY_REVIEW_PARITY_AUDIT: "false"; @@ -47,7 +48,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types diff --git a/wrangler.jsonc b/wrangler.jsonc index f4d8a43d8d..b2e5267e83 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -91,6 +91,12 @@ // identical to today. Config-application (reading a promoted override into the live gate) is a deferred // follow-up — see src/review/selftune-wire.ts. "GITTENSORY_REVIEW_SELFTUNE": "false", + // #1941: route the live CI aggregate (the gate's check/status read) through ONE GraphQL statusCheckRollup query + // instead of the paginated /check-runs + /status + /check-suites REST reads — moving that hot path onto the + // SEPARATE GraphQL rate-limit bucket. Default OFF: the gate uses the proven REST aggregate, byte-identical. When + // ON, the GraphQL path reuses the REST-resolved required contexts and still falls back to REST on any error, + // unexpected shape, or >100 rollup contexts. + "GITHUB_STATUS_ROLLUP_GRAPHQL": "false", // Convergence (#issue-coding-plan): the `@gittensory plan` command. Default OFF — `@gittensory plan` falls // through to the existing mention path (byte-identical). Hosted planning is retired with the Cloudflare AI // binding; self-host can run planning through the configured self-host AI provider.