From 1910e39093472ce67ed5515f3b1f0aa07facd316 Mon Sep 17 00:00:00 2001 From: James3014 <185240413+James3014@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:50:45 +0800 Subject: [PATCH 1/5] feat: read_head_check_runs completion port method (Nexus-new #2068) Host + bridge: paginated GET commits/{sha}/check-runs?filter=latest, 40-hex and per-run head_sha validation, app slug/id, fail closed on any error. Co-Authored-By: Claude Sonnet 5.5 --- .../github_completion_bridge.cpython-314.pyc | Bin 0 -> 17175 bytes scripts/github_completion_bridge.py | 45 ++++++++ scripts/test_github_completion_bridge.py | 57 +++++++++ src/git-pr-merge.ts | 40 +++++++ src/github-completion-host.test.ts | 109 ++++++++++++++++++ src/github-completion-host.ts | 65 +++++++++++ 6 files changed, 316 insertions(+) create mode 100644 scripts/__pycache__/github_completion_bridge.cpython-314.pyc create mode 100644 scripts/test_github_completion_bridge.py diff --git a/scripts/__pycache__/github_completion_bridge.cpython-314.pyc b/scripts/__pycache__/github_completion_bridge.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a79f52abf010cd9eeb9fcd2f47226e0fbab2095 GIT binary patch literal 17175 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 A2LJ#7 literal 0 HcmV?d00001 diff --git a/scripts/github_completion_bridge.py b/scripts/github_completion_bridge.py index 0ea3b7d84..45cde3d43 100644 --- a/scripts/github_completion_bridge.py +++ b/scripts/github_completion_bridge.py @@ -24,6 +24,34 @@ 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 + identity (app_slug/app_id) is kept out of the frozen, extra="forbid" + observation model and is returned separately via ``HostPort.last_head_check_run_apps``. + """ + 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") + observations.append( + observation_cls( + name=item.get("name"), + status=item.get("status"), + conclusion=item.get("conclusion"), + head_sha=item.get("head_sha"), + ) + ) + return tuple(observations) + + def main() -> int: start = _recv() if start.get("type") != "start": @@ -44,6 +72,7 @@ def main() -> int: CasMergeResult, CasMergeStatus, DimensionRevalidationReceipt, + HeadCheckRunObservation, IntegrationMaterializationResult, PostMergeReconciliationResult, make_dimension_revalidation_receipt, @@ -53,6 +82,8 @@ def main() -> int: class HostPort: def __init__(self) -> None: self._next_id = 1 + # App identity per observed check-run (slug/id), for optional app pinning. + self.last_head_check_run_apps: tuple[dict[str, Any], ...] = () def _call(self, method: str, params: dict[str, Any] | None = None) -> Any: call_id = self._next_id @@ -149,6 +180,20 @@ 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}) + observations = parse_head_check_runs(result, head_sha, HeadCheckRunObservation) + self.last_head_check_run_apps = tuple( + { + "name": item["name"], + "head_sha": item["head_sha"], + "app_slug": item.get("app_slug"), + "app_id": item.get("app_id"), + } + for item in result["check_runs"] + ) + return observations + 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..7f7c72764 --- /dev/null +++ b/scripts/test_github_completion_bridge.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Unit tests for parse_head_check_runs in github_completion_bridge.py (Nexus-new #2068).""" + +import importlib.util +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): + self.name, self.status, self.conclusion, self.head_sha = name, status, conclusion, head_sha + + +def run(**over): + base = {"name": "Exact-base impact gate", "status": "completed", "conclusion": "success", + "head_sha": SHA, "app_slug": "github-actions", "app_id": 15368} + 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) + + 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) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/git-pr-merge.ts b/src/git-pr-merge.ts index d6de9fb75..8c0762d1e 100644 --- a/src/git-pr-merge.ts +++ b/src/git-pr-merge.ts @@ -410,6 +410,23 @@ 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; +} + +/** Raw (unvalidated) check-run page; the completion host validates each run. */ +export interface CheckRunsPageView { + totalCount: number; + checkRuns: unknown[]; } /** @@ -715,6 +732,29 @@ 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 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..b8f98b5f9 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,109 @@ 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" }, + ...over, + }); + const pagedTransport = (pages: Array<{ total: number; runs: unknown[] } | Error>) => { + const calls: Array<[string, string, number, number]> = []; + return { + calls, + 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" } })] }, + ]); + 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[2].app_id, 7); + 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 and null app identity", async () => { + const out = await readHeadCheckRuns( + pagedTransport([{ total: 1, runs: [run(1, { status: "in_progress", conclusion: null, app: null })] }]), + "o/r", + SHA, + ); + assert.equal(out[0].conclusion, null); + assert.equal(out[0].app_slug, null); + assert.equal(out[0].app_id, null); + }); + + 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", script], { stdio: "pipe" }); + }); +}); diff --git a/src/github-completion-host.ts b/src/github-completion-host.ts index 9c693a7d2..3c47e372d 100644 --- a/src/github-completion-host.ts +++ b/src/github-completion-host.ts @@ -105,6 +105,68 @@ export function isTransientRequiredChecksDecision( ); } +const CHECK_RUNS_PER_PAGE = 100; +const CHECK_RUNS_MAX_PAGES = 50; + +export interface HeadCheckRunWire { + name: string; + status: string; + conclusion: string | null; + head_sha: string; + app_slug: string | null; + app_id: number | null; +} + +/** + * Read every latest check-run GitHub reports for exact `headSha` + * (`filter=latest`, all pages). Fails closed: any API/shape/pagination/SHA + * 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 runs: HeadCheckRunWire[] = []; + let expectedTotal: number | undefined; + for (let page = 1; page <= CHECK_RUNS_MAX_PAGES; page += 1) { + const view = await transport.getCheckRunsPage(repo, sha, page, CHECK_RUNS_PER_PAGE); + if (!Number.isInteger(view.totalCount) || view.totalCount < 0 || !Array.isArray(view.checkRuns)) { + throw new Error("CHECK_RUNS_RESPONSE_INVALID"); + } + if (expectedTotal === undefined) expectedTotal = view.totalCount; + else if (view.totalCount !== expectedTotal) throw new Error("CHECK_RUNS_TOTAL_CHANGED_DURING_PAGINATION"); + for (const raw of view.checkRuns) { + 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 }; + 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; + runs.push({ + name: r.name, + status: r.status, + conclusion: typeof r.conclusion === "string" ? r.conclusion : null, + head_sha: r.head_sha, + app_slug: app && typeof app.slug === "string" ? app.slug : null, + app_id: app && typeof app.id === "number" ? app.id : null, + }); + } + if (runs.length >= expectedTotal) { + if (runs.length !== expectedTotal) throw new Error("CHECK_RUNS_COUNT_MISMATCH"); + return runs; + } + if (view.checkRuns.length === 0) throw new Error("CHECK_RUNS_PAGINATION_INCOMPLETE"); + } + throw new Error("CHECK_RUNS_PAGINATION_LIMIT_EXCEEDED"); +} + export function parseGitHubCompletionToolInput(raw: Record): GitHubCompletionToolInput { const initialEvidence = raw.initialEvidence; const standingGrantRequest = raw.standingGrantRequest; @@ -160,6 +222,7 @@ function transportWithCompletion( "compareChangedFiles", "getPullRequestReviewState", "isPlatformApprovalRequired", + "getCheckRunsPage", ] as const; for (const name of required) { if (typeof candidate[name] !== "function") { @@ -249,6 +312,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) From a50d711fcf9fd586c3490b4d9b9e6db347d225e4 Mon Sep 17 00:00:00 2001 From: James3014 <185240413+James3014@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:52:29 +0800 Subject: [PATCH 2/5] chore: drop accidentally committed pyc; run bridge test with -B Co-Authored-By: Claude Sonnet 5.5 --- .../github_completion_bridge.cpython-314.pyc | Bin 17175 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 scripts/__pycache__/github_completion_bridge.cpython-314.pyc diff --git a/scripts/__pycache__/github_completion_bridge.cpython-314.pyc b/scripts/__pycache__/github_completion_bridge.cpython-314.pyc deleted file mode 100644 index 8a79f52abf010cd9eeb9fcd2f47226e0fbab2095..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17175 zcmcJ0dvH@(p5E2_VOf?f`Gsu|=4tSwdB4oV;5T5KYa}u4%%qaZUuJ=!XHu2q z`<<(MB^%jvPb#NM=bn4+d7t0&J4f4{g?0|%=WG7sJ1yHe?q~F(2Ah)5J^wWl;~dXP z+$E0Jb?G|wl71bf^<4(Zz@A3Qh^L{;)M=K??3=O6(rJ~f>p0$2&hh4QF6xqOyaj11 zOWS!H(sq_E;0us;u(ac1Az!%vJjaEKIIfzj;x0PZa~B=!x!sv@?ba!YVI8mEzRQY0SpN8|Ct*1E!z#o|(ca>X9J8~Xy%FrJ2% z*jSKen3*MKMB0GY=UybnIUjdPzyG(fI>@W$&gifTSO?y~8=J5ib*5kFQA{>n3q(i5 zzjlTqLFsK#66(EZ_M&VS!qTV^lZ`#`Sok4_?#x4RVI&}l)LX55w&|=G7R07^0wZCu z>3UpvSEQ*mU6&d{;cG125F8E%-^J|2CLx@NH;F+Zl90rv*XOD%RAXXHcKH37*@pdo z!HKRM_=&V9DwuPc8>(Zt$CdQ7#lx99lEv6Y|%1uK4RCH@wPac+?ESm>iUFAII9 zWf^!KG^=@i)Yh`F9&5{Uyph-KLoXC~eZ7)kah68j&~rgfrA$(#U2ubec4?qaFWbbp zAo<@7kBPEf3?~9YK#B{Zth4vvqpn`o9jr6U1`(glq7;gcO0wy?5Rt;N@f|T9lTD$~ zk%TClhN7b)2uHA>E4GcBg+e4{lV1$SLP9mtY_<0xG0r_TTE5l&jqU}bXU^#PVBqeB zKe#Yun)b|Yn=d_`>>GGeIxufMyQI)3#xen%sBDEwl(gV%E2#OEh_XqCsTu@Ybb(}v zK#MNW>R`BROpNN-a2Zvws2JZxHzFbpi>~+?MYrE)4`m?urUfHXY#^T?@te?ux-$vv z{+3L$S24J1Zje@#1)fv)epolCQBEI|oN5{6Ni?LEP3Q(`BUte128rxh*qx!)6?@aE zEez{?w1j%OsI#|!z<-?YYVBzAxAnHm)wvmbTYq0~Pk)=gtH;-Js;jlmDwLro*#xqQ z#AK@w4unW{$qs%rCPhZVZGsRNgi2N>8V`g(fDthg6Qw{b2=XGKfliH7MjMEJvjg@W3T3u;-h11aahr1fCBz%_pAb$X*kXWLBOu0(Gn1bGvL zp_#Wd8L@CyMtdan#L=iE5Ut48VXTH<91eiBBtb5UUxCEC#^kU+7!?_z-Yk+vA+g_v z%&hk|Ha1>-Bhgl&X;B&5E3Q(v?ioc>8y4v??PiZzkv$&YV&9Z0X5b}|&ham#5Zbdpi3ChL3}?OfWr|2oHAJ;c6ia_^AtjIo;wforQd$Rb@i zT6bxhwHw{27FuJGOdhVKP$_^yqeckCms zK~QU~<4%5$Y@9C;jf4VHn3+gq8Ep~0%yMXe>EKNS#z^iQ z@(u+eQExCB7sDZMJmw9=#=H?h2wxqI2B7N38twMBKya9hDeuTA%&TBP5Ku%Cz47ZY zugc2a@YhEJQL??rAQSKw7A4kVxY6rvNhIu%P#BU=8uM-qBoclxI(oH<5|L1yHzIo9 z4JRZo6s$Ks#9DxDcQYKT_l9psLf~*MOr$H3P^~>jRCq_?p>P!A4N-l?szQeuH6D(R zd9OtR-piLeG4npC`Nn8Kl>BVGY!&>Nksl)vFJEr7FEMgRBNK;Bl1?59%6Xe;Kat}XgnB*iboo?lC`KWRv`KX1_e{?gwFhF z!=4AaWbuYitQ&rK{#PQ=%5N3Bbp!3Px3jIK)qkS1?Zinxf3~OJ&))kxTU0G2+qIPt zUoX8}rwQB9w7h=BMo)M3pKcjA(J32auwBVw3P_{SYr%Ld2m=XLcAZf;fHs7K6hSO< ztjRjNu!++3h-5=F9Fq+oLSX~tK?))7YFHvwC`z&!8wQg~HZko`XA)?OSSvyU6?Nng zh$tIjs*Q*wnKy6VtSZmaylXAAq7+jhUTp9<_rl3J$|i=tZ&)bU_;JC;j|>kwe^@wE zGQaiEBk^B~u#T0Le{P_}Bk_^=ljWH(s*mZ+|G?rGX0E6zS#>z&Jd(5?AYdr@)ZU)?zJZ19XFuM4mg(b6@wB^i{8ZXiHu2tqYh%*2@wV}4 zQRU>u?;972Hho;Q>4E1*6%Q+>x25V1&sNOWbv-Rvn_PE1RdOQfIFYt`7OYis)~Zh( zRq4{oyXWtmpBze+Zc3L|-Mw+=#*{TxUYD*~cYpuA{Zpq>RXfs^HTNs-RZMM5RW`h^ znF^i1G+1o*XBMt_(~C0BQ9MzdDp;E|u6^A)Ai@UU=$?NSYg9HwV$vHGJ6X*lSyN<< z_8N4B&scmrs61acNJ)^hv0Mf-1nhx=fFn>Ca0ZG3u0V01Bv-NLnx1{c_sc@o?BsJ* zh^Fj$&25EZ)r{3aP1!-Y8FH1IDx}qCA7se#pp6NpCvU9JDFH>9`)1C4%clU?}$cSlZs@>MV}iJ4@mPo$h#Hrzh_0ER7d+mc?D2^ZlASpzuHRn*+-&vS>Qb= z+q^6juY4);kL1ZOL;kTm`Q^wzo+rNo`6ND<1(oVTHOxfITc)pLStY#kmHe808N>6m zj<4dY(SGOa(ehu31e!cxBYr-~oTFnP6v6f%wv*iL+mpB+(UfC#(CBo$D z1q4a1%ykR<2U_?6KNMqsS5Jp*hqfOT{X#qr9D${wuLP)IctHd_+^l>Z!6CGY@SAl# zZRgMS`%mWE04qgQ&h=AnMuu8|D} z_tL?KuSLSwm7+kPJpFJx;iq2h5CSoYSB$qhtL!`x5Ko7NtKm$e+B?dVYOijECmSQp zjs_KLlcj@U;8AjUC)_+`Orx>htdA#qyJAxKDyEFWr(vK95!AiOUNarl^ucJ$^i1YM zBpS*6ykR8pZrC4EJNFAYz2imceht}o`4=>(ZYn)9QeXF+;EQJ4ICqhgbO2iQmw3rQ zjux+Hps0~w!yEAiC^hPe>Le2ZssyG2R5cTzN?ijXx`?Ii zK7-G?OV7LTW|azjrd>L|n57+j32$i9`Si$fQ%+Pb75a>L^RVwup9SesmM-GU$^p3g z;6S3Sa+X)@Gvj*&OPBDKlAD2DRdU%#AQHkUetF%BlTr$>36UW(&ZVns zZnys4>CZd6Z?}HpsAF^KS+ejX*4e^uWEED%mjSJqn+UzMWMGw%urwSGDWp!H+_vMnVJ_lzQ)n_+#Q0qlO+XnzO4d{5&fc}t9RRvd3 zhESCzu1P~Y)^UTH!J`^P2_w9A%kohXsi-QNH#6);MHljw)jV#WfuRfRtR!m^@fM#B zCmT5i;tG9gC( zZVV^^{Io07BKZakwG_zgU>+Vmg(~kzc<4VxA4ikt zFC(VzzyDe{#dARN3jI>$F;@HC43!fhSeefHG4Trp*s-On-f*;!*YARL@?0JeE4} zwdDQ_$qjF($_A6J!L+;TZu6aH_+;+7WJXLZkrH~$hDnL9;$o>dDm#Eev^tJT+#keG z6UBO4J47+XQ-0<7x+o^@VjLeuf`H*ZP4pro6S9sJFnNuWVbtpKnc7QA=<}7>MJH_r zxrl&uKM{hT;D#`v4I?9v(1b2qB_SN9RGnUsFb*ark!A!()SU~*2se-+Z(f0>mUY9{ z=q*$Lb-}H^aOXnOyCYS)^LG1FciF_X$>@{v?J0NtN5SdMGi#pgY)v(`CEaaFM_YFF z)HNcB%y!kwnp;u!>)F|&Q8~&PLs>Q(MXHm`UFkfBw)84~;#x%HLF;U9V@oN1uxpV$ zr*|pu%HW91)Ieg7ZEZIaxqkBsehUPsPuHYVMG_A0a)4b`w6cwJ0MqfS`y;UV+(aJ_ z^~$tpyVRYckIGId$7;g+$XL;;$ZeO+yB4K!ID&nV?%up}bEBsLp zDgRo^eIe<%@cPF5M(n1H{!RNq;#FfYo$UnMj}y8n=u~>kjs@jd&{(V$uU}>#)V0pf zUf2O(m5innf`U4NOB5LG7J3juh;r;6;hT7ux2(n+Iqk67DKbi1^%p9PNIaYHVC>1J%~pN)V>OcqZ-g+_m4a zr#&0erFF^ehi9(MN=eVD=O#mO$)cHa*SxeCiq}3jm^{u!4tU}J+qRn6wwje~b;P5p zWXHVOhB{*5>ltB>EL!&^>g|okgd__`@e{YO`Mu&ywqxKuil?#3^n8p1LwY9pfb!w> z{nmuypRra}(sv{Zm+o@HkUd4jDWD#wfIytdUTp+Sh;u+#w7CK^=Ts1z*`q%RF+c|n zd(0aEiLG8k6#$?ilUznZF*qEET@7cJlsH2aejgJM{t==j#~(&y>>@XCKN8yU2S7n+ zZk1W#a;iOK4ns;ZharGO=rH7=4mWDpu0f3NpniyGmndBH!O6!~pvn9r&N{H#i3n*8 zCQIo=Bw2Cv;jJm@L-*vs{mRM8AKuEGhg7W5$tE^uC>Ul6w4fS}BN)$U%fL)tc7j6Z z1q$a(&?p=ri<52RX=!t=Dnng*r&{SyZx1w^HhGYXRhMtgKN`Q z|MFI<;^^HyNypKNC0Qx=QRFFPsOo0oZMqP`#Eu8K&H9xMuM8m?c?0lHwFOjG18+n= zZThmnn~+aZd0F5IH?=Oy#A}mT6=w-kzjZw~pc|l;^Yoa%zx=)B?`x1$EDJtUKIEIX z@K$v+DxtoyjxA3eTIJRH(T;fs!5~Ud`z0YmAq3XI5q17?YV}X+n&9T`BcXyvX%L!hr?W@}lFNz(xk`+W z3c)b^Ex?0xmP{AvRP(y>wU4SjCj*KQah}{RZ=jRl2pl>85TfCnh62GLj(pi|7fUEC z;SgG^vkCVwGvN;@$};62(d&m4{V_#c?3Cm z+E|TO;_;~BkZz(@HzSgr>~Pti0X5-PDDQON3!`LGgN71=Sk-}MO_-lwn+TYEaH*VbM^^T7#5@wQ$?p{H{th#~kQHx(7`XWctG=1(_H&vPT zc&E0dJ+)Jv3mXs2Z9K3fxnp|&!mggVT|H?}({#ncj#G0xPAwK%ic6lG>?D|;m2u?< z(`z=Qt2U);HZ9cboU7THP1NsRsPCAo??`Xkxv;HmZd=>a@~X-9``!1tr*2G_rmFX) z%J)Cry+7?)bGQ3WcXD0pY&Zpz!Jyw$f^*QF%`@$fy8otob}ZF=X1@R2!jAJF?>Ikc zzHh&0pE@vK(U81!`9-;-+iG1RK&GSQoBCfTJo8iw38aff4N2;TBG301yIZ~AP5z{ z4g(p~P;F*Y!$J$-P@m|okfk(|^WfB9;l&)YS@?6b&xl9WQGbC9T%cmq_D|{KUsCiV zinOxI0r)xOi6oMVvOrrI%K8X7@~#yqYqg=@rS`fIfv^@!H~=Vcanr8G&pm6WtS|Zo~_Ak&5YI-3(Q16Hlm+J59isv;66U*K0doQ8y4g<2MRfHIw>)q! z60m+q)a(vgYGMtxb$^Y-m)*K1gDP%uV@Ojq8S84TYF#ma9pHV`N*n#B~GeR!}n+qp0qT*OFsrJt>HEq&JbDFW$&L*vyTR87d==$~fpp@54uZpFnm+%K@ z>Y+|x)}UeW6%j=jPgdGoi_H@j@{qTG zg+UV=h(1KQVsdkamYJB`G<9pyVkG*tFvHBn472%)bF)K_&wtYHOI4gB%k135o+WuH z_c>%P6+&booA6_lJdebiC^-w4B=lL_>)iT&<8I;#1o#b_d6uE(EM*=EvpDuvRQ&^Os>pfcTaax*mQ#@(L;&e-4} zak&F$mg*8f5Dt_s9}i^jT|j>4FQLKnO~#CiayY{$xR=WAYnhbWNZTFqfwlX@UG6AxOCy;GJf#FxzoT5t}G@J6R}-f%3P=Tb<#S&Gg6A$QYt5!zI0Y zXV%P~PwwwYmG%NAt$J&^EP3KWs_O0Ar#`FS@lo3|4zpjx>^Dp`e^UL{^CGBja-ce|I?y%$A(i5TVP4*05qpE@j_zXD41ttKErOVs5u~F-Lb)4WgYe}6C!w(!8 zRX~>pM7d-%23tNJy#`+b_!_d__494MuC{ajfleOR5Bm=;5eNUj z)Sd7zu*JZ@7}7wjDx5)-N7?ONU2m`%=3&%-N3G>3xxie<AzYf7TSZjPV|6_w1n;<3bgEV4i|KyQa#>X`ygsF~foXn%Z3H96fh) z{tXH)fpXT z(#18CHgp4q2H85x0tSiW`y8@m?+P+Sn+>%kMi6E7VcJ#ox#FS&RzH&VY)RG~ zRN_UZ$xT+~h(q($epRvqJ|6q4_Xqfh%62-6m@43#@uVL#YTTE3w< z%R7ys;3^9<|5sf8^C%>e3}L>lDiPDjSj8rJeE>hBX4(*84{&m!y0mZum5WQ z5k!gp)fJ25f0#qm03*j6lzN|{Z&LJaiU@iZCMo)eqQ9nyY%zh&7T9o?P z33w#v4@i;_xiTsNbn^qO(#V(je+m>uR-QnhfpDH8(v?|wYFrIW#K~6mu9z^0VkE=G ztBAh%9qy%`?r6QT;~vnVN5U+MKVv4qGSAWhhn_`ZIJWyXa;Wt_u1! z%&iP({_NhOAGZl(k}B5aqLIK2e-_XbWjAGnqk#lKNfmpbNpM`nUmSx2`Z-dhvzbB-nE`WfBnIvIYjqF(t`!K+#I3tO&aH;SEYL6gGVG2=? zY^I8U3kk}^AO7fy7Kxj>Ez54m2nOUT=U*5hv%6#qRLf8#8fJeoRE`7D9R5Wo$s5_l z(nc@d@8w#hQnpN7Xbg->@mPFh3``u21jhv0dATl6 z!TfhDbLj3Y^|OrR2n-cS6fu}WAp3~^M;i}F!hfe+3q>Ta6*%>OQ0f;Hv1R-xl=`0( zF(9}asXDjNMHwe4Vl$%wD_s!zC>4KBDOzUPlKBHv&NvBecmsd9QN*i-V`@-1#YlQU zc!47O@Du+Ij>i9iw82HA-|ZN0OIsY@8vDlBL|MvGG1)P1*}(qeu{Y)1m$dHtd2#6n z!O2ZijwfpxQe};);--b-eRIY8QpE?xyVH)c$?_)-@6?eejvdL}y-ysy3kB!r3eGPS zT==-)0&Ax^R#z> zCNmh&WPVQ3tFnuF%H`POh4NT@-^jVPFBBb^D>^W4a*+U`{K$#ikmi$aUjJl+l&Yb%-9d-uqlBMa^=bM7r^t7~HO-G)02 z_z%m(&_dakxiaLfoiZcPk8VA>HFImC{ci7_-UUzN zoTqWRJ?Uwj_Z&(NoJU(TdmkNsba>{l(&j?Rwz-mR)8=H!w)v8Mk3(rI?t)R@i^aAg z^LWP#4`;I9-i!NT`=`z&?G5wB#!m|zOcs%#r+tR>W;f?dLjfre6%s@{rHJZEWQ*S) ziU$=-GAZE z{x`1nXI#O5=InpZRs20y_R@?${=c;2pWZJ$_{;3e{q$Fx#gLgZI&R;bH&ia_jV9-( z&Wd}UKPpQ(y-BP0nTa#5`2xz+QTl?@Gt3Q(5est#R zv+h|_vaUVp?3lN9rmcnlYte-AzZT0eoPSl=;^2Pju(TXB{M23Bvd8e#Jtn08FSM{d A2LJ#7 From 28f3431ecda6a9076d8a529db1a6e5a4d84ad01d Mon Sep 17 00:00:00 2001 From: James3014 <185240413+James3014@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:52:38 +0800 Subject: [PATCH 3/5] test: run bridge unittest with -B so no pyc is written Co-Authored-By: Claude Sonnet 5.5 --- src/github-completion-host.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/github-completion-host.test.ts b/src/github-completion-host.test.ts index b8f98b5f9..4c4fab368 100644 --- a/src/github-completion-host.test.ts +++ b/src/github-completion-host.test.ts @@ -193,6 +193,6 @@ describe("readHeadCheckRuns (Nexus-new #2068)", () => { 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", script], { stdio: "pipe" }); + execFileSync("python3", ["-I", "-B", script], { stdio: "pipe" }); }); }); From b5a96f70008eba32308312d68e20e9f0b6d9074f Mon Sep 17 00:00:00 2001 From: James3014 <185240413+James3014@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:57:42 +0800 Subject: [PATCH 4/5] feat: pin check runs by app_id and workflow_path (Nexus-new #2068) Resolve each GitHub Actions run's workflow file via actions/runs?head_sha (check_suite_id -> path), paginated, fail closed; non-Actions apps get workflow_path ''. Bridge passes app_id/workflow_path into the observation; drop last_head_check_run_apps. Co-Authored-By: Claude Sonnet 5.5 --- scripts/github_completion_bridge.py | 25 ++--- scripts/test_github_completion_bridge.py | 17 +++- src/git-pr-merge.ts | 39 ++++++++ src/github-completion-host.test.ts | 94 ++++++++++++++++-- src/github-completion-host.ts | 120 ++++++++++++++++++----- 5 files changed, 246 insertions(+), 49 deletions(-) diff --git a/scripts/github_completion_bridge.py b/scripts/github_completion_bridge.py index 45cde3d43..27eb9d413 100644 --- a/scripts/github_completion_bridge.py +++ b/scripts/github_completion_bridge.py @@ -28,8 +28,8 @@ def parse_head_check_runs(result: Any, head_sha: str, observation_cls: Any) -> t """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 - identity (app_slug/app_id) is kept out of the frozen, extra="forbid" - observation model and is returned separately via ``HostPort.last_head_check_run_apps``. + ``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 @@ -41,12 +41,19 @@ def parse_head_check_runs(result: Any, head_sha: str, observation_cls: Any) -> t 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) @@ -82,8 +89,6 @@ def main() -> int: class HostPort: def __init__(self) -> None: self._next_id = 1 - # App identity per observed check-run (slug/id), for optional app pinning. - self.last_head_check_run_apps: tuple[dict[str, Any], ...] = () def _call(self, method: str, params: dict[str, Any] | None = None) -> Any: call_id = self._next_id @@ -182,17 +187,7 @@ def read_required_checks(self, *, head_sha, generation, timeout_seconds=None): def read_head_check_runs(self, *, head_sha): result = self._call("read_head_check_runs", {"head_sha": head_sha}) - observations = parse_head_check_runs(result, head_sha, HeadCheckRunObservation) - self.last_head_check_run_apps = tuple( - { - "name": item["name"], - "head_sha": item["head_sha"], - "app_slug": item.get("app_slug"), - "app_id": item.get("app_id"), - } - for item in result["check_runs"] - ) - return observations + return parse_head_check_runs(result, head_sha, HeadCheckRunObservation) def read_reviews(self): result = self._call("read_reviews") diff --git a/scripts/test_github_completion_bridge.py b/scripts/test_github_completion_bridge.py index 7f7c72764..31576f687 100644 --- a/scripts/test_github_completion_bridge.py +++ b/scripts/test_github_completion_bridge.py @@ -15,13 +15,15 @@ class Obs: - def __init__(self, *, name, status, conclusion, head_sha): + 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} + "head_sha": SHA, "app_slug": "github-actions", "app_id": 15368, + "workflow_path": ".github/workflows/pytest.yml"} base.update(over) return base @@ -34,6 +36,17 @@ 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": []}), ()) diff --git a/src/git-pr-merge.ts b/src/git-pr-merge.ts index 8c0762d1e..b4ee6a581 100644 --- a/src/git-pr-merge.ts +++ b/src/git-pr-merge.ts @@ -421,6 +421,22 @@ export interface GitHubCompletionTransport extends GitHubPullRequestTransport { 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. */ @@ -755,6 +771,29 @@ export class GhCliGitHubTransport implements GitHubCompletionTransport { 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 4c4fab368..eb782923e 100644 --- a/src/github-completion-host.test.ts +++ b/src/github-completion-host.test.ts @@ -99,12 +99,33 @@ describe("readHeadCheckRuns (Nexus-new #2068)", () => { conclusion: "success", head_sha: SHA, app: { id: 15368, slug: "github-actions" }, + check_suite: { id: 1000 + n }, ...over, }); - const pagedTransport = (pages: Array<{ total: number; runs: unknown[] } | Error>) => { + 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]; @@ -119,13 +140,15 @@ describe("readHeadCheckRuns (Nexus-new #2068)", () => { 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); }); @@ -180,15 +203,74 @@ describe("readHeadCheckRuns (Nexus-new #2068)", () => { ); }); - it("keeps a null conclusion and null app identity", async () => { + it("keeps a null conclusion", async () => { const out = await readHeadCheckRuns( - pagedTransport([{ total: 1, runs: [run(1, { status: "in_progress", conclusion: null, app: null })] }]), + pagedTransport([{ total: 1, runs: [run(1, { status: "in_progress", conclusion: null })] }]), "o/r", SHA, ); assert.equal(out[0].conclusion, null); - assert.equal(out[0].app_slug, null); - assert.equal(out[0].app_id, 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("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)", () => { diff --git a/src/github-completion-host.ts b/src/github-completion-host.ts index 3c47e372d..f22d4a771 100644 --- a/src/github-completion-host.ts +++ b/src/github-completion-host.ts @@ -107,40 +107,76 @@ 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; - app_id: number | 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). Fails closed: any API/shape/pagination/SHA - * error throws; an empty array is returned only when GitHub itself reports - * `total_count: 0`. + * (`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, + transport: Pick, repo: string, headSha: unknown, ): Promise { const sha = normalizeSha(headSha, "check_runs_head_sha"); - const runs: HeadCheckRunWire[] = []; - let expectedTotal: number | undefined; - for (let page = 1; page <= CHECK_RUNS_MAX_PAGES; page += 1) { - const view = await transport.getCheckRunsPage(repo, sha, page, CHECK_RUNS_PER_PAGE); - if (!Number.isInteger(view.totalCount) || view.totalCount < 0 || !Array.isArray(view.checkRuns)) { - throw new Error("CHECK_RUNS_RESPONSE_INVALID"); - } - if (expectedTotal === undefined) expectedTotal = view.totalCount; - else if (view.totalCount !== expectedTotal) throw new Error("CHECK_RUNS_TOTAL_CHANGED_DURING_PAGINATION"); - for (const raw of view.checkRuns) { + 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 }; + 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") { @@ -149,22 +185,53 @@ export async function readHeadCheckRuns( 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; - runs.push({ + 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_slug: app && typeof app.slug === "string" ? app.slug : null, - app_id: app && typeof app.id === "number" ? app.id : null, + app_id: app.id, + app_slug: typeof app.slug === "string" ? app.slug : null, + suiteId, }); - } - if (runs.length >= expectedTotal) { - if (runs.length !== expectedTotal) throw new Error("CHECK_RUNS_COUNT_MISMATCH"); - return runs; - } - if (view.checkRuns.length === 0) throw new Error("CHECK_RUNS_PAGINATION_INCOMPLETE"); + }, + ); + + 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); + }, + ); } - throw new Error("CHECK_RUNS_PAGINATION_LIMIT_EXCEEDED"); + + 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 { @@ -223,6 +290,7 @@ function transportWithCompletion( "getPullRequestReviewState", "isPlatformApprovalRequired", "getCheckRunsPage", + "getWorkflowRunsPage", ] as const; for (const name of required) { if (typeof candidate[name] !== "function") { From f2ee9144460713685d057b7408a842c5021d6c64 Mon Sep 17 00:00:00 2001 From: James3014 <185240413+James3014@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:08:41 +0800 Subject: [PATCH 5/5] test: real-model contract test for HeadCheckRunObservation; pull_request_target suite test Co-Authored-By: Claude Sonnet 5.5 --- scripts/test_github_completion_bridge.py | 45 ++++++++++++++++++++++++ src/github-completion-host.test.ts | 23 ++++++++++++ 2 files changed, 68 insertions(+) diff --git a/scripts/test_github_completion_bridge.py b/scripts/test_github_completion_bridge.py index 31576f687..5c4c7a66d 100644 --- a/scripts/test_github_completion_bridge.py +++ b/scripts/test_github_completion_bridge.py @@ -2,6 +2,8 @@ """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 @@ -66,5 +68,48 @@ def test_malformed_payload(self): 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/github-completion-host.test.ts b/src/github-completion-host.test.ts index eb782923e..31519e879 100644 --- a/src/github-completion-host.test.ts +++ b/src/github-completion-host.test.ts @@ -241,6 +241,29 @@ describe("readHeadCheckRuns (Nexus-new #2068)", () => { 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),