From 730508a9ebb129779788770d9f97521d67bf2d83 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 20:43:55 +0000 Subject: [PATCH 1/9] fix(automation): keep mention dispatch payloads within GitHub's 10-key limit @opencode-agent mentions could not enqueue because both the first-hop wrapper payload and the merge-scheduler forwarder sent 14 client_payload keys. GitHub's repository_dispatch API allows 10 and returns HTTP 422. Keep identity on both hops, bind review-only flags in the invocation claim, hardcode those flags in the wrapper, and fail closed if a payload grows past 10 keys. Co-authored-by: Seongho Bae --- .../agent-mention-opencode-dispatch.yml | 16 +- .../review-agent-comment-invocation.md | 2 +- scripts/ci/agent_mention_router.py | 53 +++++-- ..._agent_mention_complete_payload_binding.py | 11 ++ ...st_agent_mention_dispatch_payload_limit.py | 141 ++++++++++++++++++ tests/test_agent_mention_router.py | 10 +- 6 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 tests/test_agent_mention_dispatch_payload_limit.py diff --git a/.github/workflows/agent-mention-opencode-dispatch.yml b/.github/workflows/agent-mention-opencode-dispatch.yml index 160b4723d..02a3f6f08 100644 --- a/.github/workflows/agent-mention-opencode-dispatch.yml +++ b/.github/workflows/agent-mention-opencode-dispatch.yml @@ -36,11 +36,11 @@ jobs: BASE_BRANCH: ${{ github.event.client_payload.base_branch || '' }} REQUESTED_BY: ${{ github.event.client_payload.requested_by || '' }} SOURCE_COMMENT_ID: ${{ github.event.client_payload.source_comment_id || '' }} - TRIGGER_REVIEWS: ${{ github.event.client_payload.trigger_reviews }} - REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.review_dispatch_limit || '' }} - ENABLE_AUTO_MERGE: ${{ github.event.client_payload.enable_auto_merge }} - UPDATE_BRANCHES: ${{ github.event.client_payload.update_branches }} - MERGE_MODE: ${{ github.event.client_payload.merge_mode || '' }} + TRIGGER_REVIEWS: "true" + REVIEW_DISPATCH_LIMIT: "1" + ENABLE_AUTO_MERGE: "false" + UPDATE_BRANCHES: "false" + MERGE_MODE: "disabled" steps: - name: Validate exact invocation payload run: | @@ -195,9 +195,7 @@ jobs: --arg pr_head_sha "$PR_HEAD_SHA" \ --arg pr_base_sha "$PR_BASE_SHA" \ --arg base_branch "$BASE_BRANCH" \ - --arg requested_agent "$REQUESTED_AGENT" \ --arg agent_invocation_key "$INVOCATION_KEY" \ - --arg requested_by "$REQUESTED_BY" \ --argjson source_comment_id "$SOURCE_COMMENT_ID" \ '{ event_type: "merge-scheduler", @@ -207,14 +205,10 @@ jobs: pr_head_sha: $pr_head_sha, pr_base_sha: $pr_base_sha, base_branch: $base_branch, - trigger_reviews: true, - review_dispatch_limit: "1", enable_auto_merge: false, update_branches: false, merge_mode: "disabled", - requested_agent: $requested_agent, agent_invocation_key: $agent_invocation_key, - requested_by: $requested_by, source_comment_id: $source_comment_id } }' \ diff --git a/docs/automation/review-agent-comment-invocation.md b/docs/automation/review-agent-comment-invocation.md index 51c84dcde..3d2ca496d 100644 --- a/docs/automation/review-agent-comment-invocation.md +++ b/docs/automation/review-agent-comment-invocation.md @@ -45,7 +45,7 @@ This preserves the central MSA boundary without copying privileged workflow code - `contents: write` is intentionally retained only on jobs that call GitHub's create-repository-dispatch endpoint. GitHub documents that endpoint as requiring Contents repository permission at write level. Removing it would disable the bounded central dispatch path; broad workflow-default write access is not granted. - The organization sweep uses the established cross-repository credential chain for reading target comments, while the central repository's own short-lived job token dispatches the central workflows. - OpenCode dispatch is restricted to the exact `OPENCODE_REPOSITORY_DISPATCH_TARGETS` allowlist. -- An invocation cannot merge: `enable_auto_merge=false`, `update_branches=false`, and `merge_mode=disabled` are explicit in the dispatch payload. +- An invocation cannot merge: `enable_auto_merge=false`, `update_branches=false`, and `merge_mode=disabled` are bound into the OpenCode invocation claim and hardcoded in the wrapper. GitHub's create-repository-dispatch endpoint allows at most 10 top-level `client_payload` properties (HTTP 422 otherwise), so those review-only constants are not copied onto the first-hop mention payload. The wrapper's merge-scheduler forward keeps the three flags that override scheduler defaults, together with repository, PR, head/base SHA, base branch, invocation key, and source comment identity. - Every dispatch is bound to live PR number, current head SHA, base branch, source comment, requested agent, and requesting actor metadata fetched or validated immediately before dispatch. - Router jobs use the fixed `ubuntu-24.04` runner and an immutable `actions/checkout` v7.0.1 commit pin; checkout credentials are not persisted. - A branch-selectable `workflow_dispatch` trigger is intentionally absent. This prevents a repository writer from choosing an unreviewed branch version of the central router while the job holds dispatch permissions. diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py index bdb8ac3db..2b5453139 100644 --- a/scripts/ci/agent_mention_router.py +++ b/scripts/ci/agent_mention_router.py @@ -33,6 +33,7 @@ BASE_BRANCH_RE = re.compile(r"^(?!-)[A-Za-z0-9._/-]+$") ACTOR_RE = re.compile(r"^[A-Za-z0-9-]+$") RECEIPT_RE = re.compile(r"") +REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS = 10 @dataclass(frozen=True) @@ -369,13 +370,36 @@ def dispatched_agents( return frozenset(observed) +def repository_dispatch_body( + event_type: str, + client_payload: dict[str, Any], +) -> dict[str, Any]: + """Return a repository_dispatch body within GitHub's 10-key payload limit. + + GitHub's create-repository-dispatch endpoint accepts at most 10 top-level + ``client_payload`` properties. A larger object is rejected with HTTP 422, + so mention routing cannot enqueue a review. + """ + + if len(client_payload) > REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS: + raise ValueError( + "repository_dispatch client_payload has " + f"{len(client_payload)} keys; GitHub allows at most " + f"{REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS}" + ) + return { + "event_type": event_type, + "client_payload": client_payload, + } + + def noema_payload(request: MentionRequest) -> dict[str, Any]: """Return the durable Noema wrapper dispatch request body.""" agent = "cwl-noema-review" - return { - "event_type": "agent-mention-noema", - "client_payload": { + return repository_dispatch_body( + "agent-mention-noema", + { "target_repository": request.repository, "pr_number": request.pull_request_number, "pr_head_sha": request.pull_request_head_sha, @@ -386,33 +410,32 @@ def noema_payload(request: MentionRequest) -> dict[str, Any]: "requested_by": request.actor, "source_comment_id": request.comment_id, }, - } + ) def opencode_payload(request: MentionRequest) -> dict[str, Any]: - """Return the durable review-only OpenCode wrapper dispatch body.""" + """Return the durable review-only OpenCode wrapper dispatch body. + + Review-only behavior flags stay in the invocation claim and are hardcoded + by the wrapper. Copying them onto this first hop exceeds GitHub's 10-key + ``client_payload`` limit and prevents mention pings from enqueueing. + """ agent = "opencode-agent" - claim = agent_invocation_claim(request, agent) - return { - "event_type": "agent-mention-opencode", - "client_payload": { + return repository_dispatch_body( + "agent-mention-opencode", + { "target_repository": request.repository, "pr_number": request.pull_request_number, "pr_head_sha": request.pull_request_head_sha, "pr_base_sha": request.pull_request_base_sha, "base_branch": request.pull_request_base_branch, - "trigger_reviews": claim["trigger_reviews"], - "review_dispatch_limit": claim["review_dispatch_limit"], - "enable_auto_merge": claim["enable_auto_merge"], - "update_branches": claim["update_branches"], - "merge_mode": claim["merge_mode"], "requested_agent": agent, "agent_invocation_key": agent_invocation_key(request, agent), "requested_by": request.actor, "source_comment_id": request.comment_id, }, - } + ) def dispatch_request( diff --git a/tests/test_agent_mention_complete_payload_binding.py b/tests/test_agent_mention_complete_payload_binding.py index 04562e93f..c07025407 100644 --- a/tests/test_agent_mention_complete_payload_binding.py +++ b/tests/test_agent_mention_complete_payload_binding.py @@ -162,6 +162,17 @@ def test_wrappers_recompute_complete_claim_before_ledger_access() -> None: assert "--arg pr_base_sha \"$PR_BASE_SHA\"" in workflow assert "pr_base_sha: $pr_base_sha" in workflow + assert "github.event.client_payload.trigger_reviews" not in opencode + assert "github.event.client_payload.review_dispatch_limit" not in opencode + assert "github.event.client_payload.enable_auto_merge" not in opencode + assert "github.event.client_payload.update_branches" not in opencode + assert "github.event.client_payload.merge_mode" not in opencode + assert 'TRIGGER_REVIEWS: "true"' in opencode + assert 'REVIEW_DISPATCH_LIMIT: "1"' in opencode + assert 'ENABLE_AUTO_MERGE: "false"' in opencode + assert 'UPDATE_BRANCHES: "false"' in opencode + assert 'MERGE_MODE: "disabled"' in opencode + for field in ( '"trigger_reviews": os.environ["TRIGGER_REVIEWS"] == "true"', '"review_dispatch_limit": os.environ["REVIEW_DISPATCH_LIMIT"]', diff --git a/tests/test_agent_mention_dispatch_payload_limit.py b/tests/test_agent_mention_dispatch_payload_limit.py new file mode 100644 index 000000000..87ad68d8d --- /dev/null +++ b/tests/test_agent_mention_dispatch_payload_limit.py @@ -0,0 +1,141 @@ +"""Contract: mention repository_dispatch payloads stay within GitHub's 10-key limit.""" + +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +ROUTER_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" +NOEMA_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-noema-dispatch.yml" +OPENCODE_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-opencode-dispatch.yml" +GITHUB_DOCS = ( + "https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event" +) +WRAPPER_CLIENT_PAYLOAD_RE = re.compile( + r"client_payload:\s*\{(?P.*?)^\s+\}", + re.MULTILINE | re.DOTALL, +) +WRAPPER_PAYLOAD_KEY_RE = re.compile(r"^\s+([A-Za-z_][A-Za-z0-9_]*):", re.MULTILINE) +REQUIRED_IDENTITY_KEYS = frozenset( + { + "target_repository", + "pr_number", + "pr_head_sha", + "source_comment_id", + } +) +OPENCODE_FORWARD_SAFETY_KEYS = frozenset( + { + "enable_auto_merge", + "update_branches", + "merge_mode", + } +) + + +def _load_router() -> ModuleType: + """Load the router module from the pull-request source tree.""" + + module_name = "agent_mention_dispatch_payload_limit" + spec = importlib.util.spec_from_file_location(module_name, ROUTER_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _request(module: ModuleType): + """Return one complete trusted mention request.""" + + return module.MentionRequest( + "ContextualWisdomLab/example", + 17, + "a" * 40, + "main", + 91, + "maintainer", + ("cwl-noema-review", "opencode-agent"), + pull_request_base_sha="b" * 40, + ) + + +def _wrapper_forward_payload_keys(workflow_text: str) -> tuple[str, ...]: + """Extract top-level client_payload keys from one wrapper forwarder.""" + + match = WRAPPER_CLIENT_PAYLOAD_RE.search(workflow_text) + assert match is not None + keys = tuple(WRAPPER_PAYLOAD_KEY_RE.findall(match.group("body"))) + assert keys + assert len(keys) == len(set(keys)) + return keys + + +def test_github_repository_dispatch_limit_is_ten_top_level_keys() -> None: + """The router constant matches GitHub's documented client_payload cap.""" + + router = _load_router() + assert router.REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS == 10 + assert GITHUB_DOCS in ( + ROOT / "docs" / "automation" / "review-agent-comment-invocation.md" + ).read_text(encoding="utf-8") + + +def test_mention_router_payloads_stay_within_github_key_limit() -> None: + """Both first-hop mention dispatches keep identity without exceeding 10 keys.""" + + router = _load_router() + request = _request(router) + noema = router.noema_payload(request)["client_payload"] + opencode = router.opencode_payload(request)["client_payload"] + limit = router.REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS + + assert len(noema) <= limit + assert len(opencode) <= limit + assert REQUIRED_IDENTITY_KEYS <= noema.keys() + assert REQUIRED_IDENTITY_KEYS <= opencode.keys() + assert { + "trigger_reviews", + "review_dispatch_limit", + "enable_auto_merge", + "update_branches", + "merge_mode", + }.isdisjoint(opencode.keys()) + + +def test_wrapper_forwarders_stay_within_github_key_limit() -> None: + """Mention-forwarder jq payloads also stay at or under 10 top-level keys.""" + + router = _load_router() + limit = router.REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS + noema_keys = _wrapper_forward_payload_keys( + NOEMA_WORKFLOW.read_text(encoding="utf-8") + ) + opencode_keys = _wrapper_forward_payload_keys( + OPENCODE_WORKFLOW.read_text(encoding="utf-8") + ) + + assert len(noema_keys) <= limit + assert len(opencode_keys) <= limit + assert REQUIRED_IDENTITY_KEYS <= set(noema_keys) + assert REQUIRED_IDENTITY_KEYS <= set(opencode_keys) + assert OPENCODE_FORWARD_SAFETY_KEYS <= set(opencode_keys) + assert "trigger_reviews" not in opencode_keys + assert "review_dispatch_limit" not in opencode_keys + assert "requested_agent" not in opencode_keys + assert "requested_by" not in opencode_keys + + +def test_repository_dispatch_body_rejects_more_than_ten_keys() -> None: + """An oversized client_payload fails closed before GitHub returns HTTP 422.""" + + router = _load_router() + oversized = {f"field_{index}": index for index in range(11)} + with pytest.raises(ValueError, match="GitHub allows at most 10"): + router.repository_dispatch_body("agent-mention-opencode", oversized) diff --git a/tests/test_agent_mention_router.py b/tests/test_agent_mention_router.py index 4509d43f0..874a79e4f 100644 --- a/tests/test_agent_mention_router.py +++ b/tests/test_agent_mention_router.py @@ -222,9 +222,13 @@ def test_eligible_agents_and_payloads() -> None: assert opencode["event_type"] == "agent-mention-opencode" assert opencode["client_payload"]["base_branch"] == "develop" assert opencode["client_payload"]["pr_base_sha"] == "b" * 40 - assert opencode["client_payload"]["merge_mode"] == "disabled" - assert opencode["client_payload"]["enable_auto_merge"] is False - assert opencode["client_payload"]["update_branches"] is False + assert "merge_mode" not in opencode["client_payload"] + assert "enable_auto_merge" not in opencode["client_payload"] + assert "update_branches" not in opencode["client_payload"] + claim = module.agent_invocation_claim(request, "opencode-agent") + assert claim["merge_mode"] == "disabled" + assert claim["enable_auto_merge"] is False + assert claim["update_branches"] is False def test_dispatch_uses_central_events_and_acknowledges() -> None: From 58d2f62401a77bd3a9763f63edbf44c939acabb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 03:32:44 +0000 Subject: [PATCH 2/9] fix(ci): compare trusted uv --version output against real GitHub Releases format The pinned uv 0.12.1 archive download was fixed in #1116 (releases.astral.sh -> github.com/astral-sh/uv), but the post-install version check still required the bare "uv 0.12.1" string. The actual GitHub Releases binary always prints "uv 0.12.1 (x86_64-unknown-linux-gnu)" (verified by downloading, checksum-verifying, extracting, and executing the real archive), so every installation failed this check immediately after the archive download itself started succeeding, keeping org-wide OpenCode coverage-evidence blocked with a new "unexpected version or exit status" error instead of the original HTTPError. --- CHANGELOG.md | 1 + .../ci/materialize_base_python_requirements.py | 4 +++- .../test_materialize_base_python_requirements.py | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 412daff72..87a4fd4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). +- Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index e4ebf473a..dedf8bc4b 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -38,6 +38,8 @@ UV_SHA256_HASH_RE = re.compile(r"--hash=sha256:[0-9a-fA-F]{64}") UV_EXPORT_TIMEOUT_SECONDS = 120 TRUSTED_UV_VERSION = "0.12.1" +TRUSTED_UV_TARGET_TRIPLE = "x86_64-unknown-linux-gnu" +TRUSTED_UV_VERSION_OUTPUT = f"uv {TRUSTED_UV_VERSION} ({TRUSTED_UV_TARGET_TRIPLE})" TRUSTED_UV_ARCHIVE_URL = ( "https://github.com/astral-sh/uv/releases/download/0.12.1/" "uv-x86_64-unknown-linux-gnu.tar.gz" @@ -365,7 +367,7 @@ def _install_trusted_uv() -> str: f"trusted uv executable verification failed: {type(exc).__name__}" ) from exc observed = completed.stdout.decode("utf-8", errors="replace").strip() - if completed.returncode != 0 or observed != f"uv {TRUSTED_UV_VERSION}": + if completed.returncode != 0 or observed != TRUSTED_UV_VERSION_OUTPUT: raise RuntimeError( "trusted uv executable reported an unexpected version or exit status" ) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index cc457748b..5bc56ed8f 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -724,7 +724,9 @@ def test_install_trusted_uv_verifies_version_and_caches_path( def verify(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[bytes]: nonlocal calls calls += 1 - return subprocess.CompletedProcess([], 0, b"uv 0.12.1\n", b"") + return subprocess.CompletedProcess( + [], 0, b"uv 0.12.1 (x86_64-unknown-linux-gnu)\n", b"" + ) monkeypatch.setattr(materializer.subprocess, "run", verify) @@ -773,8 +775,16 @@ def fail(*_args: object, **_kwargs: object) -> None: @pytest.mark.parametrize( "completed", [ - subprocess.CompletedProcess([], 0, b"uv 0.12.0\n", b""), - subprocess.CompletedProcess([], 1, b"uv 0.12.1\n", b"failed"), + subprocess.CompletedProcess( + [], 0, b"uv 0.12.0 (x86_64-unknown-linux-gnu)\n", b"" + ), + subprocess.CompletedProcess( + [], 1, b"uv 0.12.1 (x86_64-unknown-linux-gnu)\n", b"failed" + ), + subprocess.CompletedProcess([], 0, b"uv 0.12.1\n", b""), + subprocess.CompletedProcess( + [], 0, b"uv 0.12.1 (aarch64-unknown-linux-gnu)\n", b"" + ), ], ) def test_install_trusted_uv_rejects_wrong_version_or_exit_status( From 69f15ff21665144b2c90b5d446371f2ba4eeb3ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 06:00:41 -0700 Subject: [PATCH 3/9] docs(changelog): remove duplicate Changed heading --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 438bc55b7..911002e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,6 @@ Semantic Versioning where the repository publishes a release. - Run the bounded fast-mlsirm repair heartbeat at minute 49 of every hour with one-dispatch scope and a two-hour same-head floor, without weakening true-parameter recovery, CPU/GPU parity, skipped-test, or Rust-ownership gates. - Use NVIDIA NIM `mistralai/mistral-small-4-119b-2603` with explicit high reasoning for scheduled repair and `nvidia/nemotron-3-nano-30b-a3b` for bounded helper work instead of GitHub Models in the write-capable autofix worker. - Apply one NUL-delimited exact-path and complete pre/post-worktree verification contract to both ordinary review repair and merge-conflict repair rather than relying on a visible post-model diff for the ordinary path. - -### Changed - - Avoided the expensive R/testthat failure-summary regular expression on marker-absent bounded logs by checking the required terminal marker first, while preserving fail-closed handling for incomplete or malformed failure evidence. ### Fixed @@ -68,4 +65,4 @@ Semantic Versioning where the repository publishes a release. - Added DiskSage operational documentation for the hourly RCA loop, bounded retry cadence, permission model, standalone and MSA reuse, verification, rollback, and APA 7 references. - Added fast-mlsirm operational documentation for the hourly RCA loop, psychometric scientific gates, Rust ownership, bounded retry cadence, credential isolation, modular reuse, rollback, and APA 7 references. - Documented the ordinary and conflict repair write-scope parity, ignored-path and symlink inventory, Git-control-file denial, hook suppression, explicit push destination, RED/GREEN evidence, operator response, and local-versus-protected evidence boundary. -- Documented the review-authentication boundary that excludes autonomous writer control-plane paths from review-derived file authority, its test-first Strix security evidence, exact-head coverage contract, and rollback prohibition. \ No newline at end of file +- Documented the review-authentication boundary that excludes autonomous writer control-plane paths from review-derived file authority, its test-first Strix security evidence, exact-head coverage contract, and rollback prohibition. From ee7761c1bbab4cb3cba72ccc0f499f7d9305c965 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 16:26:55 -0700 Subject: [PATCH 4/9] test(automation): reproduce dropped pending agent mentions --- tests/test_agent_mention_queue_isolation.py | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_agent_mention_queue_isolation.py diff --git a/tests/test_agent_mention_queue_isolation.py b/tests/test_agent_mention_queue_isolation.py new file mode 100644 index 000000000..8af11e04a --- /dev/null +++ b/tests/test_agent_mention_queue_isolation.py @@ -0,0 +1,71 @@ +"""Regression contracts for isolated review-agent mention queues.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router.yml" + + +def _job_block(workflow: str, job_name: str, next_job_name: str | None) -> str: + """Return one top-level workflow job bounded by the following job.""" + + jobs = workflow.split("\njobs:\n", 1)[1] + start = jobs.index(f" {job_name}:\n") + if next_job_name is None: + return jobs[start:] + end = jobs.index(f"\n {next_job_name}:\n", start) + return jobs[start:end] + + +def _concurrency_block(job: str) -> str: + """Return the job-scoped concurrency mapping before ``runs-on``.""" + + start = job.index(" concurrency:\n") + end = job.index("\n runs-on:", start) + return job[start:end] + + +def test_interactive_mentions_and_sweeps_use_independent_queues() -> None: + """A scheduled sweep cannot replace a pending trusted mention request.""" + + workflow = WORKFLOW.read_text(encoding="utf-8") + header = workflow.split("\njobs:\n", 1)[0] + local_job = _job_block( + workflow, + "route-local-agent-mention", + "sweep-organization-agent-mentions", + ) + sweep_job = _job_block( + workflow, + "sweep-organization-agent-mentions", + None, + ) + + assert not any(line.startswith("concurrency:") for line in header.splitlines()) + assert _concurrency_block(local_job) == ( + " concurrency:\n" + " group: review-agent-mention-router-local-${{ github.repository }}\n" + " queue: max" + ) + assert _concurrency_block(sweep_job) == ( + " concurrency:\n" + " group: review-agent-mention-router-sweep-${{ github.repository }}\n" + " cancel-in-progress: false" + ) + + +def test_interactive_queue_retains_pending_requests_without_cancellation() -> None: + """The bounded interactive queue retains work and never cancels in progress.""" + + workflow = WORKFLOW.read_text(encoding="utf-8") + local_job = _job_block( + workflow, + "route-local-agent-mention", + "sweep-organization-agent-mentions", + ) + concurrency = _concurrency_block(local_job) + + assert "queue: max" in concurrency + assert "cancel-in-progress: true" not in concurrency From 6b398ded1de4bb448c24783855bda1e36bfad29e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 16:27:36 -0700 Subject: [PATCH 5/9] fix(automation): isolate interactive agent mention queue --- .github/workflows/agent-mention-router.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/agent-mention-router.yml b/.github/workflows/agent-mention-router.yml index f14667a93..b922ba5ab 100644 --- a/.github/workflows/agent-mention-router.yml +++ b/.github/workflows/agent-mention-router.yml @@ -6,10 +6,6 @@ on: schedule: - cron: "*/5 * * * *" -concurrency: - group: review-agent-mention-router-${{ github.repository }} - cancel-in-progress: false - # Organization required-workflow rules do not propagate issue_comment events # into sibling repositories. Keep the workflow default read-only; each bounded # job declares only the writes it actually needs. @@ -28,6 +24,9 @@ jobs: contains(github.event.comment.body, '@cwl-noema-review') || contains(github.event.comment.body, '@opencode-agent') ) + concurrency: + group: review-agent-mention-router-local-${{ github.repository }} + queue: max runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -70,6 +69,9 @@ jobs: if: >- github.repository == 'ContextualWisdomLab/.github' && github.event_name == 'schedule' + concurrency: + group: review-agent-mention-router-sweep-${{ github.repository }} + cancel-in-progress: false runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: From 099faef0f942afe88de417921214afdf30c15ea5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 16:28:17 -0700 Subject: [PATCH 6/9] docs(automation): record mention routing reliability boundary --- .../agent-mention-concurrency-isolation.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/doctoring/agent-mention-concurrency-isolation.md diff --git a/docs/doctoring/agent-mention-concurrency-isolation.md b/docs/doctoring/agent-mention-concurrency-isolation.md new file mode 100644 index 000000000..163a5bc85 --- /dev/null +++ b/docs/doctoring/agent-mention-concurrency-isolation.md @@ -0,0 +1,94 @@ +# Review-agent mention routing reliability + +Review date: **2026-08-19** + +## Incident + +Trusted `@opencode-agent` comments could remain unacknowledged and fail to start the existing OpenCode review path. Two independent control-plane defects produced the same operator-visible symptom before model execution. + +1. The OpenCode `repository_dispatch.client_payload` exceeded GitHub's ten-property limit, so GitHub rejected the request with HTTP 422 before the trusted wrapper started. +2. Interactive `issue_comment` routing and the five-minute organization sweep shared one workflow-level concurrency group. Under the default single-pending contract, a newly queued sweep could replace a pending interactive mention before exact-head resolution, durable claim creation, dispatch, or acknowledgement. + +Neither defect is evidence that the requesting maintainer, model, repository allowlist, or final review result is invalid. + +## Test-first repair + +The permanent regression contracts were committed before their corresponding production changes. + +- `tests/test_agent_mention_dispatch_payload_limit.py` requires both dispatch hops to stay at or below ten top-level payload properties and requires the router to reject an oversized payload before GitHub does. +- `tests/test_agent_mention_queue_isolation.py` requires the interactive route and scheduled sweep to use different job-level concurrency groups, with `queue: max` on the interactive route and no cancellation of in-progress interactive work. + +## Decision + +### Bounded dispatch envelope + +The router-to-wrapper OpenCode payload carries nine identity and provenance fields. Review-only behavior remains bound into the canonical invocation hash and is reconstructed by the trusted wrapper: + +```text +trigger_reviews=true +review_dispatch_limit=1 +enable_auto_merge=false +update_branches=false +merge_mode=disabled +``` + +The wrapper-to-scheduler payload carries exactly ten fields, including the three values that override unsafe scheduler defaults. The wrapper therefore remains review-only and cannot merge or update a branch. + +### Isolated concurrency queues + +Concurrency is scoped to each job rather than the whole workflow: + +```yaml +route-local-agent-mention: + concurrency: + group: review-agent-mention-router-local-${{ github.repository }} + queue: max + +sweep-organization-agent-mentions: + concurrency: + group: review-agent-mention-router-sweep-${{ github.repository }} + cancel-in-progress: false +``` + +GitHub documents that `queue: max` permits up to 100 pending jobs or workflow runs in one concurrency group and cannot be combined with `cancel-in-progress: true`. The interactive queue therefore retains bounded pending requests instead of replacing the previous pending request. Scheduled sweeps retain coalescing behavior in a separate group and cannot displace interactive work. + +Concurrency is not the idempotency authority. Duplicate forwarding remains governed by the complete canonical invocation key, exact-key downstream concurrency, and the immutable exact-name Actions artifact ledger. + +## Preserved boundaries + +- No model provider, reviewer identity, repository allowlist, token name, credential scope, or branch-protection rule changes. +- `COPILOT_GITHUB_TOKEN` remains unused. +- Workflow-default permissions remain read-only; existing bounded jobs keep only their required writes. +- Only trusted non-bot `OWNER`, `MEMBER`, or `COLLABORATOR` comments on open pull requests are eligible. +- Pull request number, exact head and base SHAs, base branch, source comment, requested agent, and requesting actor remain bound to the invocation key. +- Mention routing remains unable to approve, merge, update branches, publish, or release. + +## Operational acceptance + +After protected integration: + +1. submit a fresh trusted `@opencode-agent` comment on an open pull request; +2. require the hidden receipt marker, acknowledgement comment, or durable exact-name artifact for the source comment; +3. require the trusted OpenCode wrapper and review-only scheduler dispatch to start for the same repository, pull request, and exact head; +4. verify that a scheduled sweep cannot cancel or replace the interactive route; +5. distinguish downstream provider or review failure from routing failure rather than treating every missing verdict as the same incident. + +A receipt proves routing and durable claim processing. It is not an approval and never substitutes for exact-head checks or branch protection. + +## Rollback prohibition + +Do not restore either defective boundary: + +- do not increase the first- or second-hop payload beyond GitHub's limit; +- do not move local and scheduled work back into one workflow-level concurrency group; +- do not replace `queue: max` with the default single-pending interactive queue unless another independently reviewed durable queue preserves every eligible request. + +A safe emergency degradation may suspend the scheduled sweep while retaining the isolated interactive route. + +## References + +GitHub. (n.d.). *Control the concurrency of workflows and jobs*. GitHub Docs. Retrieved August 19, 2026, from https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency + +GitHub. (n.d.). *REST API endpoints for repositories: Create a repository dispatch event*. GitHub Docs. Retrieved August 19, 2026, from https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event + +GitHub. (n.d.). *Store and share data with workflow artifacts*. GitHub Docs. Retrieved August 19, 2026, from https://docs.github.com/en/actions/tutorials/store-and-share-data From f16280a0aa215563d29200c5d0bab75c48af614a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:15:38 -0700 Subject: [PATCH 7/9] fix(ci): let OpenCode resolve unreviewed merge conflicts (#1132) * test(ci): reproduce unreviewed merge-conflict dispatch gap * fix(ci): dispatch bounded OpenCode conflict repair before review * ci: enable bounded unreviewed conflict dispatch * ci: scan central pull requests for OpenCode repair hourly * test(ci): cover central hourly conflict repair contracts * docs(ci): record unreviewed conflict-repair safety decision * fix(ci): preserve legacy scheduler callers and diagnostics * fix(ci): make self-target validation safe under unset variables * test(ci): cover unset-safe self-target authorization * test(ci): allow conflict-policy keyword in scheduler branch stub * test(ci): prove queue reaches bounded conflict worker --------- Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- .../workflows/github-hourly-review-repair.yml | 27 ++++ .../hourly-nvidia-nim-review-repair.yml | 7 + .github/workflows/pr-review-fix-scheduler.yml | 39 +++-- .../github-hourly-conflict-repair.md | 119 ++++++++++++++++ scripts/ci/pr_review_fix_scheduler.py | 47 +++++-- tests/test_github_hourly_conflict_repair.py | 133 ++++++++++++++++++ ...itory_branch_coverage_review_schedulers.py | 4 +- 7 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/github-hourly-review-repair.yml create mode 100644 docs/doctoring/github-hourly-conflict-repair.md create mode 100644 tests/test_github_hourly_conflict_repair.py diff --git a/.github/workflows/github-hourly-review-repair.yml b/.github/workflows/github-hourly-review-repair.yml new file mode 100644 index 000000000..97665aa5f --- /dev/null +++ b/.github/workflows/github-hourly-review-repair.yml @@ -0,0 +1,27 @@ +name: Central GitHub Hourly Review Repair + +on: + schedule: + # Keep the control-plane queue moving without colliding with minute-zero jobs. + - cron: "21 * * * *" + +concurrency: + group: github-hourly-review-repair + cancel-in-progress: false + +permissions: + contents: read + +jobs: + dispatch-review-repair: + uses: ./.github/workflows/pr-review-fix-scheduler.yml + with: + target_repository: ContextualWisdomLab/.github + base_branch: main + max_prs: "50" + max_dispatches: "1" + resolve_unreviewed_conflicts: true + retry_hours: "1" + secrets: + PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} diff --git a/.github/workflows/hourly-nvidia-nim-review-repair.yml b/.github/workflows/hourly-nvidia-nim-review-repair.yml index 0cb5e33dc..de4a03314 100644 --- a/.github/workflows/hourly-nvidia-nim-review-repair.yml +++ b/.github/workflows/hourly-nvidia-nim-review-repair.yml @@ -10,6 +10,7 @@ on: - .github/workflows/clearfolio-hourly-review-repair.yml - .github/workflows/disksage-hourly-review-repair.yml - .github/workflows/fast-mlsirm-hourly-review-repair.yml + - .github/workflows/github-hourly-review-repair.yml - .github/workflows/governance-risk-compliance-hourly-review-repair.yml - .github/workflows/hourly-nvidia-nim-review-repair.yml - .github/workflows/originweave-hourly-review-repair.yml @@ -18,6 +19,7 @@ on: - tests/test_bandscope_hourly_review_caller.py - tests/test_disksage_hourly_review_caller.py - tests/test_fast_mlsirm_hourly_review_caller.py + - tests/test_github_hourly_conflict_repair.py - tests/test_governance_risk_compliance_hourly_review_caller.py - tests/test_hourly_scheduler_runtime_budget.py - tests/test_originweave_hourly_review_caller.py @@ -39,6 +41,7 @@ on: - docs/doctoring/conflict-control-evidence-isolation.md - docs/doctoring/disksage-hourly-review-caller.md - docs/doctoring/fast-mlsirm-hourly-review-caller.md + - docs/doctoring/github-hourly-conflict-repair.md - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - docs/doctoring/hourly-nvidia-nim-autofix.md - docs/doctoring/originweave-hourly-review-caller.md @@ -51,6 +54,7 @@ on: - .github/workflows/clearfolio-hourly-review-repair.yml - .github/workflows/disksage-hourly-review-repair.yml - .github/workflows/fast-mlsirm-hourly-review-repair.yml + - .github/workflows/github-hourly-review-repair.yml - .github/workflows/governance-risk-compliance-hourly-review-repair.yml - .github/workflows/hourly-nvidia-nim-review-repair.yml - .github/workflows/originweave-hourly-review-repair.yml @@ -59,6 +63,7 @@ on: - tests/test_bandscope_hourly_review_caller.py - tests/test_disksage_hourly_review_caller.py - tests/test_fast_mlsirm_hourly_review_caller.py + - tests/test_github_hourly_conflict_repair.py - tests/test_governance_risk_compliance_hourly_review_caller.py - tests/test_hourly_scheduler_runtime_budget.py - tests/test_originweave_hourly_review_caller.py @@ -80,6 +85,7 @@ on: - docs/doctoring/conflict-control-evidence-isolation.md - docs/doctoring/disksage-hourly-review-caller.md - docs/doctoring/fast-mlsirm-hourly-review-caller.md + - docs/doctoring/github-hourly-conflict-repair.md - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - docs/doctoring/hourly-nvidia-nim-autofix.md - docs/doctoring/originweave-hourly-review-caller.md @@ -133,6 +139,7 @@ jobs: tests/test_bandscope_hourly_review_caller.py \ tests/test_disksage_hourly_review_caller.py \ tests/test_fast_mlsirm_hourly_review_caller.py \ + tests/test_github_hourly_conflict_repair.py \ tests/test_governance_risk_compliance_hourly_review_caller.py \ tests/test_hourly_scheduler_runtime_budget.py \ tests/test_originweave_hourly_review_caller.py \ diff --git a/.github/workflows/pr-review-fix-scheduler.yml b/.github/workflows/pr-review-fix-scheduler.yml index 7eb0251d5..a3fdaa1aa 100644 --- a/.github/workflows/pr-review-fix-scheduler.yml +++ b/.github/workflows/pr-review-fix-scheduler.yml @@ -23,6 +23,11 @@ on: required: false default: "" type: string + resolve_unreviewed_conflicts: + description: Dispatch bounded conflict repair before the original head is reviewed + required: false + default: true + type: boolean retry_hours: description: Minimum hours before redispatching autofix for the same head required: false @@ -83,6 +88,7 @@ jobs: DRY_RUN: ${{ github.event.client_payload.dry_run == true || github.event.client_payload.dry_run == 'true' || inputs.dry_run == true }} MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '50' }} MAX_DISPATCHES: ${{ github.event.client_payload.max_dispatches || inputs.max_dispatches || '1' }} + RESOLVE_UNREVIEWED_CONFLICTS: ${{ github.event.client_payload.resolve_unreviewed_conflicts == true || github.event.client_payload.resolve_unreviewed_conflicts == 'true' || inputs.resolve_unreviewed_conflicts == true }} RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '1' }} AUTOFIX_WORKFLOW: pr-review-autofix.yml AUTOFIX_REPOSITORY: ContextualWisdomLab/.github @@ -104,20 +110,26 @@ jobs: "${TARGET_REPOSITORY:-}" exit 1 fi - if [ -z "$ALLOWED_TARGET_REPOSITORIES" ]; then - echo "::error::Scheduler target repository allowlist is not configured." - exit 1 - fi target_allowed=false - IFS=',' read -r -a allowed_targets <<<"$ALLOWED_TARGET_REPOSITORIES" - for candidate in "${allowed_targets[@]}"; do - candidate="${candidate//[[:space:]]/}" - if [ -n "$candidate" ] && [ "$candidate" = "$TARGET_REPOSITORY" ]; then - target_allowed=true - break + if [ -n "${GITHUB_REPOSITORY:-}" ] && + [ "$TARGET_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then + echo "Self-targeted scheduler invocation uses the protected caller repository." + target_allowed=true + else + if [ -z "$ALLOWED_TARGET_REPOSITORIES" ]; then + echo "::error::Scheduler target repository allowlist is not configured." + exit 1 fi - done + IFS=',' read -r -a allowed_targets <<<"$ALLOWED_TARGET_REPOSITORIES" + for candidate in "${allowed_targets[@]}"; do + candidate="${candidate//[[:space:]]/}" + if [ -n "$candidate" ] && [ "$candidate" = "$TARGET_REPOSITORY" ]; then + target_allowed=true + break + fi + done + fi if [ "$target_allowed" != "true" ]; then printf '::error::Scheduler target repository is not allowlisted: %s.\n' \ "$TARGET_REPOSITORY" @@ -127,7 +139,7 @@ jobs: # A reusable workflow receives its caller's original event payload, # so the hourly callers arrive as `schedule`, not `workflow_call`. # Only the direct repository_dispatch surface needs sender binding; - # every invocation still passes the target allowlist above. + # cross-repository invocations still pass the configured allowlist. if [ "$EVENT_NAME" = "repository_dispatch" ]; then if [ -z "$ALLOWED_DISPATCH_ACTOR" ] || [ "$DISPATCH_ACTOR" != "$ALLOWED_DISPATCH_ACTOR" ] || @@ -308,6 +320,9 @@ jobs: --autofix-workflow "$AUTOFIX_WORKFLOW" --autofix-repository "$AUTOFIX_REPOSITORY" ) + if [ "$RESOLVE_UNREVIEWED_CONFLICTS" = "true" ]; then + args+=(--resolve-unreviewed-conflicts) + fi if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run) fi diff --git a/docs/doctoring/github-hourly-conflict-repair.md b/docs/doctoring/github-hourly-conflict-repair.md new file mode 100644 index 000000000..2a3fc2a68 --- /dev/null +++ b/docs/doctoring/github-hourly-conflict-repair.md @@ -0,0 +1,119 @@ +# Central `.github` hourly OpenCode conflict repair + +## Decision + +The central repository scans its own open `main` pull requests once per hour and +dispatches the existing trusted OpenCode conflict worker for a same-repository +head reported by GitHub as `DIRTY` or `CONFLICTING`. + +A review is **not** a prerequisite for this bounded repair. Resolving the +conflict creates a new merge commit and therefore a new pull-request head; any +review of the old head cannot establish approval of the resulting combined +source. The repaired head must complete fresh review and required checks before +it can merge. + +Direct Python-library callers retain the historical approval prerequisite. The +trusted reusable workflow opts into unreviewed conflict repair explicitly with +`--resolve-unreviewed-conflicts`, making the privilege visible and testable. + +## Execution path + +```text +hourly protected-default-branch caller +→ exact open PR inventory +→ same-repository, non-draft, configured-base filter +→ GitHub DIRTY / CONFLICTING signal +→ head-scoped retry marker +→ repository_dispatch(pr-review-autofix, repair_mode=conflict) +→ exact live base/head revalidation +→ git merge --no-commit --no-ff +→ sealed NUL-delimited conflicted-path allowlist +→ whole-worktree snapshot outside the repository +→ OpenCode edits conflicted paths only +→ scope verification, conflict-marker rejection, syntax checks +→ live-head race check +→ merge commit push +→ fresh required reviews and checks +``` + +## Preserved security and governance boundaries + +- Draft pull requests remain ineligible. +- Fork and external-head pull requests remain read-only. +- The configured base branch must match. +- The worker refetches and validates the exact live base and head before writing. +- OpenCode receives no GitHub token, OIDC request token, shell permission, + external-directory permission, web access, task delegation, or arbitrary + JavaScript execution permission. +- The model may modify only paths Git reported as unmerged. +- Tracked, untracked, ignored, deleted, retargeted, and symbolic-link state is + included in the scope evidence. +- Unresolved conflict markers fail closed. +- A concurrent head movement prevents the push. +- Conflict repair never approves, merges, or releases the pull request; it only + produces a reviewable combined head. +- One repair is dispatched per scheduler pass, with a one-hour exact-head retry + interval and non-cancelling worker concurrency. +- `COPILOT_GITHUB_TOKEN` is not used. + +## Why approval-before-repair was removed from the scheduled path + +The previous selector required a current-head approval before conflict repair. +That created a circular dependency for PRs such as `.github#1098`: reviewers +could not assess a valid merge preview while the conflict prevented the safe +combined head from existing, and the conflict worker could not run until a +review approved the pre-resolution head. + +The correct evidence order is: + +```text +conflict detected +→ bounded mechanical/semantic repair +→ new exact head +→ review and checks on that exact head +→ guarded merge decision +``` + +This changes eligibility only. It does not weaken the worker's write boundary or +the repository's review, required-check, branch-protection, and merge gates. + +## Regression evidence + +`tests/test_github_hourly_conflict_repair.py` fixes the following contracts: + +1. An unreviewed `DIRTY` PR becomes eligible only when the trusted policy flag is + explicit. +2. Direct library use remains backward-compatible by default. +3. The CLI exposes the policy flag. +4. The reusable workflow enables the policy for hourly callers by default. +5. `.github` has its own hourly caller at minute 21. +6. A same-repository protected caller does not require a cross-repository target + allowlist entry, while cross-repository targets still do. +7. The focused NVIDIA NIM review-repair gate tracks the caller, regression test, + and this doctoring record. + +The pre-existing conflict-scope, control-file isolation, trusted Git executable, +ignored-path, symlink-target, exact-head, writer-security, and NVIDIA NIM +contract suites remain authoritative for the worker boundary. + +## Operator next action + +After this change reaches `main`, inspect the next `Central GitHub Hourly Review +Repair` run. A qualifying conflict should receive the head-scoped scheduler +marker, followed by a `PR Review Autofix` conflict-mode run. Confirm that the +new head has a merge commit whose parents are the previous PR head and the live +protected base, then require normal current-head reviews and checks before +merging. + +## References — APA 7th + +GitHub. (n.d.). *About protected branches*. GitHub Docs. +https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches + +GitHub. (n.d.). *Resolving a merge conflict using the command line*. GitHub Docs. +https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line + +National Institute of Standards and Technology. (2022). *Secure software +development framework (SSDF) version 1.1: Recommendations for mitigating the +risk of software vulnerabilities* (NIST Special Publication 800-218). +https://doi.org/10.6028/NIST.SP.800-218 diff --git a/scripts/ci/pr_review_fix_scheduler.py b/scripts/ci/pr_review_fix_scheduler.py index 0a4263e19..2c9745d09 100755 --- a/scripts/ci/pr_review_fix_scheduler.py +++ b/scripts/ci/pr_review_fix_scheduler.py @@ -174,20 +174,28 @@ def needs_rca_repair(pr: dict[str, Any]) -> tuple[bool, tuple[str, ...]]: CONFLICT_MERGE_STATES = frozenset({"DIRTY", "CONFLICTING"}) -def needs_conflict_resolution(pr: dict[str, Any]) -> tuple[bool, tuple[str, ...]]: - """Return whether an approved PR has a conflict safe to auto-resolve. - - Only a current-head-approved PR that GitHub reports as ``DIRTY`` or - ``CONFLICTING`` qualifies. The worker merges the base into the head and the - resulting head must be reviewed and checked again before merge. +def needs_conflict_resolution( + pr: dict[str, Any], + *, + allow_unreviewed: bool = False, +) -> tuple[bool, tuple[str, ...]]: + """Return whether a GitHub-reported conflict is safe to auto-resolve. + + Direct library callers retain the historical current-head approval + prerequisite unless ``allow_unreviewed`` is explicit. Trusted scheduled + callers enable it because conflict repair creates a new head and therefore + requires fresh reviews and checks regardless of the previous review state. """ merge_state = str(pr.get("mergeStateStatus") or "").upper() if merge_state not in CONFLICT_MERGE_STATES: return False, () - if not has_current_head_approval(pr): + approved = has_current_head_approval(pr) + if not approved and not allow_unreviewed: return False, () + review_state = "current-head approved" if approved else "unreviewed" return True, ( - f"current-head approved PR is {merge_state.lower()}; auto-resolving the merge conflict", + f"{review_state} PR is {merge_state.lower()}; auto-resolving the merge " + "conflict and requiring fresh review and checks on the resulting head", ) @@ -234,7 +242,7 @@ def dispatch_autofix( ``repair_mode=rca`` tells the trusted context collector to gather failed check evidence and widen the sealed edit scope only to current PR files. - ``resolve_conflict`` retains the separate approved-conflict path. + ``resolve_conflict`` retains the separately bounded conflict path. """ dispatch_repo = workflow_repository or repo if workflow != DEFAULT_AUTOFIX_WORKFLOW: @@ -303,7 +311,12 @@ def inspect_pr( repair_mode = "rca" reasons = rca_reasons else: - needs_resolve, resolve_reasons = needs_conflict_resolution(pr) + needs_resolve, resolve_reasons = needs_conflict_resolution( + pr, + allow_unreviewed=bool( + getattr(args, "resolve_unreviewed_conflicts", False) + ), + ) if not needs_resolve: return "skip", ( "no current-head autofixable review, failed-check RCA, or approved merge conflict", @@ -356,7 +369,12 @@ def process_queue(args: argparse.Namespace) -> int: continue needs_fix, _ = needs_autofix(pr) needs_rca, _ = needs_rca_repair(pr) - needs_resolve, _ = needs_conflict_resolution(pr) + needs_resolve, _ = needs_conflict_resolution( + pr, + allow_unreviewed=bool( + getattr(args, "resolve_unreviewed_conflicts", False) + ), + ) if needs_fix or needs_rca or needs_resolve: prs_needing_comments.append(pr) @@ -503,6 +521,12 @@ def self_test() -> int: {**approved_dirty_pr, "mergeStateStatus": "CLEAN"} ) == (False, ()) assert needs_conflict_resolution(dirty_pr) == (False, ()) + resolves, resolve_reasons = needs_conflict_resolution( + dirty_pr, + allow_unreviewed=True, + ) + assert resolves + assert "fresh review and checks" in resolve_reasons[0] model_exhausted_pr = { **pr, "reviews": { @@ -552,6 +576,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--max-prs", type=int, default=50) parser.add_argument("--max-dispatches", type=int, default=1) parser.add_argument("--retry-hours", type=int, default=24) + parser.add_argument("--resolve-unreviewed-conflicts", action="store_true") parser.add_argument("--autofix-workflow", default="pr-review-autofix.yml") parser.add_argument( "--autofix-repository", diff --git a/tests/test_github_hourly_conflict_repair.py b/tests/test_github_hourly_conflict_repair.py new file mode 100644 index 000000000..e905bbce8 --- /dev/null +++ b/tests/test_github_hourly_conflict_repair.py @@ -0,0 +1,133 @@ +"""Regression contracts for unattended OpenCode merge-conflict repair.""" + +from pathlib import Path +from typing import Any + +import pytest + +from scripts.ci import pr_review_fix_scheduler as scheduler + + +_CALLER = Path(".github/workflows/github-hourly-review-repair.yml") +_REUSABLE_SCHEDULER = Path(".github/workflows/pr-review-fix-scheduler.yml") + + +def _unreviewed_conflict() -> dict[str, object]: + """Return a same-repository PR whose current head has no review yet.""" + return { + "number": 1098, + "isDraft": False, + "baseRefName": "main", + "baseRefOid": "b" * 40, + "headRefName": "feature/conflict", + "headRefOid": "a" * 40, + "headRepository": {"nameWithOwner": "ContextualWisdomLab/.github"}, + "mergeStateStatus": "DIRTY", + "reviews": {"nodes": []}, + "reviewThreads": {"nodes": []}, + } + + +def test_explicit_policy_dispatches_unreviewed_conflict() -> None: + """Conflict repair must not wait for an approval invalidated by its own commit.""" + needs_repair, reasons = scheduler.needs_conflict_resolution( + _unreviewed_conflict(), + allow_unreviewed=True, + ) + + assert needs_repair + assert "fresh review and checks" in reasons[0] + + +def test_scheduler_dispatches_conflict_mode_for_unreviewed_head( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The trusted queue must reach the existing bounded conflict worker.""" + arguments = scheduler.parse_args( + [ + "--repo", + "ContextualWisdomLab/.github", + "--base-branch", + "main", + "--resolve-unreviewed-conflicts", + "--dry-run", + ] + ) + captured: dict[str, Any] = {} + + def capture_dispatch(_repo: str, _pr: dict[str, Any], **kwargs: Any) -> None: + """Capture dispatch arguments without invoking GitHub.""" + captured.update(kwargs) + + monkeypatch.setattr(scheduler, "dispatch_autofix", capture_dispatch) + monkeypatch.setattr( + scheduler, + "create_fix_marker", + lambda *_args, **_kwargs: None, + ) + + action, reasons = scheduler.inspect_pr( + "ContextualWisdomLab/.github", + _unreviewed_conflict(), + arguments, + comments=[], + ) + + assert action == "dispatch" + assert "fresh review and checks" in reasons[0] + assert captured["resolve_conflict"] is True + + +def test_default_library_policy_remains_backward_compatible() -> None: + """Direct library callers retain the prior approval requirement unless opted in.""" + assert scheduler.needs_conflict_resolution(_unreviewed_conflict()) == (False, ()) + + +def test_cli_exposes_unreviewed_conflict_policy() -> None: + """The trusted workflow can opt into unreviewed conflict repair explicitly.""" + arguments = scheduler.parse_args( + [ + "--repo", + "ContextualWisdomLab/.github", + "--base-branch", + "main", + "--resolve-unreviewed-conflicts", + ] + ) + + assert arguments.resolve_unreviewed_conflicts is True + + +def test_reusable_scheduler_enables_policy_for_hourly_callers() -> None: + """Central callers receive conflict repair by default without duplicating logic.""" + workflow = _REUSABLE_SCHEDULER.read_text(encoding="utf-8") + + assert "resolve_unreviewed_conflicts:" in workflow + policy_block = workflow.split("resolve_unreviewed_conflicts:", maxsplit=1)[1].split( + "retry_hours:", maxsplit=1 + )[0] + assert "default: true" in policy_block + assert "--resolve-unreviewed-conflicts" in workflow + + +def test_central_repository_has_hourly_self_caller() -> None: + """The central repository itself is scanned instead of relying on product callers.""" + workflow = _CALLER.read_text(encoding="utf-8") + + assert 'cron: "21 * * * *"' in workflow + assert "uses: ./.github/workflows/pr-review-fix-scheduler.yml" in workflow + assert "target_repository: ContextualWisdomLab/.github" in workflow + assert "base_branch: main" in workflow + assert "resolve_unreviewed_conflicts: true" in workflow + assert 'max_dispatches: "1"' in workflow + assert 'retry_hours: "1"' in workflow + assert "COPILOT_GITHUB_TOKEN" not in workflow + + +def test_scheduled_self_target_does_not_require_cross_repository_allowlist() -> None: + """A protected same-repository schedule is valid even without cross-repo config.""" + workflow = _REUSABLE_SCHEDULER.read_text(encoding="utf-8") + + assert 'if [ -n "${GITHUB_REPOSITORY:-}" ] &&' in workflow + assert '[ "$TARGET_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then' in workflow + assert "Self-targeted scheduler invocation uses the protected caller repository." in workflow diff --git a/tests/test_repository_branch_coverage_review_schedulers.py b/tests/test_repository_branch_coverage_review_schedulers.py index 8ee58db12..d50f94f05 100644 --- a/tests/test_repository_branch_coverage_review_schedulers.py +++ b/tests/test_repository_branch_coverage_review_schedulers.py @@ -138,7 +138,9 @@ def test_fix_scheduler_queue_includes_eligible_pr_without_fix_need( monkeypatch.setattr(fix_scheduler, "same_repository_head", lambda *_args: True) monkeypatch.setattr(fix_scheduler, "needs_autofix", lambda _pr: (False, ())) monkeypatch.setattr( - fix_scheduler, "needs_conflict_resolution", lambda _pr: (False, ()) + fix_scheduler, + "needs_conflict_resolution", + lambda _pr, **_kwargs: (False, ()), ) monkeypatch.setattr( fix_scheduler, "inspect_pr", lambda *_args, **_kwargs: ("skip", ("clean",)) From 8a65363b6bedc1f9e19cefe4253b872db0411b38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:36:41 -0700 Subject: [PATCH 8/9] chore(pr): remove unrelated changelog drift --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7fa5336c..7bf8ad766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ Semantic Versioning where the repository publishes a release. - Run the bounded fast-mlsirm repair heartbeat at minute 49 of every hour with one-dispatch scope and a two-hour same-head floor, without weakening true-parameter recovery, CPU/GPU parity, skipped-test, or Rust-ownership gates. - Use NVIDIA NIM `mistralai/mistral-small-4-119b-2603` with explicit high reasoning for scheduled repair and `nvidia/nemotron-3-nano-30b-a3b` for bounded helper work instead of GitHub Models in the write-capable autofix worker. - Apply one NUL-delimited exact-path and complete pre/post-worktree verification contract to both ordinary review repair and merge-conflict repair rather than relying on a visible post-model diff for the ordinary path. + +### Changed + - Avoided the expensive R/testthat failure-summary regular expression on marker-absent bounded logs by checking the required terminal marker first, while preserving fail-closed handling for incomplete or malformed failure evidence. ### Fixed @@ -32,7 +35,6 @@ Semantic Versioning where the repository publishes a release. - Parsed `opencode.jsonc` as JSONC (stripping `//` and `/* */` comments outside string literals) in the reasoning-effort guard and its contract tests, instead of raw `json.loads`, which rejected the file the moment it carried its first explanatory comment (added for the `contextual-orchestrator` provider block) with `Expecting property name enclosed in double quotes`. Comment markers inside string values, such as the `$schema` URL, are left untouched. - Download the pinned `uv` 0.12.1 exporter from the official GitHub Releases URL instead of `releases.astral.sh`, which now returns HTTP 403 and blocks org-wide OpenCode `coverage-evidence`. The SHA-256 pin is unchanged. The opener may follow one hop onto `release-assets.githubusercontent.com` or `objects.githubusercontent.com` and still rejects every other host, userinfo, non-HTTPS scheme, and nondefault port (ContextualWisdomLab/.github#1109). - Compared the trusted `uv` executable's post-install `--version` output against the real GitHub Releases build's full string, `uv 0.12.1 (x86_64-unknown-linux-gnu)`, instead of the bare `uv 0.12.1` the prior check required; the genuine release binary always prints the target triple, so every installation was failing the pin check immediately after the archive download itself was fixed (ContextualWisdomLab/.github#1109). -- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Excluded relative `-r` and `--requirement` referrers from generated flat base-lock publication while retaining bounded include syntax diagnostics and discovering independently complete direct `.txt` children of `requirements` directories. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. From 6529deb795c29b3b7178de03af04d37445da4792 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 18:46:14 -0700 Subject: [PATCH 9/9] chore(pr): restore bounded trusted-uv change tree --- .../agent-mention-opencode-dispatch.yml | 16 +- .github/workflows/agent-mention-router.yml | 10 +- .../workflows/github-hourly-review-repair.yml | 27 ---- .../hourly-nvidia-nim-review-repair.yml | 7 - .github/workflows/pr-review-fix-scheduler.yml | 39 ++--- .../review-agent-comment-invocation.md | 2 +- .../agent-mention-concurrency-isolation.md | 94 ------------ .../github-hourly-conflict-repair.md | 119 --------------- scripts/ci/agent_mention_router.py | 53 ++----- scripts/ci/pr_review_fix_scheduler.py | 47 ++---- ..._agent_mention_complete_payload_binding.py | 11 -- ...st_agent_mention_dispatch_payload_limit.py | 141 ------------------ tests/test_agent_mention_queue_isolation.py | 71 --------- tests/test_agent_mention_router.py | 10 +- tests/test_github_hourly_conflict_repair.py | 133 ----------------- ...itory_branch_coverage_review_schedulers.py | 4 +- 16 files changed, 58 insertions(+), 726 deletions(-) delete mode 100644 .github/workflows/github-hourly-review-repair.yml delete mode 100644 docs/doctoring/agent-mention-concurrency-isolation.md delete mode 100644 docs/doctoring/github-hourly-conflict-repair.md delete mode 100644 tests/test_agent_mention_dispatch_payload_limit.py delete mode 100644 tests/test_agent_mention_queue_isolation.py delete mode 100644 tests/test_github_hourly_conflict_repair.py diff --git a/.github/workflows/agent-mention-opencode-dispatch.yml b/.github/workflows/agent-mention-opencode-dispatch.yml index 02a3f6f08..160b4723d 100644 --- a/.github/workflows/agent-mention-opencode-dispatch.yml +++ b/.github/workflows/agent-mention-opencode-dispatch.yml @@ -36,11 +36,11 @@ jobs: BASE_BRANCH: ${{ github.event.client_payload.base_branch || '' }} REQUESTED_BY: ${{ github.event.client_payload.requested_by || '' }} SOURCE_COMMENT_ID: ${{ github.event.client_payload.source_comment_id || '' }} - TRIGGER_REVIEWS: "true" - REVIEW_DISPATCH_LIMIT: "1" - ENABLE_AUTO_MERGE: "false" - UPDATE_BRANCHES: "false" - MERGE_MODE: "disabled" + TRIGGER_REVIEWS: ${{ github.event.client_payload.trigger_reviews }} + REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.review_dispatch_limit || '' }} + ENABLE_AUTO_MERGE: ${{ github.event.client_payload.enable_auto_merge }} + UPDATE_BRANCHES: ${{ github.event.client_payload.update_branches }} + MERGE_MODE: ${{ github.event.client_payload.merge_mode || '' }} steps: - name: Validate exact invocation payload run: | @@ -195,7 +195,9 @@ jobs: --arg pr_head_sha "$PR_HEAD_SHA" \ --arg pr_base_sha "$PR_BASE_SHA" \ --arg base_branch "$BASE_BRANCH" \ + --arg requested_agent "$REQUESTED_AGENT" \ --arg agent_invocation_key "$INVOCATION_KEY" \ + --arg requested_by "$REQUESTED_BY" \ --argjson source_comment_id "$SOURCE_COMMENT_ID" \ '{ event_type: "merge-scheduler", @@ -205,10 +207,14 @@ jobs: pr_head_sha: $pr_head_sha, pr_base_sha: $pr_base_sha, base_branch: $base_branch, + trigger_reviews: true, + review_dispatch_limit: "1", enable_auto_merge: false, update_branches: false, merge_mode: "disabled", + requested_agent: $requested_agent, agent_invocation_key: $agent_invocation_key, + requested_by: $requested_by, source_comment_id: $source_comment_id } }' \ diff --git a/.github/workflows/agent-mention-router.yml b/.github/workflows/agent-mention-router.yml index b922ba5ab..f14667a93 100644 --- a/.github/workflows/agent-mention-router.yml +++ b/.github/workflows/agent-mention-router.yml @@ -6,6 +6,10 @@ on: schedule: - cron: "*/5 * * * *" +concurrency: + group: review-agent-mention-router-${{ github.repository }} + cancel-in-progress: false + # Organization required-workflow rules do not propagate issue_comment events # into sibling repositories. Keep the workflow default read-only; each bounded # job declares only the writes it actually needs. @@ -24,9 +28,6 @@ jobs: contains(github.event.comment.body, '@cwl-noema-review') || contains(github.event.comment.body, '@opencode-agent') ) - concurrency: - group: review-agent-mention-router-local-${{ github.repository }} - queue: max runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -69,9 +70,6 @@ jobs: if: >- github.repository == 'ContextualWisdomLab/.github' && github.event_name == 'schedule' - concurrency: - group: review-agent-mention-router-sweep-${{ github.repository }} - cancel-in-progress: false runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: diff --git a/.github/workflows/github-hourly-review-repair.yml b/.github/workflows/github-hourly-review-repair.yml deleted file mode 100644 index 97665aa5f..000000000 --- a/.github/workflows/github-hourly-review-repair.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Central GitHub Hourly Review Repair - -on: - schedule: - # Keep the control-plane queue moving without colliding with minute-zero jobs. - - cron: "21 * * * *" - -concurrency: - group: github-hourly-review-repair - cancel-in-progress: false - -permissions: - contents: read - -jobs: - dispatch-review-repair: - uses: ./.github/workflows/pr-review-fix-scheduler.yml - with: - target_repository: ContextualWisdomLab/.github - base_branch: main - max_prs: "50" - max_dispatches: "1" - resolve_unreviewed_conflicts: true - retry_hours: "1" - secrets: - PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} - OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} diff --git a/.github/workflows/hourly-nvidia-nim-review-repair.yml b/.github/workflows/hourly-nvidia-nim-review-repair.yml index de4a03314..0cb5e33dc 100644 --- a/.github/workflows/hourly-nvidia-nim-review-repair.yml +++ b/.github/workflows/hourly-nvidia-nim-review-repair.yml @@ -10,7 +10,6 @@ on: - .github/workflows/clearfolio-hourly-review-repair.yml - .github/workflows/disksage-hourly-review-repair.yml - .github/workflows/fast-mlsirm-hourly-review-repair.yml - - .github/workflows/github-hourly-review-repair.yml - .github/workflows/governance-risk-compliance-hourly-review-repair.yml - .github/workflows/hourly-nvidia-nim-review-repair.yml - .github/workflows/originweave-hourly-review-repair.yml @@ -19,7 +18,6 @@ on: - tests/test_bandscope_hourly_review_caller.py - tests/test_disksage_hourly_review_caller.py - tests/test_fast_mlsirm_hourly_review_caller.py - - tests/test_github_hourly_conflict_repair.py - tests/test_governance_risk_compliance_hourly_review_caller.py - tests/test_hourly_scheduler_runtime_budget.py - tests/test_originweave_hourly_review_caller.py @@ -41,7 +39,6 @@ on: - docs/doctoring/conflict-control-evidence-isolation.md - docs/doctoring/disksage-hourly-review-caller.md - docs/doctoring/fast-mlsirm-hourly-review-caller.md - - docs/doctoring/github-hourly-conflict-repair.md - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - docs/doctoring/hourly-nvidia-nim-autofix.md - docs/doctoring/originweave-hourly-review-caller.md @@ -54,7 +51,6 @@ on: - .github/workflows/clearfolio-hourly-review-repair.yml - .github/workflows/disksage-hourly-review-repair.yml - .github/workflows/fast-mlsirm-hourly-review-repair.yml - - .github/workflows/github-hourly-review-repair.yml - .github/workflows/governance-risk-compliance-hourly-review-repair.yml - .github/workflows/hourly-nvidia-nim-review-repair.yml - .github/workflows/originweave-hourly-review-repair.yml @@ -63,7 +59,6 @@ on: - tests/test_bandscope_hourly_review_caller.py - tests/test_disksage_hourly_review_caller.py - tests/test_fast_mlsirm_hourly_review_caller.py - - tests/test_github_hourly_conflict_repair.py - tests/test_governance_risk_compliance_hourly_review_caller.py - tests/test_hourly_scheduler_runtime_budget.py - tests/test_originweave_hourly_review_caller.py @@ -85,7 +80,6 @@ on: - docs/doctoring/conflict-control-evidence-isolation.md - docs/doctoring/disksage-hourly-review-caller.md - docs/doctoring/fast-mlsirm-hourly-review-caller.md - - docs/doctoring/github-hourly-conflict-repair.md - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - docs/doctoring/hourly-nvidia-nim-autofix.md - docs/doctoring/originweave-hourly-review-caller.md @@ -139,7 +133,6 @@ jobs: tests/test_bandscope_hourly_review_caller.py \ tests/test_disksage_hourly_review_caller.py \ tests/test_fast_mlsirm_hourly_review_caller.py \ - tests/test_github_hourly_conflict_repair.py \ tests/test_governance_risk_compliance_hourly_review_caller.py \ tests/test_hourly_scheduler_runtime_budget.py \ tests/test_originweave_hourly_review_caller.py \ diff --git a/.github/workflows/pr-review-fix-scheduler.yml b/.github/workflows/pr-review-fix-scheduler.yml index a3fdaa1aa..7eb0251d5 100644 --- a/.github/workflows/pr-review-fix-scheduler.yml +++ b/.github/workflows/pr-review-fix-scheduler.yml @@ -23,11 +23,6 @@ on: required: false default: "" type: string - resolve_unreviewed_conflicts: - description: Dispatch bounded conflict repair before the original head is reviewed - required: false - default: true - type: boolean retry_hours: description: Minimum hours before redispatching autofix for the same head required: false @@ -88,7 +83,6 @@ jobs: DRY_RUN: ${{ github.event.client_payload.dry_run == true || github.event.client_payload.dry_run == 'true' || inputs.dry_run == true }} MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '50' }} MAX_DISPATCHES: ${{ github.event.client_payload.max_dispatches || inputs.max_dispatches || '1' }} - RESOLVE_UNREVIEWED_CONFLICTS: ${{ github.event.client_payload.resolve_unreviewed_conflicts == true || github.event.client_payload.resolve_unreviewed_conflicts == 'true' || inputs.resolve_unreviewed_conflicts == true }} RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '1' }} AUTOFIX_WORKFLOW: pr-review-autofix.yml AUTOFIX_REPOSITORY: ContextualWisdomLab/.github @@ -110,26 +104,20 @@ jobs: "${TARGET_REPOSITORY:-}" exit 1 fi + if [ -z "$ALLOWED_TARGET_REPOSITORIES" ]; then + echo "::error::Scheduler target repository allowlist is not configured." + exit 1 + fi target_allowed=false - if [ -n "${GITHUB_REPOSITORY:-}" ] && - [ "$TARGET_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then - echo "Self-targeted scheduler invocation uses the protected caller repository." - target_allowed=true - else - if [ -z "$ALLOWED_TARGET_REPOSITORIES" ]; then - echo "::error::Scheduler target repository allowlist is not configured." - exit 1 + IFS=',' read -r -a allowed_targets <<<"$ALLOWED_TARGET_REPOSITORIES" + for candidate in "${allowed_targets[@]}"; do + candidate="${candidate//[[:space:]]/}" + if [ -n "$candidate" ] && [ "$candidate" = "$TARGET_REPOSITORY" ]; then + target_allowed=true + break fi - IFS=',' read -r -a allowed_targets <<<"$ALLOWED_TARGET_REPOSITORIES" - for candidate in "${allowed_targets[@]}"; do - candidate="${candidate//[[:space:]]/}" - if [ -n "$candidate" ] && [ "$candidate" = "$TARGET_REPOSITORY" ]; then - target_allowed=true - break - fi - done - fi + done if [ "$target_allowed" != "true" ]; then printf '::error::Scheduler target repository is not allowlisted: %s.\n' \ "$TARGET_REPOSITORY" @@ -139,7 +127,7 @@ jobs: # A reusable workflow receives its caller's original event payload, # so the hourly callers arrive as `schedule`, not `workflow_call`. # Only the direct repository_dispatch surface needs sender binding; - # cross-repository invocations still pass the configured allowlist. + # every invocation still passes the target allowlist above. if [ "$EVENT_NAME" = "repository_dispatch" ]; then if [ -z "$ALLOWED_DISPATCH_ACTOR" ] || [ "$DISPATCH_ACTOR" != "$ALLOWED_DISPATCH_ACTOR" ] || @@ -320,9 +308,6 @@ jobs: --autofix-workflow "$AUTOFIX_WORKFLOW" --autofix-repository "$AUTOFIX_REPOSITORY" ) - if [ "$RESOLVE_UNREVIEWED_CONFLICTS" = "true" ]; then - args+=(--resolve-unreviewed-conflicts) - fi if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run) fi diff --git a/docs/automation/review-agent-comment-invocation.md b/docs/automation/review-agent-comment-invocation.md index 3d2ca496d..51c84dcde 100644 --- a/docs/automation/review-agent-comment-invocation.md +++ b/docs/automation/review-agent-comment-invocation.md @@ -45,7 +45,7 @@ This preserves the central MSA boundary without copying privileged workflow code - `contents: write` is intentionally retained only on jobs that call GitHub's create-repository-dispatch endpoint. GitHub documents that endpoint as requiring Contents repository permission at write level. Removing it would disable the bounded central dispatch path; broad workflow-default write access is not granted. - The organization sweep uses the established cross-repository credential chain for reading target comments, while the central repository's own short-lived job token dispatches the central workflows. - OpenCode dispatch is restricted to the exact `OPENCODE_REPOSITORY_DISPATCH_TARGETS` allowlist. -- An invocation cannot merge: `enable_auto_merge=false`, `update_branches=false`, and `merge_mode=disabled` are bound into the OpenCode invocation claim and hardcoded in the wrapper. GitHub's create-repository-dispatch endpoint allows at most 10 top-level `client_payload` properties (HTTP 422 otherwise), so those review-only constants are not copied onto the first-hop mention payload. The wrapper's merge-scheduler forward keeps the three flags that override scheduler defaults, together with repository, PR, head/base SHA, base branch, invocation key, and source comment identity. +- An invocation cannot merge: `enable_auto_merge=false`, `update_branches=false`, and `merge_mode=disabled` are explicit in the dispatch payload. - Every dispatch is bound to live PR number, current head SHA, base branch, source comment, requested agent, and requesting actor metadata fetched or validated immediately before dispatch. - Router jobs use the fixed `ubuntu-24.04` runner and an immutable `actions/checkout` v7.0.1 commit pin; checkout credentials are not persisted. - A branch-selectable `workflow_dispatch` trigger is intentionally absent. This prevents a repository writer from choosing an unreviewed branch version of the central router while the job holds dispatch permissions. diff --git a/docs/doctoring/agent-mention-concurrency-isolation.md b/docs/doctoring/agent-mention-concurrency-isolation.md deleted file mode 100644 index 163a5bc85..000000000 --- a/docs/doctoring/agent-mention-concurrency-isolation.md +++ /dev/null @@ -1,94 +0,0 @@ -# Review-agent mention routing reliability - -Review date: **2026-08-19** - -## Incident - -Trusted `@opencode-agent` comments could remain unacknowledged and fail to start the existing OpenCode review path. Two independent control-plane defects produced the same operator-visible symptom before model execution. - -1. The OpenCode `repository_dispatch.client_payload` exceeded GitHub's ten-property limit, so GitHub rejected the request with HTTP 422 before the trusted wrapper started. -2. Interactive `issue_comment` routing and the five-minute organization sweep shared one workflow-level concurrency group. Under the default single-pending contract, a newly queued sweep could replace a pending interactive mention before exact-head resolution, durable claim creation, dispatch, or acknowledgement. - -Neither defect is evidence that the requesting maintainer, model, repository allowlist, or final review result is invalid. - -## Test-first repair - -The permanent regression contracts were committed before their corresponding production changes. - -- `tests/test_agent_mention_dispatch_payload_limit.py` requires both dispatch hops to stay at or below ten top-level payload properties and requires the router to reject an oversized payload before GitHub does. -- `tests/test_agent_mention_queue_isolation.py` requires the interactive route and scheduled sweep to use different job-level concurrency groups, with `queue: max` on the interactive route and no cancellation of in-progress interactive work. - -## Decision - -### Bounded dispatch envelope - -The router-to-wrapper OpenCode payload carries nine identity and provenance fields. Review-only behavior remains bound into the canonical invocation hash and is reconstructed by the trusted wrapper: - -```text -trigger_reviews=true -review_dispatch_limit=1 -enable_auto_merge=false -update_branches=false -merge_mode=disabled -``` - -The wrapper-to-scheduler payload carries exactly ten fields, including the three values that override unsafe scheduler defaults. The wrapper therefore remains review-only and cannot merge or update a branch. - -### Isolated concurrency queues - -Concurrency is scoped to each job rather than the whole workflow: - -```yaml -route-local-agent-mention: - concurrency: - group: review-agent-mention-router-local-${{ github.repository }} - queue: max - -sweep-organization-agent-mentions: - concurrency: - group: review-agent-mention-router-sweep-${{ github.repository }} - cancel-in-progress: false -``` - -GitHub documents that `queue: max` permits up to 100 pending jobs or workflow runs in one concurrency group and cannot be combined with `cancel-in-progress: true`. The interactive queue therefore retains bounded pending requests instead of replacing the previous pending request. Scheduled sweeps retain coalescing behavior in a separate group and cannot displace interactive work. - -Concurrency is not the idempotency authority. Duplicate forwarding remains governed by the complete canonical invocation key, exact-key downstream concurrency, and the immutable exact-name Actions artifact ledger. - -## Preserved boundaries - -- No model provider, reviewer identity, repository allowlist, token name, credential scope, or branch-protection rule changes. -- `COPILOT_GITHUB_TOKEN` remains unused. -- Workflow-default permissions remain read-only; existing bounded jobs keep only their required writes. -- Only trusted non-bot `OWNER`, `MEMBER`, or `COLLABORATOR` comments on open pull requests are eligible. -- Pull request number, exact head and base SHAs, base branch, source comment, requested agent, and requesting actor remain bound to the invocation key. -- Mention routing remains unable to approve, merge, update branches, publish, or release. - -## Operational acceptance - -After protected integration: - -1. submit a fresh trusted `@opencode-agent` comment on an open pull request; -2. require the hidden receipt marker, acknowledgement comment, or durable exact-name artifact for the source comment; -3. require the trusted OpenCode wrapper and review-only scheduler dispatch to start for the same repository, pull request, and exact head; -4. verify that a scheduled sweep cannot cancel or replace the interactive route; -5. distinguish downstream provider or review failure from routing failure rather than treating every missing verdict as the same incident. - -A receipt proves routing and durable claim processing. It is not an approval and never substitutes for exact-head checks or branch protection. - -## Rollback prohibition - -Do not restore either defective boundary: - -- do not increase the first- or second-hop payload beyond GitHub's limit; -- do not move local and scheduled work back into one workflow-level concurrency group; -- do not replace `queue: max` with the default single-pending interactive queue unless another independently reviewed durable queue preserves every eligible request. - -A safe emergency degradation may suspend the scheduled sweep while retaining the isolated interactive route. - -## References - -GitHub. (n.d.). *Control the concurrency of workflows and jobs*. GitHub Docs. Retrieved August 19, 2026, from https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency - -GitHub. (n.d.). *REST API endpoints for repositories: Create a repository dispatch event*. GitHub Docs. Retrieved August 19, 2026, from https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event - -GitHub. (n.d.). *Store and share data with workflow artifacts*. GitHub Docs. Retrieved August 19, 2026, from https://docs.github.com/en/actions/tutorials/store-and-share-data diff --git a/docs/doctoring/github-hourly-conflict-repair.md b/docs/doctoring/github-hourly-conflict-repair.md deleted file mode 100644 index 2a3fc2a68..000000000 --- a/docs/doctoring/github-hourly-conflict-repair.md +++ /dev/null @@ -1,119 +0,0 @@ -# Central `.github` hourly OpenCode conflict repair - -## Decision - -The central repository scans its own open `main` pull requests once per hour and -dispatches the existing trusted OpenCode conflict worker for a same-repository -head reported by GitHub as `DIRTY` or `CONFLICTING`. - -A review is **not** a prerequisite for this bounded repair. Resolving the -conflict creates a new merge commit and therefore a new pull-request head; any -review of the old head cannot establish approval of the resulting combined -source. The repaired head must complete fresh review and required checks before -it can merge. - -Direct Python-library callers retain the historical approval prerequisite. The -trusted reusable workflow opts into unreviewed conflict repair explicitly with -`--resolve-unreviewed-conflicts`, making the privilege visible and testable. - -## Execution path - -```text -hourly protected-default-branch caller -→ exact open PR inventory -→ same-repository, non-draft, configured-base filter -→ GitHub DIRTY / CONFLICTING signal -→ head-scoped retry marker -→ repository_dispatch(pr-review-autofix, repair_mode=conflict) -→ exact live base/head revalidation -→ git merge --no-commit --no-ff -→ sealed NUL-delimited conflicted-path allowlist -→ whole-worktree snapshot outside the repository -→ OpenCode edits conflicted paths only -→ scope verification, conflict-marker rejection, syntax checks -→ live-head race check -→ merge commit push -→ fresh required reviews and checks -``` - -## Preserved security and governance boundaries - -- Draft pull requests remain ineligible. -- Fork and external-head pull requests remain read-only. -- The configured base branch must match. -- The worker refetches and validates the exact live base and head before writing. -- OpenCode receives no GitHub token, OIDC request token, shell permission, - external-directory permission, web access, task delegation, or arbitrary - JavaScript execution permission. -- The model may modify only paths Git reported as unmerged. -- Tracked, untracked, ignored, deleted, retargeted, and symbolic-link state is - included in the scope evidence. -- Unresolved conflict markers fail closed. -- A concurrent head movement prevents the push. -- Conflict repair never approves, merges, or releases the pull request; it only - produces a reviewable combined head. -- One repair is dispatched per scheduler pass, with a one-hour exact-head retry - interval and non-cancelling worker concurrency. -- `COPILOT_GITHUB_TOKEN` is not used. - -## Why approval-before-repair was removed from the scheduled path - -The previous selector required a current-head approval before conflict repair. -That created a circular dependency for PRs such as `.github#1098`: reviewers -could not assess a valid merge preview while the conflict prevented the safe -combined head from existing, and the conflict worker could not run until a -review approved the pre-resolution head. - -The correct evidence order is: - -```text -conflict detected -→ bounded mechanical/semantic repair -→ new exact head -→ review and checks on that exact head -→ guarded merge decision -``` - -This changes eligibility only. It does not weaken the worker's write boundary or -the repository's review, required-check, branch-protection, and merge gates. - -## Regression evidence - -`tests/test_github_hourly_conflict_repair.py` fixes the following contracts: - -1. An unreviewed `DIRTY` PR becomes eligible only when the trusted policy flag is - explicit. -2. Direct library use remains backward-compatible by default. -3. The CLI exposes the policy flag. -4. The reusable workflow enables the policy for hourly callers by default. -5. `.github` has its own hourly caller at minute 21. -6. A same-repository protected caller does not require a cross-repository target - allowlist entry, while cross-repository targets still do. -7. The focused NVIDIA NIM review-repair gate tracks the caller, regression test, - and this doctoring record. - -The pre-existing conflict-scope, control-file isolation, trusted Git executable, -ignored-path, symlink-target, exact-head, writer-security, and NVIDIA NIM -contract suites remain authoritative for the worker boundary. - -## Operator next action - -After this change reaches `main`, inspect the next `Central GitHub Hourly Review -Repair` run. A qualifying conflict should receive the head-scoped scheduler -marker, followed by a `PR Review Autofix` conflict-mode run. Confirm that the -new head has a merge commit whose parents are the previous PR head and the live -protected base, then require normal current-head reviews and checks before -merging. - -## References — APA 7th - -GitHub. (n.d.). *About protected branches*. GitHub Docs. -https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches - -GitHub. (n.d.). *Resolving a merge conflict using the command line*. GitHub Docs. -https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line - -National Institute of Standards and Technology. (2022). *Secure software -development framework (SSDF) version 1.1: Recommendations for mitigating the -risk of software vulnerabilities* (NIST Special Publication 800-218). -https://doi.org/10.6028/NIST.SP.800-218 diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py index 2b5453139..bdb8ac3db 100644 --- a/scripts/ci/agent_mention_router.py +++ b/scripts/ci/agent_mention_router.py @@ -33,7 +33,6 @@ BASE_BRANCH_RE = re.compile(r"^(?!-)[A-Za-z0-9._/-]+$") ACTOR_RE = re.compile(r"^[A-Za-z0-9-]+$") RECEIPT_RE = re.compile(r"") -REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS = 10 @dataclass(frozen=True) @@ -370,36 +369,13 @@ def dispatched_agents( return frozenset(observed) -def repository_dispatch_body( - event_type: str, - client_payload: dict[str, Any], -) -> dict[str, Any]: - """Return a repository_dispatch body within GitHub's 10-key payload limit. - - GitHub's create-repository-dispatch endpoint accepts at most 10 top-level - ``client_payload`` properties. A larger object is rejected with HTTP 422, - so mention routing cannot enqueue a review. - """ - - if len(client_payload) > REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS: - raise ValueError( - "repository_dispatch client_payload has " - f"{len(client_payload)} keys; GitHub allows at most " - f"{REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS}" - ) - return { - "event_type": event_type, - "client_payload": client_payload, - } - - def noema_payload(request: MentionRequest) -> dict[str, Any]: """Return the durable Noema wrapper dispatch request body.""" agent = "cwl-noema-review" - return repository_dispatch_body( - "agent-mention-noema", - { + return { + "event_type": "agent-mention-noema", + "client_payload": { "target_repository": request.repository, "pr_number": request.pull_request_number, "pr_head_sha": request.pull_request_head_sha, @@ -410,32 +386,33 @@ def noema_payload(request: MentionRequest) -> dict[str, Any]: "requested_by": request.actor, "source_comment_id": request.comment_id, }, - ) + } def opencode_payload(request: MentionRequest) -> dict[str, Any]: - """Return the durable review-only OpenCode wrapper dispatch body. - - Review-only behavior flags stay in the invocation claim and are hardcoded - by the wrapper. Copying them onto this first hop exceeds GitHub's 10-key - ``client_payload`` limit and prevents mention pings from enqueueing. - """ + """Return the durable review-only OpenCode wrapper dispatch body.""" agent = "opencode-agent" - return repository_dispatch_body( - "agent-mention-opencode", - { + claim = agent_invocation_claim(request, agent) + return { + "event_type": "agent-mention-opencode", + "client_payload": { "target_repository": request.repository, "pr_number": request.pull_request_number, "pr_head_sha": request.pull_request_head_sha, "pr_base_sha": request.pull_request_base_sha, "base_branch": request.pull_request_base_branch, + "trigger_reviews": claim["trigger_reviews"], + "review_dispatch_limit": claim["review_dispatch_limit"], + "enable_auto_merge": claim["enable_auto_merge"], + "update_branches": claim["update_branches"], + "merge_mode": claim["merge_mode"], "requested_agent": agent, "agent_invocation_key": agent_invocation_key(request, agent), "requested_by": request.actor, "source_comment_id": request.comment_id, }, - ) + } def dispatch_request( diff --git a/scripts/ci/pr_review_fix_scheduler.py b/scripts/ci/pr_review_fix_scheduler.py index 2c9745d09..0a4263e19 100755 --- a/scripts/ci/pr_review_fix_scheduler.py +++ b/scripts/ci/pr_review_fix_scheduler.py @@ -174,28 +174,20 @@ def needs_rca_repair(pr: dict[str, Any]) -> tuple[bool, tuple[str, ...]]: CONFLICT_MERGE_STATES = frozenset({"DIRTY", "CONFLICTING"}) -def needs_conflict_resolution( - pr: dict[str, Any], - *, - allow_unreviewed: bool = False, -) -> tuple[bool, tuple[str, ...]]: - """Return whether a GitHub-reported conflict is safe to auto-resolve. - - Direct library callers retain the historical current-head approval - prerequisite unless ``allow_unreviewed`` is explicit. Trusted scheduled - callers enable it because conflict repair creates a new head and therefore - requires fresh reviews and checks regardless of the previous review state. +def needs_conflict_resolution(pr: dict[str, Any]) -> tuple[bool, tuple[str, ...]]: + """Return whether an approved PR has a conflict safe to auto-resolve. + + Only a current-head-approved PR that GitHub reports as ``DIRTY`` or + ``CONFLICTING`` qualifies. The worker merges the base into the head and the + resulting head must be reviewed and checked again before merge. """ merge_state = str(pr.get("mergeStateStatus") or "").upper() if merge_state not in CONFLICT_MERGE_STATES: return False, () - approved = has_current_head_approval(pr) - if not approved and not allow_unreviewed: + if not has_current_head_approval(pr): return False, () - review_state = "current-head approved" if approved else "unreviewed" return True, ( - f"{review_state} PR is {merge_state.lower()}; auto-resolving the merge " - "conflict and requiring fresh review and checks on the resulting head", + f"current-head approved PR is {merge_state.lower()}; auto-resolving the merge conflict", ) @@ -242,7 +234,7 @@ def dispatch_autofix( ``repair_mode=rca`` tells the trusted context collector to gather failed check evidence and widen the sealed edit scope only to current PR files. - ``resolve_conflict`` retains the separately bounded conflict path. + ``resolve_conflict`` retains the separate approved-conflict path. """ dispatch_repo = workflow_repository or repo if workflow != DEFAULT_AUTOFIX_WORKFLOW: @@ -311,12 +303,7 @@ def inspect_pr( repair_mode = "rca" reasons = rca_reasons else: - needs_resolve, resolve_reasons = needs_conflict_resolution( - pr, - allow_unreviewed=bool( - getattr(args, "resolve_unreviewed_conflicts", False) - ), - ) + needs_resolve, resolve_reasons = needs_conflict_resolution(pr) if not needs_resolve: return "skip", ( "no current-head autofixable review, failed-check RCA, or approved merge conflict", @@ -369,12 +356,7 @@ def process_queue(args: argparse.Namespace) -> int: continue needs_fix, _ = needs_autofix(pr) needs_rca, _ = needs_rca_repair(pr) - needs_resolve, _ = needs_conflict_resolution( - pr, - allow_unreviewed=bool( - getattr(args, "resolve_unreviewed_conflicts", False) - ), - ) + needs_resolve, _ = needs_conflict_resolution(pr) if needs_fix or needs_rca or needs_resolve: prs_needing_comments.append(pr) @@ -521,12 +503,6 @@ def self_test() -> int: {**approved_dirty_pr, "mergeStateStatus": "CLEAN"} ) == (False, ()) assert needs_conflict_resolution(dirty_pr) == (False, ()) - resolves, resolve_reasons = needs_conflict_resolution( - dirty_pr, - allow_unreviewed=True, - ) - assert resolves - assert "fresh review and checks" in resolve_reasons[0] model_exhausted_pr = { **pr, "reviews": { @@ -576,7 +552,6 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--max-prs", type=int, default=50) parser.add_argument("--max-dispatches", type=int, default=1) parser.add_argument("--retry-hours", type=int, default=24) - parser.add_argument("--resolve-unreviewed-conflicts", action="store_true") parser.add_argument("--autofix-workflow", default="pr-review-autofix.yml") parser.add_argument( "--autofix-repository", diff --git a/tests/test_agent_mention_complete_payload_binding.py b/tests/test_agent_mention_complete_payload_binding.py index c07025407..04562e93f 100644 --- a/tests/test_agent_mention_complete_payload_binding.py +++ b/tests/test_agent_mention_complete_payload_binding.py @@ -162,17 +162,6 @@ def test_wrappers_recompute_complete_claim_before_ledger_access() -> None: assert "--arg pr_base_sha \"$PR_BASE_SHA\"" in workflow assert "pr_base_sha: $pr_base_sha" in workflow - assert "github.event.client_payload.trigger_reviews" not in opencode - assert "github.event.client_payload.review_dispatch_limit" not in opencode - assert "github.event.client_payload.enable_auto_merge" not in opencode - assert "github.event.client_payload.update_branches" not in opencode - assert "github.event.client_payload.merge_mode" not in opencode - assert 'TRIGGER_REVIEWS: "true"' in opencode - assert 'REVIEW_DISPATCH_LIMIT: "1"' in opencode - assert 'ENABLE_AUTO_MERGE: "false"' in opencode - assert 'UPDATE_BRANCHES: "false"' in opencode - assert 'MERGE_MODE: "disabled"' in opencode - for field in ( '"trigger_reviews": os.environ["TRIGGER_REVIEWS"] == "true"', '"review_dispatch_limit": os.environ["REVIEW_DISPATCH_LIMIT"]', diff --git a/tests/test_agent_mention_dispatch_payload_limit.py b/tests/test_agent_mention_dispatch_payload_limit.py deleted file mode 100644 index 87ad68d8d..000000000 --- a/tests/test_agent_mention_dispatch_payload_limit.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Contract: mention repository_dispatch payloads stay within GitHub's 10-key limit.""" - -from __future__ import annotations - -import importlib.util -import re -import sys -from pathlib import Path -from types import ModuleType - -import pytest - -ROOT = Path(__file__).resolve().parents[1] -ROUTER_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" -NOEMA_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-noema-dispatch.yml" -OPENCODE_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-opencode-dispatch.yml" -GITHUB_DOCS = ( - "https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event" -) -WRAPPER_CLIENT_PAYLOAD_RE = re.compile( - r"client_payload:\s*\{(?P.*?)^\s+\}", - re.MULTILINE | re.DOTALL, -) -WRAPPER_PAYLOAD_KEY_RE = re.compile(r"^\s+([A-Za-z_][A-Za-z0-9_]*):", re.MULTILINE) -REQUIRED_IDENTITY_KEYS = frozenset( - { - "target_repository", - "pr_number", - "pr_head_sha", - "source_comment_id", - } -) -OPENCODE_FORWARD_SAFETY_KEYS = frozenset( - { - "enable_auto_merge", - "update_branches", - "merge_mode", - } -) - - -def _load_router() -> ModuleType: - """Load the router module from the pull-request source tree.""" - - module_name = "agent_mention_dispatch_payload_limit" - spec = importlib.util.spec_from_file_location(module_name, ROUTER_PATH) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module - - -def _request(module: ModuleType): - """Return one complete trusted mention request.""" - - return module.MentionRequest( - "ContextualWisdomLab/example", - 17, - "a" * 40, - "main", - 91, - "maintainer", - ("cwl-noema-review", "opencode-agent"), - pull_request_base_sha="b" * 40, - ) - - -def _wrapper_forward_payload_keys(workflow_text: str) -> tuple[str, ...]: - """Extract top-level client_payload keys from one wrapper forwarder.""" - - match = WRAPPER_CLIENT_PAYLOAD_RE.search(workflow_text) - assert match is not None - keys = tuple(WRAPPER_PAYLOAD_KEY_RE.findall(match.group("body"))) - assert keys - assert len(keys) == len(set(keys)) - return keys - - -def test_github_repository_dispatch_limit_is_ten_top_level_keys() -> None: - """The router constant matches GitHub's documented client_payload cap.""" - - router = _load_router() - assert router.REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS == 10 - assert GITHUB_DOCS in ( - ROOT / "docs" / "automation" / "review-agent-comment-invocation.md" - ).read_text(encoding="utf-8") - - -def test_mention_router_payloads_stay_within_github_key_limit() -> None: - """Both first-hop mention dispatches keep identity without exceeding 10 keys.""" - - router = _load_router() - request = _request(router) - noema = router.noema_payload(request)["client_payload"] - opencode = router.opencode_payload(request)["client_payload"] - limit = router.REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS - - assert len(noema) <= limit - assert len(opencode) <= limit - assert REQUIRED_IDENTITY_KEYS <= noema.keys() - assert REQUIRED_IDENTITY_KEYS <= opencode.keys() - assert { - "trigger_reviews", - "review_dispatch_limit", - "enable_auto_merge", - "update_branches", - "merge_mode", - }.isdisjoint(opencode.keys()) - - -def test_wrapper_forwarders_stay_within_github_key_limit() -> None: - """Mention-forwarder jq payloads also stay at or under 10 top-level keys.""" - - router = _load_router() - limit = router.REPOSITORY_DISPATCH_CLIENT_PAYLOAD_MAX_KEYS - noema_keys = _wrapper_forward_payload_keys( - NOEMA_WORKFLOW.read_text(encoding="utf-8") - ) - opencode_keys = _wrapper_forward_payload_keys( - OPENCODE_WORKFLOW.read_text(encoding="utf-8") - ) - - assert len(noema_keys) <= limit - assert len(opencode_keys) <= limit - assert REQUIRED_IDENTITY_KEYS <= set(noema_keys) - assert REQUIRED_IDENTITY_KEYS <= set(opencode_keys) - assert OPENCODE_FORWARD_SAFETY_KEYS <= set(opencode_keys) - assert "trigger_reviews" not in opencode_keys - assert "review_dispatch_limit" not in opencode_keys - assert "requested_agent" not in opencode_keys - assert "requested_by" not in opencode_keys - - -def test_repository_dispatch_body_rejects_more_than_ten_keys() -> None: - """An oversized client_payload fails closed before GitHub returns HTTP 422.""" - - router = _load_router() - oversized = {f"field_{index}": index for index in range(11)} - with pytest.raises(ValueError, match="GitHub allows at most 10"): - router.repository_dispatch_body("agent-mention-opencode", oversized) diff --git a/tests/test_agent_mention_queue_isolation.py b/tests/test_agent_mention_queue_isolation.py deleted file mode 100644 index 8af11e04a..000000000 --- a/tests/test_agent_mention_queue_isolation.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Regression contracts for isolated review-agent mention queues.""" - -from __future__ import annotations - -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router.yml" - - -def _job_block(workflow: str, job_name: str, next_job_name: str | None) -> str: - """Return one top-level workflow job bounded by the following job.""" - - jobs = workflow.split("\njobs:\n", 1)[1] - start = jobs.index(f" {job_name}:\n") - if next_job_name is None: - return jobs[start:] - end = jobs.index(f"\n {next_job_name}:\n", start) - return jobs[start:end] - - -def _concurrency_block(job: str) -> str: - """Return the job-scoped concurrency mapping before ``runs-on``.""" - - start = job.index(" concurrency:\n") - end = job.index("\n runs-on:", start) - return job[start:end] - - -def test_interactive_mentions_and_sweeps_use_independent_queues() -> None: - """A scheduled sweep cannot replace a pending trusted mention request.""" - - workflow = WORKFLOW.read_text(encoding="utf-8") - header = workflow.split("\njobs:\n", 1)[0] - local_job = _job_block( - workflow, - "route-local-agent-mention", - "sweep-organization-agent-mentions", - ) - sweep_job = _job_block( - workflow, - "sweep-organization-agent-mentions", - None, - ) - - assert not any(line.startswith("concurrency:") for line in header.splitlines()) - assert _concurrency_block(local_job) == ( - " concurrency:\n" - " group: review-agent-mention-router-local-${{ github.repository }}\n" - " queue: max" - ) - assert _concurrency_block(sweep_job) == ( - " concurrency:\n" - " group: review-agent-mention-router-sweep-${{ github.repository }}\n" - " cancel-in-progress: false" - ) - - -def test_interactive_queue_retains_pending_requests_without_cancellation() -> None: - """The bounded interactive queue retains work and never cancels in progress.""" - - workflow = WORKFLOW.read_text(encoding="utf-8") - local_job = _job_block( - workflow, - "route-local-agent-mention", - "sweep-organization-agent-mentions", - ) - concurrency = _concurrency_block(local_job) - - assert "queue: max" in concurrency - assert "cancel-in-progress: true" not in concurrency diff --git a/tests/test_agent_mention_router.py b/tests/test_agent_mention_router.py index 874a79e4f..4509d43f0 100644 --- a/tests/test_agent_mention_router.py +++ b/tests/test_agent_mention_router.py @@ -222,13 +222,9 @@ def test_eligible_agents_and_payloads() -> None: assert opencode["event_type"] == "agent-mention-opencode" assert opencode["client_payload"]["base_branch"] == "develop" assert opencode["client_payload"]["pr_base_sha"] == "b" * 40 - assert "merge_mode" not in opencode["client_payload"] - assert "enable_auto_merge" not in opencode["client_payload"] - assert "update_branches" not in opencode["client_payload"] - claim = module.agent_invocation_claim(request, "opencode-agent") - assert claim["merge_mode"] == "disabled" - assert claim["enable_auto_merge"] is False - assert claim["update_branches"] is False + assert opencode["client_payload"]["merge_mode"] == "disabled" + assert opencode["client_payload"]["enable_auto_merge"] is False + assert opencode["client_payload"]["update_branches"] is False def test_dispatch_uses_central_events_and_acknowledges() -> None: diff --git a/tests/test_github_hourly_conflict_repair.py b/tests/test_github_hourly_conflict_repair.py deleted file mode 100644 index e905bbce8..000000000 --- a/tests/test_github_hourly_conflict_repair.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Regression contracts for unattended OpenCode merge-conflict repair.""" - -from pathlib import Path -from typing import Any - -import pytest - -from scripts.ci import pr_review_fix_scheduler as scheduler - - -_CALLER = Path(".github/workflows/github-hourly-review-repair.yml") -_REUSABLE_SCHEDULER = Path(".github/workflows/pr-review-fix-scheduler.yml") - - -def _unreviewed_conflict() -> dict[str, object]: - """Return a same-repository PR whose current head has no review yet.""" - return { - "number": 1098, - "isDraft": False, - "baseRefName": "main", - "baseRefOid": "b" * 40, - "headRefName": "feature/conflict", - "headRefOid": "a" * 40, - "headRepository": {"nameWithOwner": "ContextualWisdomLab/.github"}, - "mergeStateStatus": "DIRTY", - "reviews": {"nodes": []}, - "reviewThreads": {"nodes": []}, - } - - -def test_explicit_policy_dispatches_unreviewed_conflict() -> None: - """Conflict repair must not wait for an approval invalidated by its own commit.""" - needs_repair, reasons = scheduler.needs_conflict_resolution( - _unreviewed_conflict(), - allow_unreviewed=True, - ) - - assert needs_repair - assert "fresh review and checks" in reasons[0] - - -def test_scheduler_dispatches_conflict_mode_for_unreviewed_head( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The trusted queue must reach the existing bounded conflict worker.""" - arguments = scheduler.parse_args( - [ - "--repo", - "ContextualWisdomLab/.github", - "--base-branch", - "main", - "--resolve-unreviewed-conflicts", - "--dry-run", - ] - ) - captured: dict[str, Any] = {} - - def capture_dispatch(_repo: str, _pr: dict[str, Any], **kwargs: Any) -> None: - """Capture dispatch arguments without invoking GitHub.""" - captured.update(kwargs) - - monkeypatch.setattr(scheduler, "dispatch_autofix", capture_dispatch) - monkeypatch.setattr( - scheduler, - "create_fix_marker", - lambda *_args, **_kwargs: None, - ) - - action, reasons = scheduler.inspect_pr( - "ContextualWisdomLab/.github", - _unreviewed_conflict(), - arguments, - comments=[], - ) - - assert action == "dispatch" - assert "fresh review and checks" in reasons[0] - assert captured["resolve_conflict"] is True - - -def test_default_library_policy_remains_backward_compatible() -> None: - """Direct library callers retain the prior approval requirement unless opted in.""" - assert scheduler.needs_conflict_resolution(_unreviewed_conflict()) == (False, ()) - - -def test_cli_exposes_unreviewed_conflict_policy() -> None: - """The trusted workflow can opt into unreviewed conflict repair explicitly.""" - arguments = scheduler.parse_args( - [ - "--repo", - "ContextualWisdomLab/.github", - "--base-branch", - "main", - "--resolve-unreviewed-conflicts", - ] - ) - - assert arguments.resolve_unreviewed_conflicts is True - - -def test_reusable_scheduler_enables_policy_for_hourly_callers() -> None: - """Central callers receive conflict repair by default without duplicating logic.""" - workflow = _REUSABLE_SCHEDULER.read_text(encoding="utf-8") - - assert "resolve_unreviewed_conflicts:" in workflow - policy_block = workflow.split("resolve_unreviewed_conflicts:", maxsplit=1)[1].split( - "retry_hours:", maxsplit=1 - )[0] - assert "default: true" in policy_block - assert "--resolve-unreviewed-conflicts" in workflow - - -def test_central_repository_has_hourly_self_caller() -> None: - """The central repository itself is scanned instead of relying on product callers.""" - workflow = _CALLER.read_text(encoding="utf-8") - - assert 'cron: "21 * * * *"' in workflow - assert "uses: ./.github/workflows/pr-review-fix-scheduler.yml" in workflow - assert "target_repository: ContextualWisdomLab/.github" in workflow - assert "base_branch: main" in workflow - assert "resolve_unreviewed_conflicts: true" in workflow - assert 'max_dispatches: "1"' in workflow - assert 'retry_hours: "1"' in workflow - assert "COPILOT_GITHUB_TOKEN" not in workflow - - -def test_scheduled_self_target_does_not_require_cross_repository_allowlist() -> None: - """A protected same-repository schedule is valid even without cross-repo config.""" - workflow = _REUSABLE_SCHEDULER.read_text(encoding="utf-8") - - assert 'if [ -n "${GITHUB_REPOSITORY:-}" ] &&' in workflow - assert '[ "$TARGET_REPOSITORY" = "$GITHUB_REPOSITORY" ]; then' in workflow - assert "Self-targeted scheduler invocation uses the protected caller repository." in workflow diff --git a/tests/test_repository_branch_coverage_review_schedulers.py b/tests/test_repository_branch_coverage_review_schedulers.py index d50f94f05..8ee58db12 100644 --- a/tests/test_repository_branch_coverage_review_schedulers.py +++ b/tests/test_repository_branch_coverage_review_schedulers.py @@ -138,9 +138,7 @@ def test_fix_scheduler_queue_includes_eligible_pr_without_fix_need( monkeypatch.setattr(fix_scheduler, "same_repository_head", lambda *_args: True) monkeypatch.setattr(fix_scheduler, "needs_autofix", lambda _pr: (False, ())) monkeypatch.setattr( - fix_scheduler, - "needs_conflict_resolution", - lambda _pr, **_kwargs: (False, ()), + fix_scheduler, "needs_conflict_resolution", lambda _pr: (False, ()) ) monkeypatch.setattr( fix_scheduler, "inspect_pr", lambda *_args, **_kwargs: ("skip", ("clean",))