diff --git a/scripts/github_completion_bridge.py b/scripts/github_completion_bridge.py index 0ea3b7d84..27eb9d413 100644 --- a/scripts/github_completion_bridge.py +++ b/scripts/github_completion_bridge.py @@ -24,6 +24,41 @@ def _recv() -> dict[str, Any]: return value +def parse_head_check_runs(result: Any, head_sha: str, observation_cls: Any) -> tuple[Any, ...]: + """Validate the host's check-run payload; fail closed on any irregularity. + + Each run must carry its own head_sha equal to the requested one. App + ``app_id`` (int) and ``workflow_path`` (str, "" for non-Actions apps) are + passed straight into the observation so the loop can pin the app/workflow. + """ + if not isinstance(head_sha, str) or len(head_sha) != 40 or any( + c not in "0123456789abcdef" for c in head_sha + ): + raise RuntimeError("HEAD_CHECK_RUNS_HEAD_SHA_INVALID") + if not isinstance(result, dict) or not isinstance(result.get("check_runs"), list): + raise RuntimeError("HEAD_CHECK_RUNS_RESPONSE_INVALID") + observations = [] + for item in result["check_runs"]: + if not isinstance(item, dict) or item.get("head_sha") != head_sha: + raise RuntimeError("HEAD_CHECK_RUNS_HEAD_SHA_MISMATCH") + app_id = item.get("app_id") + if not isinstance(app_id, int) or isinstance(app_id, bool): + raise RuntimeError("HEAD_CHECK_RUNS_APP_ID_INVALID") + if not isinstance(item.get("workflow_path"), str): + raise RuntimeError("HEAD_CHECK_RUNS_WORKFLOW_PATH_INVALID") + observations.append( + observation_cls( + name=item.get("name"), + status=item.get("status"), + conclusion=item.get("conclusion"), + head_sha=item.get("head_sha"), + app_id=app_id, + workflow_path=item["workflow_path"], + ) + ) + return tuple(observations) + + def main() -> int: start = _recv() if start.get("type") != "start": @@ -44,6 +79,7 @@ def main() -> int: CasMergeResult, CasMergeStatus, DimensionRevalidationReceipt, + HeadCheckRunObservation, IntegrationMaterializationResult, PostMergeReconciliationResult, make_dimension_revalidation_receipt, @@ -149,6 +185,10 @@ def read_required_checks(self, *, head_sha, generation, timeout_seconds=None): ) return tuple(CheckResult.model_validate(item) for item in result["checks"]) + def read_head_check_runs(self, *, head_sha): + result = self._call("read_head_check_runs", {"head_sha": head_sha}) + return parse_head_check_runs(result, head_sha, HeadCheckRunObservation) + def read_reviews(self): result = self._call("read_reviews") unresolved = int(result.get("unresolved_threads", 0)) diff --git a/scripts/test_github_completion_bridge.py b/scripts/test_github_completion_bridge.py new file mode 100644 index 000000000..5c4c7a66d --- /dev/null +++ b/scripts/test_github_completion_bridge.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Unit tests for parse_head_check_runs in github_completion_bridge.py (Nexus-new #2068).""" + +import importlib.util +import os +import sys +import unittest +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + "github_completion_bridge", Path(__file__).with_name("github_completion_bridge.py") +) +bridge = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(bridge) + +SHA = "a" * 40 + + +class Obs: + def __init__(self, *, name, status, conclusion, head_sha, app_id, workflow_path): + self.name, self.status, self.conclusion, self.head_sha = name, status, conclusion, head_sha + self.app_id, self.workflow_path = app_id, workflow_path + + +def run(**over): + base = {"name": "Exact-base impact gate", "status": "completed", "conclusion": "success", + "head_sha": SHA, "app_slug": "github-actions", "app_id": 15368, + "workflow_path": ".github/workflows/pytest.yml"} + base.update(over) + return base + + +class ParseHeadCheckRuns(unittest.TestCase): + def parse(self, result, sha=SHA): + return bridge.parse_head_check_runs(result, sha, Obs) + + def test_valid_runs(self): + out = self.parse({"check_runs": [run(), run(name="x", conclusion=None, status="queued")]}) + self.assertEqual([o.name for o in out], ["Exact-base impact gate", "x"]) + self.assertIsNone(out[1].conclusion) + self.assertEqual(out[0].app_id, 15368) + self.assertEqual(out[0].workflow_path, ".github/workflows/pytest.yml") + + def test_non_actions_app_has_empty_workflow_path(self): + out = self.parse({"check_runs": [run(app_id=7, workflow_path="")]}) + self.assertEqual((out[0].app_id, out[0].workflow_path), (7, "")) + + def test_missing_or_bad_app_fields_fail_closed(self): + for bad in [{"app_id": None}, {"app_id": "1"}, {"app_id": True}, {"workflow_path": None}, {"workflow_path": 3}]: + with self.assertRaises(RuntimeError): + self.parse({"check_runs": [run(**bad)]}) + + def test_empty_ok_when_host_says_empty(self): + self.assertEqual(self.parse({"check_runs": []}), ()) + + def test_bad_requested_sha(self): + for bad in ["abc", "A" * 40, None]: + with self.assertRaises(RuntimeError): + self.parse({"check_runs": []}, bad) + + def test_mismatched_run_sha(self): + with self.assertRaises(RuntimeError): + self.parse({"check_runs": [run(head_sha="b" * 40)]}) + + def test_malformed_payload(self): + for bad in [None, [], {}, {"check_runs": None}, {"check_runs": ["x"]}]: + with self.assertRaises(RuntimeError): + self.parse(bad) + + +def _load_real_observation(): + """Import the real HeadCheckRunObservation from a Nexus-new checkout. + + Root: $NEXUS_NEW_ROOT or ~/workspace/Nexus-new. Returns (cls, None) or + (None, reason) when it cannot be imported (the contract test then skips). + """ + root = Path(os.environ.get("NEXUS_NEW_ROOT", Path.home() / "workspace" / "Nexus-new")) + if not (root / "nexus" / "orchestrator" / "github_completion_loop.py").is_file(): + return None, f"no Nexus-new checkout at {root}" + sys.path.insert(0, str(root)) + try: + from nexus.orchestrator.github_completion_loop import HeadCheckRunObservation # noqa: PLC0415 + except Exception as exc: # noqa: BLE001 - any import failure means skip + return None, f"cannot import HeadCheckRunObservation from {root}: {type(exc).__name__}: {exc}" + return HeadCheckRunObservation, None + + +REAL_OBS, REAL_SKIP_REASON = _load_real_observation() + + +class RealModelContract(unittest.TestCase): + """Feeds host-shaped payloads through the REAL Nexus-new model (no fake).""" + + def setUp(self): + if REAL_OBS is None: + print(f"SKIP RealModelContract: {REAL_SKIP_REASON}", file=sys.stderr) + self.skipTest(REAL_SKIP_REASON) + + def test_realistic_mix_validates_with_real_model(self): + payload = {"check_runs": [ + run(name="Exact-base impact gate", workflow_path=".github/workflows/pytest.yml"), + run(name="Trusted impact gate (default branch)", + workflow_path=".github/workflows/trusted-impact-gate.yml"), + run(name="third-party-scan", app_id=7, app_slug="other-app", workflow_path=""), + ]} + out = bridge.parse_head_check_runs(payload, SHA, REAL_OBS) + self.assertEqual([o.app_id for o in out], [15368, 15368, 7]) + self.assertEqual( + [o.workflow_path for o in out], + [".github/workflows/pytest.yml", ".github/workflows/trusted-impact-gate.yml", ""], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/git-pr-merge.ts b/src/git-pr-merge.ts index d6de9fb75..b4ee6a581 100644 --- a/src/git-pr-merge.ts +++ b/src/git-pr-merge.ts @@ -410,6 +410,39 @@ export interface GitHubCompletionTransport extends GitHubPullRequestTransport { compareChangedFiles(repo: string, oldSha: string, newSha: string): Promise; getPullRequestReviewState(repo: string, prNumber: number): Promise; isPlatformApprovalRequired(repo: string, branch: string): Promise; + /** + * One page of `GET /repos/{repo}/commits/{sha}/check-runs?filter=latest`. + * Rejects on any API error or malformed body; never returns an empty page + * to represent a failure. + */ + getCheckRunsPage( + repo: string, + headSha: string, + page: number, + perPage: number, + ): Promise; + /** + * One page of `GET /repos/{repo}/actions/runs?head_sha={sha}`. Rejects on any + * API error or malformed body. + */ + getWorkflowRunsPage( + repo: string, + headSha: string, + page: number, + perPage: number, + ): Promise; +} + +/** Raw (unvalidated) Actions workflow-run page. */ +export interface WorkflowRunsPageView { + totalCount: number; + workflowRuns: unknown[]; +} + +/** Raw (unvalidated) check-run page; the completion host validates each run. */ +export interface CheckRunsPageView { + totalCount: number; + checkRuns: unknown[]; } /** @@ -715,6 +748,52 @@ export class GhCliGitHubTransport implements GitHubCompletionTransport { }); } + async getCheckRunsPage( + repo: string, + headSha: string, + page: number, + perPage: number, + ): Promise { + const body = await this.apiJson([ + "api", + `repos/${repo}/commits/${headSha}/check-runs?filter=latest&per_page=${perPage}&page=${page}`, + ]); + if (!body || Array.isArray(body)) throw new Error("CHECK_RUNS_RESPONSE_INVALID"); + const record = body as { total_count?: unknown; check_runs?: unknown }; + if ( + typeof record.total_count !== "number" + || !Number.isInteger(record.total_count) + || record.total_count < 0 + || !Array.isArray(record.check_runs) + ) { + throw new Error("CHECK_RUNS_RESPONSE_INVALID"); + } + return { totalCount: record.total_count, checkRuns: record.check_runs }; + } + + async getWorkflowRunsPage( + repo: string, + headSha: string, + page: number, + perPage: number, + ): Promise { + const body = await this.apiJson([ + "api", + `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=${perPage}&page=${page}`, + ]); + if (!body || Array.isArray(body)) throw new Error("WORKFLOW_RUNS_RESPONSE_INVALID"); + const record = body as { total_count?: unknown; workflow_runs?: unknown }; + if ( + typeof record.total_count !== "number" + || !Number.isInteger(record.total_count) + || record.total_count < 0 + || !Array.isArray(record.workflow_runs) + ) { + throw new Error("WORKFLOW_RUNS_RESPONSE_INVALID"); + } + return { totalCount: record.total_count, workflowRuns: record.workflow_runs }; + } + async getCommitStatus(repo: string, headSha: string): Promise { const body = await this.apiJson([ "api", diff --git a/src/github-completion-host.test.ts b/src/github-completion-host.test.ts index db0069e3b..31519e879 100644 --- a/src/github-completion-host.test.ts +++ b/src/github-completion-host.test.ts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { MAX_COMPLETION_INTEGRATION_GENERATIONS, isTransientRequiredChecksDecision, parseCompletionGeneration, parseGitHubCompletionToolInput, + readHeadCheckRuns, } from "./github-completion-host.js"; const missingReason = "no check run or commit status matches the required context for the expected head SHA"; @@ -87,3 +90,214 @@ describe("completion integration generation cap (#1384)", () => { } }); }); + +describe("readHeadCheckRuns (Nexus-new #2068)", () => { + const SHA = "a".repeat(40); + const run = (n: number, over: Record = {}) => ({ + name: `check-${n}`, + status: "completed", + conclusion: "success", + head_sha: SHA, + app: { id: 15368, slug: "github-actions" }, + check_suite: { id: 1000 + n }, + ...over, + }); + const wf = (n: number, over: Record = {}) => ({ + id: 5000 + n, + check_suite_id: 1000 + n, + path: ".github/workflows/pytest.yml", + head_sha: SHA, + ...over, + }); + type WfPage = { total: number; runs: unknown[] } | Error; + const defaultWf = (count: number): WfPage[] => [{ total: count, runs: Array.from({ length: count }, (_, i) => wf(i + 1)) }]; + const pagedTransport = (pages: Array<{ total: number; runs: unknown[] } | Error>, wfPages?: WfPage[]) => { + const calls: Array<[string, string, number, number]> = []; + const wfCalls: number[] = []; + const wfSource = wfPages ?? defaultWf(pages.reduce((n, p) => n + (p instanceof Error ? 0 : p.runs.length), 0)); + return { + calls, + wfCalls, + getWorkflowRunsPage: async (_repo: string, sha: string, page: number, _perPage: number) => { + assert.equal(sha, SHA); + wfCalls.push(page); + const entry = wfSource[page - 1]; + if (!entry) throw new Error("unexpected extra workflow page"); + if (entry instanceof Error) throw entry; + return { totalCount: entry.total, workflowRuns: entry.runs }; + }, + getCheckRunsPage: async (repo: string, sha: string, page: number, perPage: number) => { + calls.push([repo, sha, page, perPage]); + const entry = pages[page - 1]; + if (!entry) throw new Error("unexpected extra page"); + if (entry instanceof Error) throw entry; + return { totalCount: entry.total, checkRuns: entry.runs }; + }, + }; + }; + + it("paginates through every page and returns app identity", async () => { + const t = pagedTransport([ + { total: 3, runs: [run(1), run(2)] }, + { total: 3, runs: [run(3, { app: { id: 7, slug: "other" } })] }, + ], defaultWf(2)); + const out = await readHeadCheckRuns(t, "o/r", SHA); + assert.deepEqual(out.map((r) => r.name), ["check-1", "check-2", "check-3"]); + assert.deepEqual(t.calls.map((c) => c[2]), [1, 2]); + assert.equal(out[0].app_slug, "github-actions"); + assert.equal(out[0].app_id, 15368); + assert.equal(out[0].workflow_path, ".github/workflows/pytest.yml"); + assert.equal(out[2].app_id, 7); + assert.equal(out[2].workflow_path, ""); + assert.equal(out[0].head_sha, SHA); + }); + + it("returns empty only when GitHub reports total_count 0", async () => { + assert.deepEqual(await readHeadCheckRuns(pagedTransport([{ total: 0, runs: [] }]), "o/r", SHA), []); + }); + + it("rejects a non-40-hex requested SHA before any API call", async () => { + for (const bad of ["abc", "A".repeat(40), "g".repeat(40), 5, null]) { + const t = pagedTransport([{ total: 0, runs: [] }]); + await assert.rejects(() => readHeadCheckRuns(t, "o/r", bad), /check_runs_head_sha_INVALID/); + assert.equal(t.calls.length, 0); + } + }); + + it("rejects any run whose head_sha differs from the requested SHA", async () => { + const t = pagedTransport([{ total: 2, runs: [run(1), run(2, { head_sha: "b".repeat(40) })] }]); + await assert.rejects(() => readHeadCheckRuns(t, "o/r", SHA), /CHECK_RUN_HEAD_SHA_MISMATCH/); + }); + + it("fails closed on API error instead of returning an empty list", async () => { + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([new Error("HTTP 502")]), "o/r", SHA), + /HTTP 502/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 2, runs: [run(1)] }, new Error("HTTP 500")]), "o/r", SHA), + /HTTP 500/, + ); + }); + + it("fails closed on incomplete, shifting or malformed pagination", async () => { + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 3, runs: [run(1)] }, { total: 3, runs: [] }]), "o/r", SHA), + /PAGINATION_INCOMPLETE/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 2, runs: [run(1)] }, { total: 3, runs: [run(2)] }]), "o/r", SHA), + /TOTAL_CHANGED/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 1, runs: [run(1), run(2)] }]), "o/r", SHA), + /COUNT_MISMATCH/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 1, runs: [run(1, { status: 3 })] }]), "o/r", SHA), + /STATUS_INVALID/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 1, runs: ["x"] }]), "o/r", SHA), + /CHECK_RUN_INVALID/, + ); + }); + + it("keeps a null conclusion", async () => { + const out = await readHeadCheckRuns( + pagedTransport([{ total: 1, runs: [run(1, { status: "in_progress", conclusion: null })] }]), + "o/r", + SHA, + ); + assert.equal(out[0].conclusion, null); + }); + + it("requires an integer app_id on every run", async () => { + for (const app of [null, { slug: "x" }, { id: "1", slug: "x" }]) { + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 1, runs: [run(1, { app })] }]), "o/r", SHA), + /APP_ID_INVALID/, + ); + } + }); + + it("does not query workflow runs when no run is from GitHub Actions", async () => { + const t = pagedTransport([{ total: 1, runs: [run(1, { app: { id: 7, slug: "other" } })] }], []); + const out = await readHeadCheckRuns(t, "o/r", SHA); + assert.equal(out[0].workflow_path, ""); + assert.deepEqual(t.wfCalls, []); + }); + + it("paginates workflow runs and maps check_suite_id to path", async () => { + const t = pagedTransport( + [{ total: 2, runs: [run(1), run(2)] }], + [ + { total: 2, runs: [wf(1, { path: ".github/workflows/a.yml" })] }, + { total: 2, runs: [wf(2, { path: ".github/workflows/b.yml" })] }, + ], + ); + const out = await readHeadCheckRuns(t, "o/r", SHA); + assert.deepEqual(out.map((r) => r.workflow_path), [".github/workflows/a.yml", ".github/workflows/b.yml"]); + assert.deepEqual(t.wfCalls, [1, 2]); + }); + + it("resolves several check runs sharing one pull_request_target suite (real Nexus-new shape)", async () => { + // Real data (PR head cae34255): pull_request_target workflow runs carry the PR head_sha, + // and one suite (trusted-impact-gate.yml) backs three check runs. + const suite = { id: 104027917083 }; + const t = pagedTransport( + [{ total: 3, runs: [ + run(1, { name: "Trusted impact controller (default branch)", check_suite: suite }), + run(2, { name: "Unprivileged impact executor (exact head)", check_suite: suite }), + run(3, { name: "Exact-base impact gate" }), + ] }], + [{ total: 2, runs: [ + wf(0, { check_suite_id: 104027917083, path: ".github/workflows/trusted-impact-gate.yml", event: "pull_request_target" }), + wf(3, { path: ".github/workflows/pytest.yml", event: "pull_request" }), + ] }], + ); + const out = await readHeadCheckRuns(t, "o/r", SHA); + assert.deepEqual(out.map((r) => r.workflow_path), [ + ".github/workflows/trusted-impact-gate.yml", + ".github/workflows/trusted-impact-gate.yml", + ".github/workflows/pytest.yml", + ]); + }); + + it("fails closed when an Actions run's workflow cannot be resolved", async () => { + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 1, runs: [run(1)] }], [{ total: 0, runs: [] }]), "o/r", SHA), + /WORKFLOW_UNRESOLVED/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport([{ total: 1, runs: [run(1, { check_suite: null })] }]), "o/r", SHA), + /CHECK_SUITE_ID_INVALID/, + ); + }); + + it("fails closed on workflow-run API error, SHA mismatch, bad path or conflicting paths", async () => { + const one = [{ total: 1, runs: [run(1)] }]; + await assert.rejects(() => readHeadCheckRuns(pagedTransport(one, [new Error("HTTP 503")]), "o/r", SHA), /HTTP 503/); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport(one, [{ total: 1, runs: [wf(1, { head_sha: "b".repeat(40) })] }]), "o/r", SHA), + /WORKFLOW_RUN_HEAD_SHA_MISMATCH/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport(one, [{ total: 1, runs: [wf(1, { path: "" })] }]), "o/r", SHA), + /WORKFLOW_RUN_PATH_INVALID/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport(one, [{ total: 2, runs: [wf(1), wf(1, { id: 9, path: ".github/workflows/x.yml" })] }]), "o/r", SHA), + /WORKFLOW_PATH_CONFLICT/, + ); + await assert.rejects( + () => readHeadCheckRuns(pagedTransport(one, [{ total: 2, runs: [wf(1)] }, { total: 2, runs: [] }]), "o/r", SHA), + /PAGINATION_INCOMPLETE/, + ); + }); + + it("bridge parse_head_check_runs validates payloads (python unittest)", () => { + const script = fileURLToPath(new URL("../scripts/test_github_completion_bridge.py", import.meta.url)); + execFileSync("python3", ["-I", "-B", script], { stdio: "pipe" }); + }); +}); diff --git a/src/github-completion-host.ts b/src/github-completion-host.ts index 9c693a7d2..f22d4a771 100644 --- a/src/github-completion-host.ts +++ b/src/github-completion-host.ts @@ -105,6 +105,135 @@ export function isTransientRequiredChecksDecision( ); } +const CHECK_RUNS_PER_PAGE = 100; +const CHECK_RUNS_MAX_PAGES = 50; +/** GitHub Actions app id; only its check runs are bound to a workflow file. */ +const GITHUB_ACTIONS_APP_ID = 15368; + +export interface HeadCheckRunWire { + name: string; + status: string; + conclusion: string | null; + head_sha: string; + app_id: number; + app_slug: string | null; + /** Workflow file path for GitHub Actions runs; "" for any other app. */ + workflow_path: string; +} + +type PageView = { totalCount: number; items: unknown[] }; + +/** Fetch every page; throws on any error, shifting total, or short pagination. */ +async function collectAllPages( + fetchPage: (page: number) => Promise, + label: string, + onItem: (raw: unknown) => void, +): Promise { + let expectedTotal: number | undefined; + let seen = 0; + for (let page = 1; page <= CHECK_RUNS_MAX_PAGES; page += 1) { + const view = await fetchPage(page); + if (!Number.isInteger(view.totalCount) || view.totalCount < 0 || !Array.isArray(view.items)) { + throw new Error(`${label}_RESPONSE_INVALID`); + } + if (expectedTotal === undefined) expectedTotal = view.totalCount; + else if (view.totalCount !== expectedTotal) throw new Error(`${label}_TOTAL_CHANGED_DURING_PAGINATION`); + for (const raw of view.items) { + onItem(raw); + seen += 1; + } + if (seen >= expectedTotal) { + if (seen !== expectedTotal) throw new Error(`${label}_COUNT_MISMATCH`); + return; + } + if (view.items.length === 0) throw new Error(`${label}_PAGINATION_INCOMPLETE`); + } + throw new Error(`${label}_PAGINATION_LIMIT_EXCEEDED`); +} + +/** + * Read every latest check-run GitHub reports for exact `headSha` + * (`filter=latest`, all pages) and bind each GitHub Actions run to its workflow + * file via `actions/runs?head_sha=` (check_suite_id -> path). Fails closed: any + * API/shape/pagination/SHA/workflow-resolution error throws; an empty array is + * returned only when GitHub itself reports `total_count: 0`. + */ +export async function readHeadCheckRuns( + transport: Pick, + repo: string, + headSha: unknown, +): Promise { + const sha = normalizeSha(headSha, "check_runs_head_sha"); + const parsed: Array & { suiteId: number | null }> = []; + await collectAllPages( + async (page) => { + const v = await transport.getCheckRunsPage(repo, sha, page, CHECK_RUNS_PER_PAGE); + return { totalCount: v.totalCount, items: v.checkRuns }; + }, + "CHECK_RUNS", + (raw) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("CHECK_RUN_INVALID"); + const r = raw as { + name?: unknown; status?: unknown; conclusion?: unknown; head_sha?: unknown; + app?: unknown; check_suite?: unknown; + }; + if (typeof r.name !== "string" || r.name === "") throw new Error("CHECK_RUN_NAME_INVALID"); + if (typeof r.status !== "string" || r.status === "") throw new Error("CHECK_RUN_STATUS_INVALID"); + if (r.conclusion !== null && r.conclusion !== undefined && typeof r.conclusion !== "string") { + throw new Error("CHECK_RUN_CONCLUSION_INVALID"); + } + if (typeof r.head_sha !== "string" || !FULL_SHA.test(r.head_sha)) throw new Error("CHECK_RUN_HEAD_SHA_INVALID"); + if (r.head_sha !== sha) throw new Error("CHECK_RUN_HEAD_SHA_MISMATCH"); + const app = r.app && typeof r.app === "object" ? (r.app as { id?: unknown; slug?: unknown }) : null; + if (!app || typeof app.id !== "number" || !Number.isInteger(app.id)) throw new Error("CHECK_RUN_APP_ID_INVALID"); + const suite = r.check_suite && typeof r.check_suite === "object" + ? (r.check_suite as { id?: unknown }) + : null; + const suiteId = suite && typeof suite.id === "number" && Number.isInteger(suite.id) ? suite.id : null; + if (app.id === GITHUB_ACTIONS_APP_ID && suiteId === null) throw new Error("CHECK_RUN_CHECK_SUITE_ID_INVALID"); + parsed.push({ + name: r.name, + status: r.status, + conclusion: typeof r.conclusion === "string" ? r.conclusion : null, + head_sha: r.head_sha, + app_id: app.id, + app_slug: typeof app.slug === "string" ? app.slug : null, + suiteId, + }); + }, + ); + + const pathBySuite = new Map(); + if (parsed.some((r) => r.app_id === GITHUB_ACTIONS_APP_ID)) { + await collectAllPages( + async (page) => { + const v = await transport.getWorkflowRunsPage(repo, sha, page, CHECK_RUNS_PER_PAGE); + return { totalCount: v.totalCount, items: v.workflowRuns }; + }, + "WORKFLOW_RUNS", + (raw) => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("WORKFLOW_RUN_INVALID"); + const w = raw as { check_suite_id?: unknown; path?: unknown; head_sha?: unknown }; + if (w.head_sha !== sha) throw new Error("WORKFLOW_RUN_HEAD_SHA_MISMATCH"); + if (typeof w.check_suite_id !== "number" || !Number.isInteger(w.check_suite_id)) { + throw new Error("WORKFLOW_RUN_CHECK_SUITE_ID_INVALID"); + } + if (typeof w.path !== "string" || w.path === "") throw new Error("WORKFLOW_RUN_PATH_INVALID"); + const existing = pathBySuite.get(w.check_suite_id); + if (existing !== undefined && existing !== w.path) throw new Error("WORKFLOW_PATH_CONFLICT"); + pathBySuite.set(w.check_suite_id, w.path); + }, + ); + } + + return parsed.map(({ suiteId, ...run }) => { + if (run.app_id !== GITHUB_ACTIONS_APP_ID) return { ...run, workflow_path: "" }; + const path = pathBySuite.get(suiteId as number); + if (path === undefined) throw new Error("CHECK_RUN_WORKFLOW_UNRESOLVED"); + return { ...run, workflow_path: path }; + }); +} + export function parseGitHubCompletionToolInput(raw: Record): GitHubCompletionToolInput { const initialEvidence = raw.initialEvidence; const standingGrantRequest = raw.standingGrantRequest; @@ -160,6 +289,8 @@ function transportWithCompletion( "compareChangedFiles", "getPullRequestReviewState", "isPlatformApprovalRequired", + "getCheckRunsPage", + "getWorkflowRunsPage", ] as const; for (const name of required) { if (typeof candidate[name] !== "function") { @@ -249,6 +380,8 @@ class CompletionHostPort { return await this.materialize(params); case "read_required_checks": return await this.readRequiredChecks(params); + case "read_head_check_runs": + return { check_runs: await readHeadCheckRuns(this.transport, REPOSITORY, params.head_sha) }; case "read_reviews": { const state = await this.transport.getPullRequestReviewState(REPOSITORY, this.prNumber); const initialReviews = Array.isArray(this.input.initialEvidence.reviews)