diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 4ab0c1b8..9ddcf198 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -94,7 +94,8 @@ concurrency: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number) || github.event_name == 'workflow_call' && inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.event_name == 'workflow_call' && inputs.base_branch != '' && format('call-{0}', inputs.base_branch) || - github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) || + github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number > 0 && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) || + github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number > 0 && format('pr-{0}', github.event.client_payload.pr_number) || github.event_name == 'repository_dispatch' && github.run_id || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }} @@ -227,6 +228,70 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" + - name: Bind targeted central dispatch to live pull request metadata + id: dispatch_target + if: >- + github.event_name == 'repository_dispatch' && + github.event.client_payload.target_repository != '' + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} + MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} + TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository }} + TARGET_PR_NUMBER: ${{ github.event.client_payload.pr_number }} + run: | + set -euo pipefail + + if [ "$GITHUB_REPOSITORY" != "ContextualWisdomLab/.github" ]; then + echo "::error::Cross-repository targeted dispatch is only allowed from ContextualWisdomLab/.github." + exit 1 + fi + if [[ ! "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9._-]+$ ]]; then + echo "::error::target_repository must name a ContextualWisdomLab repository." + exit 1 + fi + if [[ ! "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::pr_number must be a positive integer for targeted dispatch." + exit 1 + fi + if [ "$MUTATION_TOKEN_SOURCE" = "github-token" ]; then + echo "::error::Targeted central dispatch has no sibling-repository credential. Configure PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the OpenCode app token exchange." + exit 1 + fi + + pull_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}")" + live_state="$(jq -r '.state // empty' <<<"$pull_json")" + base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_json")" + base_branch="$(jq -r '.base.ref // empty' <<<"$pull_json")" + base_sha="$(jq -r '.base.sha // empty' <<<"$pull_json")" + head_sha="$(jq -r '.head.sha // empty' <<<"$pull_json")" + + if [ "$live_state" != "open" ] || [ "$base_repository" != "$TARGET_REPOSITORY" ]; then + echo "::error::Targeted dispatch must resolve to an open PR whose base repository exactly matches target_repository." + exit 1 + fi + if [[ ! "$base_branch" =~ ^[A-Za-z0-9._/-]+$ ]] || + [[ "$base_branch" == "HEAD" ]] || [[ "$base_branch" == /* ]] || + [[ "$base_branch" == *".."* ]] || [[ "$base_branch" == *"@{"* ]] || + [[ "$base_branch" == *"//"* ]] || + [[ "$base_branch" == .* ]] || [[ "$base_branch" == */.* ]] || + [[ "$base_branch" == */ ]] || [[ "$base_branch" == *. ]]; then + echo "::error::Live PR base ref is not safe for targeted scheduler arguments." + exit 1 + fi + if [[ ! "$base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || [[ ! "$head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Live PR metadata did not provide exact 40-character base and head SHAs." + exit 1 + fi + + { + echo "repository=$TARGET_REPOSITORY" + echo "pr_number=$TARGET_PR_NUMBER" + echo "base_branch=$base_branch" + echo "base_sha=$base_sha" + echo "head_sha=$head_sha" + } >>"$GITHUB_OUTPUT" + echo "Targeted scheduler bound to ${TARGET_REPOSITORY}#${TARGET_PR_NUMBER} at head ${head_sha} (base ${base_branch}@${base_sha})." + - name: Resolve trusted scheduler source ref id: trusted_source env: @@ -406,21 +471,34 @@ jobs: env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} SCHEDULER_ACTIONS_TOKEN: ${{ github.token }} + # A targeted central run controls only central Actions. The app/PAT in + # GH_TOKEN reads and mutates the sibling PR, while github.token inspects + # and dispatches the central required workflows without making a + # doomed sibling Actions API request first. + SCHEDULER_ACTIONS_REPOSITORY: ${{ steps.dispatch_target.outputs.repository != '' && github.repository || '' }} # Same-repository dispatch credential: when this scheduler runs inside # ContextualWisdomLab/.github (the repository the required workflows are # dispatched on), the runner token can dispatch them without any # cross-repository PAT. The scheduler only uses it when # GITHUB_REPOSITORY equals the dispatch repository. SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} - SCHEDULER_READ_TOKEN: ${{ github.token }} + SCHEDULER_READ_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github + # Bound the aggregate central model-review queue across all target + # repositories. Per-run REVIEW_DISPATCH_LIMIT alone cannot prevent a + # large set of concurrent scheduler runs from saturating Actions. + ACTIVE_OPENCODE_REVIEW_LIMIT: ${{ vars.ACTIVE_OPENCODE_REVIEW_LIMIT || '16' }} SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} + SCHEDULER_TARGET_REPOSITORY: ${{ steps.dispatch_target.outputs.repository || github.repository }} + SCHEDULER_TARGET_BASE_BRANCH: ${{ steps.dispatch_target.outputs.base_branch || env.DEFAULT_BRANCH }} + SCHEDULER_TARGET_PR_NUMBER: ${{ steps.dispatch_target.outputs.pr_number || env.PULL_REQUEST_NUMBER }} + SCHEDULER_TARGET_HEAD_SHA: ${{ steps.dispatch_target.outputs.head_sha || '' }} run: | set -euo pipefail project_flow="$PROJECT_FLOW_INPUT" if [ -z "$project_flow" ]; then - case "$DEFAULT_BRANCH" in + case "$SCHEDULER_TARGET_BASE_BRANCH" in main|master) project_flow="github-flow" ;; develop) project_flow="git-flow" ;; *) project_flow="github-flow" ;; @@ -435,8 +513,8 @@ jobs: branch_update_limit="1" fi args=( - --repo "$GITHUB_REPOSITORY" - --base-branch "$DEFAULT_BRANCH" + --repo "$SCHEDULER_TARGET_REPOSITORY" + --base-branch "$SCHEDULER_TARGET_BASE_BRANCH" --max-prs "$MAX_PRS" --project-flow "$project_flow" --review-workflow "Required OpenCode Review" @@ -444,8 +522,11 @@ jobs: --branch-update-limit "$branch_update_limit" --stale-opencode-minutes "$STALE_OPENCODE_MINUTES" ) - if [ -n "$PULL_REQUEST_NUMBER" ]; then - args+=(--pr-number "$PULL_REQUEST_NUMBER") + if [ -n "$SCHEDULER_TARGET_PR_NUMBER" ]; then + args+=(--pr-number "$SCHEDULER_TARGET_PR_NUMBER") + fi + if [ -n "$SCHEDULER_TARGET_HEAD_SHA" ]; then + args+=(--expected-head-sha "$SCHEDULER_TARGET_HEAD_SHA") fi if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run) @@ -671,6 +752,7 @@ jobs: SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.sweep_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github + ACTIVE_OPENCODE_REVIEW_LIMIT: ${{ vars.ACTIVE_OPENCODE_REVIEW_LIMIT || '16' }} SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} run: | set -euo pipefail diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt index 4cf45203..655e090c 100644 --- a/requirements-strix-ci-hashes.txt +++ b/requirements-strix-ci-hashes.txt @@ -1501,9 +1501,9 @@ protobuf==6.33.6 \ # grpc-google-iam-v1 # grpcio-status # proto-plus -pyasn1==0.6.3 \ - --hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \ - --hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde +pyasn1==0.6.4 \ + --hash=sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81 \ + --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b # via pyasn1-modules pyasn1-modules==0.4.2 \ --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 5b024c84..a392e791 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -5,6 +5,7 @@ import argparse import base64 +import http.client import ipaddress import json import os @@ -29,6 +30,10 @@ "opencode-review-control-v1", ) REVIEW_BODY_HEAD_SHA_RE = re.compile(r"Head SHA:\s*`([0-9a-fA-F]{40})`") +NOEMA_REVIEW_MARKER_HEAD_RE = re.compile( + r"")]}), "noema", ) + assert noema.existing_noema_review( + make_pr( + reviews={ + "nodes": [ + review( + login="NoEmA", + body="", + ) + ] + } + ), + "NOEMA", + ) assert not noema.existing_noema_review(make_pr(reviews={"nodes": [review("DISMISSED", login="noema")]}), "noema") assert not noema.existing_noema_review(make_pr(reviews={"nodes": [review(commit="old", login="noema")]}), "noema") +def test_existing_noema_review_rejects_mismatched_body_and_marker_heads(): + current_head = "a" * 40 + previous_head = "b" * 40 + mismatched_body = review( + commit=current_head, + login="noema", + body=( + f"- Head SHA: `{previous_head}`\n" + f"" + ), + ) + mismatched_marker = review( + commit=current_head, + login="noema", + body=( + f"- Head SHA: `{current_head}`\n" + f"" + ), + ) + exact = review( + commit=current_head, + login="noema", + body=( + f"- Head SHA: `{current_head}`\n" + f"" + ), + ) + def pr(item): + return make_pr(headRefOid=current_head, reviews={"nodes": [item]}) + + assert not noema.existing_noema_review(pr(mismatched_body), "noema") + assert not noema.existing_noema_review(pr(mismatched_marker), "noema") + assert not noema.existing_noema_review( + pr( + review( + commit=current_head, + login="noema", + body=f"- Head SHA: `{current_head}`\nResult: APPROVE", + ) + ), + "noema", + ) + assert noema.existing_noema_review(pr(exact), "noema") + assert not noema.existing_noema_review( + pr(review(commit=current_head, login="attacker", body=exact["body"])), + "noema", + ) + assert not noema.existing_noema_review(pr(exact), "") + + def test_current_actor_fetch_diff_and_json_extraction(monkeypatch): - monkeypatch.setattr(noema, "run", lambda *args, **kwargs: "noema\n") + monkeypatch.setattr(noema, "run", lambda *args, **kwargs: "NoEmA\n") assert noema.current_actor() == "noema" monkeypatch.setattr(noema, "run", lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("no gh"))) assert noema.current_actor() == "" @@ -297,11 +360,12 @@ def test_review_context_reports_omitted_files_and_missing_codegraph(monkeypatch, class FakeResponse: - """Small context-manager response for urllib monkeypatches.""" + """Small response object for pinned HTTPS transport tests.""" - def __init__(self, payload): + def __init__(self, payload, status=200): """Store a JSON-serializable response payload.""" self.payload = payload + self.status = status def __enter__(self): """Return the response for with-statement use.""" @@ -325,98 +389,213 @@ def test_call_llm_handles_configuration_and_verdicts(monkeypatch): monkeypatch.setenv("NOEMA_LLM_API_URL", "file:///etc/passwd") monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") - with pytest.raises(ValueError, match="URL scheme must be http or https"): + with pytest.raises(ValueError, match="must use https"): noema.call_llm("owner/repo", 1, pr, "diff", False) - monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example.test/chat") - monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") - monkeypatch.setenv("NOEMA_LLM_MODEL", "review-model") + response = { + "value": FakeResponse( + {"choices": [{"message": {"content": '{"decision":"approve","summary":"ok","findings":[]}'}}]} + ) + } seen = {} - def fake_urlopen(request, timeout): - seen["url"] = request.full_url - seen["body"] = json.loads(request.data.decode("utf-8")) - return FakeResponse({"choices": [{"message": {"content": '{"decision":"approve","summary":"ok","findings":[]}'}}]}) + class FakeConnection: + def __init__(self, hostname, port, pinned_ips, *, timeout): + seen.update(hostname=hostname, port=port, pinned_ips=pinned_ips, timeout=timeout) - # Since we replaced urlopen with build_opener, we mock build_opener - class FakeOpener: - def __init__(self, call_func): - self.call_func = call_func - def open(self, request, timeout=None): - return self.call_func(request, timeout) + def request(self, method, target, *, body, headers): + seen.update( + method=method, + target=target, + body=json.loads(body.decode("utf-8")), + headers=headers, + ) + + def getresponse(self): + return response["value"] + + def close(self): + seen["closed"] = True + + def public_dns(host, port, *args, **kwargs): + return [ + (noema.socket.AF_INET, noema.socket.SOCK_STREAM, 6, "", ("8.8.8.8", port)), + (noema.socket.AF_INET, noema.socket.SOCK_STREAM, 6, "", ("8.8.8.8", port)), + ] - monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener(fake_urlopen)) + monkeypatch.setattr(noema.socket, "getaddrinfo", public_dns) + monkeypatch.setattr(noema, "PinnedHTTPSConnection", FakeConnection) + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example.test/chat;v=1?mode=review") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") + monkeypatch.setenv("NOEMA_LLM_MODEL", "review-model") verdict = noema.call_llm("owner/repo", 1, pr, "diff", True, "extra review context") assert verdict["decision"] == "approve" - assert seen["url"] == "https://llm.example.test/chat" + assert seen["hostname"] == "llm.example.test" + assert seen["port"] == 443 + assert seen["pinned_ips"] == ("8.8.8.8",) + assert seen["target"] == "/chat;v=1?mode=review" + assert seen["headers"]["authorization"] == "Bearer secret" assert seen["body"]["model"] == "review-model" assert "extra review context" in seen["body"]["messages"][1]["content"] + assert seen["closed"] is True - def fake_urlopen_defer(request, timeout=None): - return FakeResponse({"choices": [{"message": {"content": '{"decision":"defer"}'}}]}) - - monkeypatch.setattr( - noema.urllib.request, - "build_opener", - lambda *args: FakeOpener(fake_urlopen_defer) + response["value"] = FakeResponse( + {"choices": [{"message": {"content": '{"decision":"defer"}'}}]} ) with pytest.raises(RuntimeError, match="unsupported decision"): noema.call_llm("owner/repo", 1, pr, "diff", False) - # Test case-insensitive valid URL + response["value"] = FakeResponse( + {"choices": [{"message": {"content": '{"decision":"approve","summary":"ok","findings":[]}'}}]} + ) monkeypatch.setenv("NOEMA_LLM_API_URL", "HTTPS://llm.example.test/chat") - monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener(fake_urlopen)) assert noema.call_llm("owner/repo", 1, pr, "diff", True)["decision"] == "approve" - # Test invalid scheme (and no original URL in error) - monkeypatch.setenv("NOEMA_LLM_API_URL", "file:///etc/passwd") - with pytest.raises(ValueError, match="URL scheme must be http or https"): + monkeypatch.setenv("NOEMA_LLM_API_URL", "http://llm.example.test/chat") + with pytest.raises(ValueError, match="must use https"): noema.call_llm("owner/repo", 1, pr, "diff", False) - # Test localhost rejection - monkeypatch.setenv("NOEMA_LLM_API_URL", "http://localhost/chat") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://localhost/chat") with pytest.raises(ValueError, match="URL cannot target localhost"): noema.call_llm("owner/repo", 1, pr, "diff", False) - # Test missing hostname - monkeypatch.setenv("NOEMA_LLM_API_URL", "http:///chat") - with pytest.raises(ValueError, match="URL must have a valid hostname"): + monkeypatch.setenv("NOEMA_LLM_API_URL", "https:///chat") + with pytest.raises(ValueError, match="NOEMA_LLM_API_URL must have a valid hostname"): noema.call_llm("owner/repo", 1, pr, "diff", False) - # Test internal IP rejection - monkeypatch.setenv("NOEMA_LLM_API_URL", "http://169.254.169.254/chat") - with pytest.raises(ValueError, match="URL cannot target internal IP addresses"): + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example.test/chat path") + with pytest.raises(ValueError, match="NOEMA_LLM_API_URL cannot contain whitespace"): noema.call_llm("owner/repo", 1, pr, "diff", False) - import socket - original_getaddrinfo = socket.getaddrinfo - - # Test DNS resolution bypass - monkeypatch.setenv("NOEMA_LLM_API_URL", "http://resolved-to-local.example.com/chat") - def fake_getaddrinfo(host, port, *args, **kwargs): - if host == "resolved-to-local.example.com": - return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0))] - return original_getaddrinfo(host, port, *args, **kwargs) - monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) - with pytest.raises(ValueError, match="URL cannot target internal IP addresses"): + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://169.254.169.254/chat") + monkeypatch.setattr( + noema.socket, + "getaddrinfo", + lambda host, port, *args, **kwargs: [ + (noema.socket.AF_INET, noema.socket.SOCK_STREAM, 6, "", ("169.254.169.254", port)) + ], + ) + with pytest.raises( + ValueError, + match="NOEMA_LLM_API_URL cannot target non-public IP addresses", + ): noema.call_llm("owner/repo", 1, pr, "diff", False) - # Test unresolved hostname does not break - monkeypatch.setenv("NOEMA_LLM_API_URL", "http://unresolved.example.com/chat") - def fake_getaddrinfo_error(host, port, *args, **kwargs): - raise socket.gaierror("Name or service not known") - monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo_error) - monkeypatch.setattr(noema.urllib.request, "build_opener", lambda *args: FakeOpener(fake_urlopen)) - assert noema.call_llm("owner/repo", 1, pr, "diff", True)["decision"] == "approve" + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://unresolved.example.com/chat") + monkeypatch.setattr( + noema.socket, + "getaddrinfo", + lambda *args, **kwargs: (_ for _ in ()).throw(noema.socket.gaierror("not found")), + ) + with pytest.raises(ValueError, match="could not be resolved"): + noema.call_llm("owner/repo", 1, pr, "diff", False) - # Test invalid IP string from getaddrinfo (unlikely but theoretically possible) - monkeypatch.setenv("NOEMA_LLM_API_URL", "http://weird-dns.example.com/chat") - def fake_getaddrinfo_invalid_ip(host, port, *args, **kwargs): - if host == "weird-dns.example.com": - return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("not_an_ip", 0))] - return original_getaddrinfo(host, port, *args, **kwargs) - monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo_invalid_ip) - assert noema.call_llm("owner/repo", 1, pr, "diff", True)["decision"] == "approve" + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://weird-dns.example.com/chat") + monkeypatch.setattr( + noema.socket, + "getaddrinfo", + lambda host, port, *args, **kwargs: [ + (noema.socket.AF_INET, noema.socket.SOCK_STREAM, 6, "", ("not_an_ip", port)) + ], + ) + with pytest.raises(ValueError, match="invalid IP"): + noema.call_llm("owner/repo", 1, pr, "diff", False) + + monkeypatch.setattr(noema.socket, "getaddrinfo", lambda *args, **kwargs: []) + with pytest.raises(ValueError, match="no addresses"): + noema.call_llm("owner/repo", 1, pr, "diff", False) + + monkeypatch.setattr(noema.socket, "getaddrinfo", public_dns) + for unsafe_url, message in ( + ("https://user:password@llm.example.test/chat", "user information"), + ("https://llm.example.test/chat#fragment", "fragment"), + ("https://llm.example.test:bad/chat", "invalid port"), + ): + monkeypatch.setenv("NOEMA_LLM_API_URL", unsafe_url) + with pytest.raises(ValueError, match=message): + noema.call_llm("owner/repo", 1, pr, "diff", False) + + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example.test/chat") + response["value"] = FakeResponse({"error": "redirect denied"}, status=302) + with pytest.raises(RuntimeError, match="HTTP 302"): + noema.call_llm("owner/repo", 1, pr, "diff", False) + + +def test_pinned_https_connection_uses_numeric_peer_and_original_sni(monkeypatch): + """The transport must never resolve the validated hostname a second time.""" + seen = {} + + class FakeSocket: + def setsockopt(self, *args): + seen["setsockopt"] = args + + def close(self): + seen["closed"] = True + + fake_socket = FakeSocket() + + def fake_create_connection(address, timeout, source_address): + seen.update(address=address, timeout=timeout, source_address=source_address) + return fake_socket + + class FakeContext: + def wrap_socket(self, sock, *, server_hostname): + seen.update(wrapped_socket=sock, server_hostname=server_hostname) + return sock + + monkeypatch.setattr(noema.socket, "create_connection", fake_create_connection) + connection = noema.PinnedHTTPSConnection("llm.example.test", 443, "8.8.8.8", timeout=12) + connection._context = FakeContext() + connection.connect() + connection.close() + + assert seen["address"] == ("8.8.8.8", 443) + assert seen["server_hostname"] == "llm.example.test" + assert seen["wrapped_socket"] is fake_socket + assert seen["closed"] is True + + +def test_pinned_https_connection_falls_back_across_validated_addresses(monkeypatch): + """A failed numeric peer must fall back without another DNS lookup.""" + attempts = [] + + class FakeSocket: + def setsockopt(self, *args): + return None + + def close(self): + return None + + successful_socket = FakeSocket() + + def fake_create_connection(address, timeout, source_address): + attempts.append(address) + if address[0] == "8.8.8.8": + raise OSError("first peer unavailable") + return successful_socket + + class FakeContext: + def wrap_socket(self, sock, *, server_hostname): + assert server_hostname == "llm.example.test" + return sock + + monkeypatch.setattr(noema.socket, "create_connection", fake_create_connection) + monkeypatch.setattr( + noema.socket, + "getaddrinfo", + lambda *args, **kwargs: pytest.fail("pinned transport must not resolve DNS again"), + ) + connection = noema.PinnedHTTPSConnection( + "llm.example.test", + 443, + ("8.8.8.8", "1.1.1.1"), + timeout=12, + ) + connection._context = FakeContext() + connection.connect() + connection.close() + + assert attempts == [("8.8.8.8", 443), ("1.1.1.1", 443)] def test_noema_redirect_handler_rejects_redirects(): @@ -450,7 +629,7 @@ def raise_gaierror(host, port, *args, **kwargs): raise socket.gaierror("Name or service not known") monkeypatch.setattr(socket, "getaddrinfo", raise_gaierror) - with pytest.raises(ValueError, match="must start with http:// or https://"): + with pytest.raises(ValueError, match="control characters"): noema.call_llm("owner/repo", 1, pr, "diff", False) @@ -462,7 +641,7 @@ def test_call_llm_rejects_non_http_parsed_scheme(monkeypatch): parsed = noema.urllib.parse.ParseResult("file", "llm.example.test", "/chat", "", "", "") monkeypatch.setattr(noema.urllib.parse, "urlparse", lambda _: parsed) - with pytest.raises(ValueError, match="URL scheme must be http or https"): + with pytest.raises(ValueError, match="https scheme"): noema.call_llm("owner/repo", 1, pr, "diff", False) @@ -521,6 +700,7 @@ def test_inspect_and_review_skip_paths(monkeypatch): (make_pr(reviews={"nodes": [review(body=marker_body)]}, reviewThreads={"nodes": [{"isResolved": False, "isOutdated": False}]}), "noema"), (make_pr(reviews={"nodes": [review(body=marker_body)]}, statusCheckRollup={"contexts": {"nodes": [{"__typename": "StatusContext", "context": "ci", "state": "FAILURE"}]}}), "noema"), (clean_pr, "opencode-agent"), + (clean_pr, ""), ] for pr, actor in cases: calls.clear() diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index c8eb1d34..fd2e34ec 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1433,7 +1433,15 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): assert "secrets.PR_REVIEW_MERGE_TOKEN" in workflow assert "secrets.OPENCODE_APPROVE_TOKEN" in workflow assert "steps.scheduler_app_token.outputs.token" in workflow - assert "SCHEDULER_READ_TOKEN: ${{ github.token }}" in workflow + assert ( + "SCHEDULER_READ_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || " + "secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || " + "github.token }}" + ) in workflow + assert "SCHEDULER_ACTIONS_REPOSITORY:" in workflow + assert workflow.count( + "ACTIVE_OPENCODE_REVIEW_LIMIT: ${{ vars.ACTIVE_OPENCODE_REVIEW_LIMIT || '16' }}" + ) == 2 assert "SCHEDULER_MUTATION_TOKEN_SOURCE" in workflow assert 'default: "1"' in workflow assert 'review_dispatch_limit="-1"' in workflow diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 1fe10da7..47e99911 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1,5 +1,7 @@ import json import sys +import threading +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone import pytest @@ -349,6 +351,79 @@ def fake_run(args, stdin=None): assert sleeps == [1, 2] +def test_gh_graphql_retries_truncated_cli_json_errors(monkeypatch): + """A truncated GitHub response must retry before falling back to REST.""" + calls = [] + sleeps = [] + + def fake_run(args, stdin=None): + calls.append((args, stdin)) + if len(calls) < 4: + raise RuntimeError( + "Command failed (1): gh api graphql\nunexpected end of JSON input" + ) + return '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false}}}}}' + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "sleep", lambda seconds: sleeps.append(seconds)) + + payload = sched.gh_graphql("query", owner="owner", name="repo", pageSize=25) + + assert payload["data"]["repository"]["pullRequests"]["nodes"] == [] + assert len(calls) == 4 + assert sleeps == [1, 2, 4] + + +def test_gh_graphql_retries_truncated_success_stdout(monkeypatch): + """Invalid JSON on stdout is transient even when gh exits successfully.""" + responses = [ + '{"data":{"repository":', + '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false}}}}}', + ] + sleeps = [] + + monkeypatch.setattr(sched, "run", lambda args, stdin=None: responses.pop(0)) + monkeypatch.setattr(sched.time, "sleep", lambda seconds: sleeps.append(seconds)) + + payload = sched.gh_graphql("query", owner="owner", name="repo", pageSize=25) + + assert payload["data"]["repository"]["pullRequests"]["nodes"] == [] + assert sleeps == [1] + + +def test_gh_graphql_does_not_retry_structurally_invalid_json(monkeypatch): + """A complete non-JSON response fails immediately instead of hiding its cause.""" + calls = [] + sleeps = [] + + def fake_run(args, stdin=None): + calls.append((args, stdin)) + return "proxy authentication required" + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "sleep", lambda seconds: sleeps.append(seconds)) + + with pytest.raises(RuntimeError, match="invalid JSON") as exc_info: + sched.gh_graphql("query", owner="owner") + + assert isinstance(exc_info.value.__cause__, json.JSONDecodeError) + assert len(calls) == 1 + assert sleeps == [] + + +def test_gh_graphql_preserves_final_json_decode_cause(monkeypatch): + """A final invalid response must retain the parser exception for diagnostics.""" + monkeypatch.setattr(sched, "run", lambda args, stdin=None: '{"data":') + monkeypatch.setattr(sched.time, "sleep", lambda seconds: None) + + with pytest.raises(RuntimeError, match="invalid JSON") as exc_info: + sched.gh_graphql("query", owner="owner") + + assert isinstance(exc_info.value.__cause__, json.JSONDecodeError) + assert "line 1 column 9" in str(exc_info.value) + assert "char 8" in str(exc_info.value) + + def test_gh_graphql_retries_transient_http2_stream_cancel(monkeypatch): calls = [] sleeps = [] @@ -1642,8 +1717,32 @@ def fake_run(args, stdin=None): sched.dispatch_opencode_review("owner/repo", "OpenCode Review", required_workflow_pr, dry_run=False) sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", required_workflow_pr, dry_run=False) assert calls[:2] == [ - ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs", "-f", "status=queued", "-F", "per_page=100"], - ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs", "-f", "status=in_progress", "-F", "per_page=100"], + [ + "gh", + "api", + "--method", + "GET", + "repos/owner/repo/actions/runs", + "-f", + "status=queued", + "-F", + "per_page=100", + "-F", + "page=1", + ], + [ + "gh", + "api", + "--method", + "GET", + "repos/owner/repo/actions/runs", + "-f", + "status=in_progress", + "-F", + "per_page=100", + "-F", + "page=1", + ], ] assert calls[2:] == [ ["gh", "api", "-X", "POST", "repos/owner/repo/dispatches", "--input", "-"], @@ -1942,6 +2041,19 @@ def test_stacked_pr_waits_when_opencode_dispatch_is_already_active(monkeypatch): assert stacked.reason == "stacked PR onto develop; same-head OpenCode workflow run is already active" +def test_stacked_pr_waits_when_central_opencode_capacity_is_full(monkeypatch): + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "capacity_reached", + ) + + stacked = inspect(make_pr(baseRefName="develop")) + + assert stacked.action == "wait" + assert stacked.reason == "stacked PR onto develop; central OpenCode active-run capacity is full" + + def test_cross_repo_dispatch_wait_reason_can_be_explicitly_enabled(monkeypatch): monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") monkeypatch.delenv("SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH", raising=False) @@ -1995,6 +2107,128 @@ def test_scheduler_dispatch_env_is_noop_without_distinct_token(monkeypatch): assert sched.scheduler_dispatch_env() is None +def test_actions_repository_scope_skips_inaccessible_sibling_queries(monkeypatch, capsys): + """A central runner token must not query sibling Actions before dispatch.""" + monkeypatch.setenv( + "SCHEDULER_ACTIONS_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr( + sched, + "run_github_actions", + lambda *args, **kwargs: pytest.fail("out-of-scope Actions API call"), + ) + + assert sched.active_workflow_runs("ContextualWisdomLab/scopeweave") == [] + captured = capsys.readouterr() + assert "Actions run inspection skipped for ContextualWisdomLab/scopeweave" in captured.err + assert captured.out == "" + + +def test_actions_repository_scope_keeps_central_run_inspection(monkeypatch): + """The same scope still permits deduplication against central workflows.""" + calls = [] + monkeypatch.setenv( + "SCHEDULER_ACTIONS_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr( + sched, + "run_github_actions", + lambda args, stdin=None: calls.append(args) or json.dumps({"workflow_runs": []}), + ) + + assert sched.active_workflow_runs("ContextualWisdomLab/.github") == [] + assert len(calls) == 2 + assert all("repos/ContextualWisdomLab/.github/actions/runs" in call for call in calls) + + +def test_actions_repository_scope_is_case_insensitive(monkeypatch): + """GitHub repository identity must not depend on owner/name casing.""" + monkeypatch.setenv( + "SCHEDULER_ACTIONS_REPOSITORY", + "contextualwisdomlab/.GITHUB", + ) + + assert sched.scheduler_actions_repository_in_scope("ContextualWisdomLab/.github") + + +def test_actions_repository_scope_names_invalid_configuration(monkeypatch): + """An invalid scoped Actions repository must identify its configuration key.""" + monkeypatch.setenv("SCHEDULER_ACTIONS_REPOSITORY", "invalid-repository") + + with pytest.raises(RuntimeError, match="SCHEDULER_ACTIONS_REPOSITORY") as exc_info: + sched.scheduler_actions_repository_in_scope("ContextualWisdomLab/.github") + + assert "invalid-repository" in str(exc_info.value) + + +def test_active_workflow_runs_paginates_each_status(monkeypatch): + """Capacity accounting must include active runs beyond the first 100.""" + calls = [] + + def fake_run(args, stdin=None): + calls.append(args) + status = args[args.index("-f") + 1].split("=", 1)[1] + page = int(args[-1].split("=", 1)[1]) + if page == 1: + return json.dumps({"workflow_runs": [{"id": f"{status}-{index}"} for index in range(100)]}) + return json.dumps({"workflow_runs": [{"id": f"{status}-last"}]}) + + monkeypatch.setattr(sched, "run_github_actions", fake_run) + + runs = sched.active_workflow_runs("ContextualWisdomLab/.github") + + assert len(runs) == 202 + assert [call[-1] for call in calls] == ["page=1", "page=2", "page=1", "page=2"] + + +def test_active_workflow_runs_stops_at_exact_total_count(monkeypatch): + """An exact 100-run page must not trigger a redundant second API request.""" + calls = [] + + def fake_run(args, stdin=None): + calls.append(args) + status = args[args.index("-f") + 1].split("=", 1)[1] + return json.dumps( + { + "total_count": 100, + "workflow_runs": [ + {"id": f"{status}-{index}"} for index in range(100) + ], + } + ) + + monkeypatch.setattr(sched, "run_github_actions", fake_run) + + runs = sched.active_workflow_runs("ContextualWisdomLab/.github") + + assert len(runs) == 200 + assert [call[-1] for call in calls] == ["page=1", "page=1"] + + +def test_actions_repository_scope_refuses_sibling_job_rerun(monkeypatch): + """A central runner token must not rerun a sibling repository job.""" + monkeypatch.setenv( + "SCHEDULER_ACTIONS_REPOSITORY", + "ContextualWisdomLab/.github", + ) + + with pytest.raises( + RuntimeError, + match=( + "retry failed job refused for ContextualWisdomLab/scopeweave; " + "the Actions credential is scoped to ContextualWisdomLab/.github" + ), + ): + sched.rerun_actions_job( + "ContextualWisdomLab/scopeweave", + "42", + dry_run=False, + action="retry failed job", + ) + + def test_run_github_dispatch_uses_dispatch_token_env(monkeypatch): calls = [] monkeypatch.setenv("GH_TOKEN", "mutation-token") @@ -2065,7 +2299,19 @@ def fake_run(args, stdin=None): ) assert result == "already_running" - assert ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs", "-f", "status=queued", "-F", "per_page=100"] in calls + assert [ + "gh", + "api", + "--method", + "GET", + "repos/owner/repo/actions/runs", + "-f", + "status=queued", + "-F", + "per_page=100", + "-F", + "page=1", + ] in calls assert ["gh", "api", "-X", "POST", "repos/owner/repo/actions/runs/9001/force-cancel"] in calls assert not any("9002/force-cancel" in " ".join(call) for call in calls) assert not any("9003/force-cancel" in " ".join(call) for call in calls) @@ -2124,6 +2370,170 @@ def fake_run(args, stdin=None): assert not any(call[:3] == ["gh", "workflow", "run"] for call in calls) +def test_active_central_opencode_dispatch_refs_filters_unrelated_runs(monkeypatch): + central_runs = [ + { + "id": 9200, + "name": "Required OpenCode Review owner/repo#1@" + "a" * 40, + "display_title": "Required OpenCode Review owner/repo#1@" + "a" * 40, + "event": "repository_dispatch", + }, + { + "id": 9201, + "name": "Required OpenCode Review", + "display_title": "Required OpenCode Review owner/other#2@" + "b" * 40, + "event": "repository_dispatch", + }, + { + "id": 9202, + "name": "Required OpenCode Review", + "display_title": "ordinary pull request check", + "event": "pull_request_target", + }, + { + "id": 9203, + "name": "Strix Security Scan owner/repo#1@" + "a" * 40, + "display_title": "Strix Security Scan owner/repo#1@" + "a" * 40, + "event": "repository_dispatch", + }, + ] + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr( + sched, + "active_workflow_runs", + lambda repo, statuses=("queued", "in_progress"): central_runs, + ) + + assert sched.active_central_opencode_dispatch_refs( + "owner/repo", + "OpenCode Review", + ) == [ + ("ContextualWisdomLab/.github", "9200"), + ("ContextualWisdomLab/.github", "9201"), + ] + + +def test_central_dispatch_capacity_is_cached_and_reserved_per_heartbeat(monkeypatch): + """One heartbeat performs one central run read and counts accepted dispatches.""" + calls = 0 + + def fake_active_runs(repo, statuses=("queued", "in_progress")): + nonlocal calls + calls += 1 + assert repo == "ContextualWisdomLab/.github" + assert statuses == ("queued", "in_progress") + return [ + { + "id": 9200, + "name": "Required OpenCode Review", + "display_title": "Required OpenCode Review owner/repo#1@" + "a" * 40, + "event": "repository_dispatch", + } + ] + + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr(sched, "active_workflow_runs", fake_active_runs) + + with sched.central_opencode_dispatch_cache(): + first = sched.active_central_opencode_dispatch_refs( + "owner/repo", "OpenCode Review" + ) + second = sched.active_central_opencode_dispatch_refs( + "owner/other", "OpenCode Review" + ) + sched.reserve_central_opencode_dispatch_capacity( + "owner/repo", "OpenCode Review" + ) + reserved = sched.active_central_opencode_dispatch_refs( + "owner/repo", "OpenCode Review" + ) + + assert first == second == [("ContextualWisdomLab/.github", "9200")] + assert len(reserved) == 2 + assert reserved[-1][1].startswith("heartbeat-reservation-") + assert calls == 1 + + +def test_central_dispatch_capacity_cache_is_isolated_between_threads(monkeypatch): + """Concurrent scheduler contexts must not share mutable heartbeat caches.""" + barrier = threading.Barrier(2) + + def fake_active_runs(repo, statuses=("queued", "in_progress")): + del statuses + barrier.wait(timeout=30) + return [ + { + "id": threading.current_thread().name, + "name": "Required OpenCode Review", + "event": "repository_dispatch", + } + ] + + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr(sched, "active_workflow_runs", fake_active_runs) + + def cached_refs(): + with sched.central_opencode_dispatch_cache(): + first = sched.active_central_opencode_dispatch_refs( + "owner/repo", "OpenCode Review" + ) + second = sched.active_central_opencode_dispatch_refs( + "owner/repo", "OpenCode Review" + ) + return first, second + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda _: cached_refs(), range(2))) + + assert all(first == second for first, second in results) + assert results[0][0] != results[1][0] + + +def test_dispatch_opencode_review_defers_when_central_capacity_is_full(monkeypatch, capsys): + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GH_TOKEN", "workflow-token") + monkeypatch.setenv("ACTIVE_OPENCODE_REVIEW_LIMIT", "2") + monkeypatch.setattr(sched, "active_opencode_run_refs", lambda *args, **kwargs: ([], [])) + monkeypatch.setattr(sched, "force_cancel_workflow_run_refs", lambda refs: None) + monkeypatch.setattr( + sched, + "active_central_opencode_dispatch_refs", + lambda *args, **kwargs: [("central/repo", "1"), ("central/repo", "2")], + ) + monkeypatch.setattr( + sched, + "run_github_dispatch", + lambda *args, **kwargs: pytest.fail("capacity guard must prevent dispatch"), + ) + + result = sched.dispatch_opencode_review( + "owner/repo", + "OpenCode Review", + make_pr(baseRefOid="b" * 40, headRefOid="a" * 40), + dry_run=False, + ) + + assert result == "capacity_reached" + assert "central active-run capacity is 2/2" in capsys.readouterr().out + + +@pytest.mark.parametrize("value", ["not-an-integer", "-2"]) +def test_active_opencode_dispatch_limit_rejects_invalid_values(monkeypatch, value): + monkeypatch.setenv("ACTIVE_OPENCODE_REVIEW_LIMIT", value) + + with pytest.raises(RuntimeError, match="must be an integer greater than or equal to -1"): + sched.active_opencode_dispatch_limit() + + def test_dispatch_strix_cancels_stale_central_run_and_keeps_current(monkeypatch, capsys): calls = [] head_sha = "a" * 40 @@ -2657,15 +3067,15 @@ def test_print_summary_writes_github_step_summary(monkeypatch, tmp_path, capsys) assert payload["decisions"][5]["guidance"]["head_repository"] == "fork/repo" summary = summary_path.read_text(encoding="utf-8") assert "## PR review merge scheduler" in summary - assert "| #7 | block | merge conflict: DIRTY; base=main, head=feature\\|x; changed files to inspect first:" in summary + assert "| #7 | `block` | ``merge conflict: DIRTY; base=main, head=feature|x; changed files to inspect first:" in summary assert "do not retry update-branch until the conflict is repaired" in summary assert "### Outdated review threads" in summary assert "Would resolve 1 outdated review thread(s)" in summary assert ( - "| #8 | update_branch | current-head OpenCode review approved; " - "branch update requested with workflow GITHUB_TOKEN inside GitHub Actions as github-actions[bot] |" + "| #8 | `update_branch` | `current-head OpenCode review approved; " + "branch update requested with workflow GITHUB_TOKEN inside GitHub Actions as github-actions[bot]` |" ) in summary - assert "| #12 | merge | current head is approved; direct merge requested with workflow GITHUB_TOKEN" in summary + assert "| #12 | `merge` | `current head is approved; direct merge requested with workflow GITHUB_TOKEN" in summary assert "fresh same-head OpenCode review" in summary assert "### Conflict repair" in summary assert "When GitHub shows `Conflicting`" in summary @@ -2678,7 +3088,7 @@ def test_print_summary_writes_github_step_summary(monkeypatch, tmp_path, capsys) assert "Changed files to inspect first:" in summary assert "- `scripts/ci/pr_review_merge_scheduler.py`" in summary assert "- `tests/test_pr_review_merge_scheduler.py`" in summary - assert "- `docs/has\\`tick.md`" in summary + assert "- ``docs/has`tick.md``" in summary assert "git push --force-with-lease" in summary assert "### Branch update requests" in summary assert "Requested `update-branch` for PR #8 with `workflow GITHUB_TOKEN`" in summary @@ -2707,6 +3117,34 @@ def test_write_actions_summary_is_noop_without_summary_path(monkeypatch): ) +def test_summary_markdown_renderers_neutralize_untrusted_markup(monkeypatch, tmp_path): + """Workflow-controlled values cannot create headings, links, HTML, or table cells.""" + summary_path = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_path)) + malicious = "safe|cell\r\n## injected [click](https://evil.test) `tick`" + + assert sched.markdown_code_span("docs/has`tick.md") == "``docs/has`tick.md``" + assert sched.markdown_code_span("`edge`") == "`` `edge` ``" + assert sched.markdown_code_span("line1\rline2") == "`line1 line2`" + rendered_cell = sched.markdown_cell(malicious) + assert "\n" not in rendered_cell + assert "safe|cell" in rendered_cell + assert "\\|" not in rendered_cell + assert rendered_cell.startswith("``") and rendered_cell.endswith("``") + + sched.write_actions_summary( + [sched.Decision(1, malicious, malicious)], + counts={"wait": 1}, + dry_run=True, + base_branch=malicious, + project_flow=malicious, + ) + summary = summary_path.read_text(encoding="utf-8") + assert "\n## injected" not in summary + assert "\n N assert "github.event.workflow_run.pull_requests[0].number" in workflow +def test_targeted_central_scheduler_binds_live_sibling_pr_metadata() -> None: + """A bounded central dispatch must validate and operate on the requested live PR.""" + workflow = workflow_text("pr-review-merge-scheduler.yml") + + assert "Bind targeted central dispatch to live pull request metadata" in workflow + assert ( + "github.event.client_payload.target_repository != '' && " + "github.event.client_payload.pr_number > 0 && " + "format('target-{0}-pr-{1}'" + ) in workflow + assert "^ContextualWisdomLab/[A-Za-z0-9._-]+$" in workflow + assert 'pull_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}")"' in workflow + assert 'base_repository" != "$TARGET_REPOSITORY"' in workflow + assert '[[ "$base_branch" == "HEAD" ]]' in workflow + assert '[[ "$base_branch" == /* ]]' in workflow + assert '[[ "$base_branch" == *"//"* ]]' in workflow + assert "Live PR metadata did not provide exact 40-character base and head SHAs" in workflow + assert "SCHEDULER_ACTIONS_REPOSITORY:" in workflow + assert '--repo "$SCHEDULER_TARGET_REPOSITORY"' in workflow + assert '--base-branch "$SCHEDULER_TARGET_BASE_BRANCH"' in workflow + assert 'args+=(--pr-number "$SCHEDULER_TARGET_PR_NUMBER")' in workflow + assert 'args+=(--expected-head-sha "$SCHEDULER_TARGET_HEAD_SHA")' in workflow + assert 'case "$SCHEDULER_TARGET_BASE_BRANCH" in' in workflow + + def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: """Guard the org-wide approved-PR fallback sweep contract.