diff --git a/.github/workflows/agent-mention-opencode-dispatch.yml b/.github/workflows/agent-mention-opencode-dispatch.yml index 160b4723d..af8446286 100644 --- a/.github/workflows/agent-mention-opencode-dispatch.yml +++ b/.github/workflows/agent-mention-opencode-dispatch.yml @@ -1,7 +1,7 @@ name: Agent Mention OpenCode Dispatch run-name: >- - Agent Mention OpenCode ${{ github.event.client_payload.target_repository }}#${{ - github.event.client_payload.pr_number }} [cwl-agent-invocation:${{ + Agent Mention OpenCode ${{ github.event.client_payload.claim.repository }}#${{ + github.event.client_payload.claim.pr_number }} [cwl-agent-invocation:${{ github.event.client_payload.agent_invocation_key }}] on: @@ -26,40 +26,15 @@ jobs: contents: write env: GH_TOKEN: ${{ github.token }} - REQUESTED_AGENT: "opencode-agent" - PAYLOAD_AGENT: ${{ github.event.client_payload.requested_agent || '' }} + CLIENT_PAYLOAD_JSON: ${{ toJSON(github.event.client_payload) }} + PAYLOAD_SCHEMA: ${{ github.event.client_payload.schema || '' }} INVOCATION_KEY: ${{ github.event.client_payload.agent_invocation_key || '' }} - TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || '' }} - PR_NUMBER: ${{ github.event.client_payload.pr_number || '' }} - PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }} - PR_BASE_SHA: ${{ github.event.client_payload.pr_base_sha || '' }} - 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 || '' }} steps: - - name: Validate exact invocation payload + - name: Validate exact invocation payload and prepare scheduler request run: | set -euo pipefail - if [ "$PAYLOAD_AGENT" != "$REQUESTED_AGENT" ] || - ! [[ "$INVOCATION_KEY" =~ ^[0-9a-f]{64}$ ]] || - ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || - ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || - ! [[ "$PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] || - ! [[ "$BASE_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]] || - [[ "$BASE_BRANCH" == -* ]] || - ! [[ "$SOURCE_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || - [ "$TRIGGER_REVIEWS" != "true" ] || - [ "$REVIEW_DISPATCH_LIMIT" != "1" ] || - [ "$ENABLE_AUTO_MERGE" != "false" ] || - [ "$UPDATE_BRANCHES" != "false" ] || - [ "$MERGE_MODE" != "disabled" ] || - ! [[ "$REQUESTED_BY" =~ ^[A-Za-z0-9-]+$ ]]; then + if [ "$PAYLOAD_SCHEMA" != "cwl.agent-invocation/v2" ] || + ! [[ "$INVOCATION_KEY" =~ ^[0-9a-f]{64}$ ]]; then echo "::error::Rejected malformed or mismatched OpenCode agent invocation payload." exit 1 fi @@ -69,23 +44,85 @@ jobs: import hmac import json import os + from pathlib import Path + import re + + envelope = json.loads(os.environ["CLIENT_PAYLOAD_JSON"]) + envelope_keys = {"schema", "claim", "agent_invocation_key"} + claim_keys = { + "actor", + "agent", + "base_branch", + "base_sha", + "comment_id", + "enable_auto_merge", + "head_sha", + "merge_mode", + "pr_number", + "repository", + "review_dispatch_limit", + "trigger_reviews", + "update_branches", + } + if not isinstance(envelope, dict) or set(envelope) != envelope_keys: + raise SystemExit("invalid OpenCode invocation envelope") + claim = envelope["claim"] + if not isinstance(claim, dict): + raise SystemExit("OpenCode invocation claim must be an object") + if envelope["schema"] != "cwl.agent-invocation/v2": + raise SystemExit("unsupported OpenCode invocation schema") + if envelope["schema"] != os.environ["PAYLOAD_SCHEMA"]: + raise SystemExit("OpenCode invocation schema context mismatch") + if envelope["agent_invocation_key"] != os.environ["INVOCATION_KEY"]: + raise SystemExit("OpenCode invocation key context mismatch") + if set(claim) != claim_keys: + raise SystemExit("invalid OpenCode invocation claim fields") + + text_fields = { + "actor", + "agent", + "base_branch", + "base_sha", + "head_sha", + "merge_mode", + "repository", + "review_dispatch_limit", + } + if any(not isinstance(claim[field], str) for field in text_fields): + raise SystemExit("OpenCode invocation claim has a non-string text field") + if type(claim["comment_id"]) is not int or claim["comment_id"] < 1: + raise SystemExit("OpenCode invocation claim has an invalid comment id") + if type(claim["pr_number"]) is not int or claim["pr_number"] < 1: + raise SystemExit("OpenCode invocation claim has an invalid pull request number") + for field in ("enable_auto_merge", "trigger_reviews", "update_branches"): + if type(claim[field]) is not bool: + raise SystemExit(f"OpenCode invocation claim has an invalid {field} flag") + if not re.fullmatch(r"ContextualWisdomLab/[A-Za-z0-9_.-]+", claim["repository"]): + raise SystemExit("OpenCode invocation claim has an invalid repository") + if not re.fullmatch(r"[0-9a-f]{40}", claim["head_sha"]): + raise SystemExit("OpenCode invocation claim has an invalid head SHA") + if not re.fullmatch(r"[0-9a-f]{40}", claim["base_sha"]): + raise SystemExit("OpenCode invocation claim has an invalid base SHA") + if ( + not re.fullmatch(r"[A-Za-z0-9._/-]+", claim["base_branch"]) + or claim["base_branch"].startswith("-") + ): + raise SystemExit("OpenCode invocation claim has an invalid base branch") + if not re.fullmatch(r"[A-Za-z0-9-]+", claim["actor"]): + raise SystemExit("OpenCode invocation claim has an invalid actor") + expected_policy = { + "agent": "opencode-agent", + "enable_auto_merge": False, + "merge_mode": "disabled", + "review_dispatch_limit": "1", + "trigger_reviews": True, + "update_branches": False, + } + if any(claim[field] != value for field, value in expected_policy.items()): + raise SystemExit("OpenCode invocation claim violates review-only policy") canonical = json.dumps( - { - "actor": os.environ["REQUESTED_BY"], - "agent": os.environ["REQUESTED_AGENT"], - "base_branch": os.environ["BASE_BRANCH"], - "base_sha": os.environ["PR_BASE_SHA"], - "comment_id": int(os.environ["SOURCE_COMMENT_ID"]), - "enable_auto_merge": os.environ["ENABLE_AUTO_MERGE"] == "true", - "head_sha": os.environ["PR_HEAD_SHA"], - "merge_mode": os.environ["MERGE_MODE"], - "pr_number": int(os.environ["PR_NUMBER"]), - "repository": os.environ["TARGET_REPOSITORY"], - "review_dispatch_limit": os.environ["REVIEW_DISPATCH_LIMIT"], - "trigger_reviews": os.environ["TRIGGER_REVIEWS"] == "true", - "update_branches": os.environ["UPDATE_BRANCHES"] == "true", - }, + claim, ensure_ascii=True, separators=(",", ":"), sort_keys=True, @@ -93,6 +130,41 @@ jobs: expected = hashlib.sha256(canonical).hexdigest() if not hmac.compare_digest(expected, os.environ["INVOCATION_KEY"]): raise SystemExit("invocation key does not match canonical payload") + + scheduler_request = { + "event_type": "merge-scheduler-agent-review-v2", + "client_payload": envelope, + } + encoded_request = json.dumps( + scheduler_request, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + if len(envelope) > 10 or len(encoded_request) > 65_535: + raise SystemExit("scheduler repository dispatch exceeds GitHub limits") + + request_path = Path(os.environ["RUNNER_TEMP"]) / "agent-review-scheduler-request.json" + request_path.write_bytes(encoded_request) + exports = { + "BASE_BRANCH": claim["base_branch"], + "ENABLE_AUTO_MERGE": str(claim["enable_auto_merge"]).lower(), + "MERGE_MODE": claim["merge_mode"], + "PR_BASE_SHA": claim["base_sha"], + "PR_HEAD_SHA": claim["head_sha"], + "PR_NUMBER": str(claim["pr_number"]), + "REQUESTED_AGENT": claim["agent"], + "REQUESTED_BY": claim["actor"], + "REVIEW_DISPATCH_LIMIT": claim["review_dispatch_limit"], + "SCHEDULER_REQUEST_FILE": str(request_path), + "SOURCE_COMMENT_ID": str(claim["comment_id"]), + "TARGET_REPOSITORY": claim["repository"], + "TRIGGER_REVIEWS": str(claim["trigger_reviews"]).lower(), + "UPDATE_BRANCHES": str(claim["update_branches"]).lower(), + } + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as handle: + for name, value in exports.items(): + handle.write(f"{name}={value}\n") PYTHON - name: Inspect exact-name Actions artifact ledger @@ -189,33 +261,6 @@ jobs: if: steps.ledger.outputs.claim == 'true' run: | set -euo pipefail - jq -n \ - --arg target_repository "$TARGET_REPOSITORY" \ - --argjson pr_number "$PR_NUMBER" \ - --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", - client_payload: { - target_repository: $target_repository, - pr_number: $pr_number, - 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 - } - }' \ - | gh api "repos/${GITHUB_REPOSITORY}/dispatches" -X POST --input - + gh api "repos/${GITHUB_REPOSITORY}/dispatches" \ + -X POST \ + --input "$SCHEDULER_REQUEST_FILE" diff --git a/.github/workflows/agent-mention-router-quality-ci.yml b/.github/workflows/agent-mention-router-quality-ci.yml index f69cdce10..7492b7671 100644 --- a/.github/workflows/agent-mention-router-quality-ci.yml +++ b/.github/workflows/agent-mention-router-quality-ci.yml @@ -8,11 +8,21 @@ on: - ".github/workflows/agent-mention-router-quality-ci.yml" - ".github/workflows/agent-mention-noema-dispatch.yml" - ".github/workflows/agent-mention-opencode-dispatch.yml" + - ".github/workflows/opencode-review-dispatch.yml" + - ".github/workflows/pr-review-merge-scheduler.yml" + - ".github/workflows/strix.yml" - "docs/automation/review-agent-comment-invocation.md" - "scripts/ci/agent_mention_router.py" - "scripts/ci/agent_mention_sweep.py" + - "scripts/ci/pr_review_fix_scheduler.py" + - "scripts/ci/pr_review_merge_scheduler.py" + - "scripts/ci/test_strix_quick_gate.sh" - "tests/test_agent_mention_*.py" + - "tests/test_opencode_agent_contract.py" + - "tests/test_opencode_workflow_shell_syntax.py" + - "tests/test_pr_review_merge_scheduler.py" - "tests/test_pr_review_fix_scheduler_coverage.py" + - "tests/test_required_workflow_queue_contract.py" - "requirements-opencode-review-ci-hashes.txt" push: branches: [main] @@ -21,11 +31,21 @@ on: - ".github/workflows/agent-mention-router-quality-ci.yml" - ".github/workflows/agent-mention-noema-dispatch.yml" - ".github/workflows/agent-mention-opencode-dispatch.yml" + - ".github/workflows/opencode-review-dispatch.yml" + - ".github/workflows/pr-review-merge-scheduler.yml" + - ".github/workflows/strix.yml" - "docs/automation/review-agent-comment-invocation.md" - "scripts/ci/agent_mention_router.py" - "scripts/ci/agent_mention_sweep.py" + - "scripts/ci/pr_review_fix_scheduler.py" + - "scripts/ci/pr_review_merge_scheduler.py" + - "scripts/ci/test_strix_quick_gate.sh" - "tests/test_agent_mention_*.py" + - "tests/test_opencode_agent_contract.py" + - "tests/test_opencode_workflow_shell_syntax.py" + - "tests/test_pr_review_merge_scheduler.py" - "tests/test_pr_review_fix_scheduler_coverage.py" + - "tests/test_required_workflow_queue_contract.py" - "requirements-opencode-review-ci-hashes.txt" concurrency: @@ -99,6 +119,8 @@ jobs: include = scripts/ci/agent_mention_router.py scripts/ci/agent_mention_sweep.py + scripts/ci/pr_review_fix_scheduler.py + scripts/ci/pr_review_merge_scheduler.py [report] fail_under = 100 show_missing = True @@ -109,6 +131,8 @@ jobs: python -m coverage report --fail-under=100 python -m interrogate --fail-under=100 \ scripts/ci/agent_mention_router.py \ - scripts/ci/agent_mention_sweep.py + scripts/ci/agent_mention_sweep.py \ + scripts/ci/pr_review_fix_scheduler.py \ + scripts/ci/pr_review_merge_scheduler.py python -m compileall -q scripts/ci tests git diff --check "$CHANGE_DIFF_RANGE" diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 8e1157060..0520a555b 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -84,7 +84,7 @@ on: # within ~15 minutes instead of sitting idle for up to an hour. - cron: "*/15 * * * *" repository_dispatch: - types: [merge-scheduler] + types: [merge-scheduler, merge-scheduler-agent-review-v2] concurrency: group: >- @@ -95,8 +95,10 @@ concurrency: 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 == 'schedule' && format('schedule-{0}', github.event.schedule) || - github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != '' && 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 != '' && format('pr-{0}', github.event.client_payload.pr_number) || + github.event_name == 'repository_dispatch' && github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.agent_invocation_key != '' && format('agent-review-{0}', github.event.client_payload.agent_invocation_key) || + github.event_name == 'repository_dispatch' && github.event.action == 'merge-scheduler-agent-review-v2' && github.run_id || + github.event_name == 'repository_dispatch' && github.event.action != 'merge-scheduler-agent-review-v2' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != '' && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) || + github.event_name == 'repository_dispatch' && github.event.action != 'merge-scheduler-agent-review-v2' && github.event.client_payload.pr_number != '' && 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' }} @@ -150,17 +152,17 @@ jobs: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true GH_TOKEN: ${{ github.token }} - DEFAULT_BRANCH: ${{ github.event.client_payload.base_branch || inputs.base_branch || github.event.repository.default_branch }} + DEFAULT_BRANCH: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.base_branch || github.event.client_payload.base_branch || inputs.base_branch || github.event.repository.default_branch }} DRY_RUN: ${{ github.event.client_payload.dry_run == true || inputs.dry_run == true }} MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '100' }} PROJECT_FLOW_INPUT: ${{ github.event.client_payload.project_flow || inputs.project_flow || vars.PROJECT_FLOW || '' }} - PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pr_number || inputs.pr_number || '' }} - TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || github.event_name == 'push' || github.event_name == 'pull_request_target' || (github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false) || inputs.trigger_reviews == true }} - REVIEW_DISPATCH_LIMIT_INPUT: ${{ github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || vars.REVIEW_DISPATCH_LIMIT || '1' }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.pr_number || github.event.client_payload.pr_number || inputs.pr_number || '' }} + TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || github.event_name == 'push' || github.event_name == 'pull_request_target' || (github.event_name == 'repository_dispatch' && github.event.action == 'merge-scheduler-agent-review-v2') || (github.event_name == 'repository_dispatch' && github.event.action != 'merge-scheduler-agent-review-v2' && github.event.client_payload.trigger_reviews != false) || inputs.trigger_reviews == true }} + REVIEW_DISPATCH_LIMIT_INPUT: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && '1' || github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || vars.REVIEW_DISPATCH_LIMIT || '1' }} BRANCH_UPDATE_LIMIT_INPUT: ${{ github.event.client_payload.branch_update_limit || inputs.branch_update_limit || vars.BRANCH_UPDATE_LIMIT || '1' }} - ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false) || inputs.enable_auto_merge == true }} - MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || vars.PR_MERGE_MODE || 'direct_or_auto' }} - UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false) || inputs.update_branches == true }} + ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.action != 'merge-scheduler-agent-review-v2' && github.event.client_payload.enable_auto_merge != false) || inputs.enable_auto_merge == true }} + MERGE_MODE: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && 'disabled' || github.event.client_payload.merge_mode || inputs.merge_mode || vars.PR_MERGE_MODE || 'direct_or_auto' }} + UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.action != 'merge-scheduler-agent-review-v2' && github.event.client_payload.update_branches != false) || inputs.update_branches == true }} STALE_OPENCODE_MINUTES: ${{ github.event.client_payload.stale_opencode_minutes || inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '90' }} steps: - name: Exchange OpenCode app token for scheduler mutations @@ -233,13 +235,124 @@ jobs: id: targeted_dispatch env: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} - TARGET_REPOSITORY_INPUT: ${{ github.event.client_payload.target_repository || '' }} - TARGET_PR_NUMBER: ${{ github.event.client_payload.pr_number || '' }} - TARGET_BASE_BRANCH_INPUT: ${{ github.event.client_payload.base_branch || '' }} + DISPATCH_CLIENT_PAYLOAD_JSON: ${{ toJSON(github.event.client_payload) }} + GITHUB_EVENT_ACTION: ${{ github.event.action || '' }} + TARGET_REPOSITORY_INPUT: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.repository || github.event.client_payload.target_repository || '' }} + TARGET_PR_NUMBER: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.pr_number || github.event.client_payload.pr_number || '' }} + TARGET_BASE_BRANCH_INPUT: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.base_branch || github.event.client_payload.base_branch || '' }} + TARGET_EXPECTED_BASE_BRANCH_INPUT: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.base_branch || '' }} + TARGET_HEAD_SHA_INPUT: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.head_sha || '' }} + TARGET_BASE_SHA_INPUT: ${{ github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.base_sha || '' }} ALLOWED_TARGET_REPOSITORIES: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} run: | set -euo pipefail + EXPECTED_SCHEMA="cwl.agent-invocation/v2" + export EXPECTED_SCHEMA + python3 - <<'PYTHON' + import hashlib + import hmac + import json + import os + import re + + envelope = json.loads(os.environ["DISPATCH_CLIENT_PAYLOAD_JSON"] or "null") + if envelope is None: + envelope = {} + if not isinstance(envelope, dict): + raise SystemExit("repository dispatch client_payload must be an object") + is_agent_review = ( + os.environ["GITHUB_EVENT_ACTION"] == "merge-scheduler-agent-review-v2" + ) + if not is_agent_review: + if "schema" in envelope or "claim" in envelope: + raise SystemExit( + "versioned scheduler payload cannot use the legacy merge-scheduler event" + ) + raise SystemExit(0) + + envelope_keys = {"schema", "claim", "agent_invocation_key"} + claim_keys = { + "actor", + "agent", + "base_branch", + "base_sha", + "comment_id", + "enable_auto_merge", + "head_sha", + "merge_mode", + "pr_number", + "repository", + "review_dispatch_limit", + "trigger_reviews", + "update_branches", + } + if set(envelope) != envelope_keys: + raise SystemExit("invalid agent-review scheduler envelope fields") + if envelope["schema"] != os.environ["EXPECTED_SCHEMA"]: + raise SystemExit("unsupported agent-review scheduler schema") + invocation_key = envelope["agent_invocation_key"] + if not isinstance(invocation_key, str) or not re.fullmatch( + r"[0-9a-f]{64}", invocation_key + ): + raise SystemExit("invalid agent-review scheduler invocation key") + claim = envelope["claim"] + if not isinstance(claim, dict) or set(claim) != claim_keys: + raise SystemExit("invalid agent-review scheduler claim fields") + + text_fields = { + "actor", + "agent", + "base_branch", + "base_sha", + "head_sha", + "merge_mode", + "repository", + "review_dispatch_limit", + } + if any(not isinstance(claim[field], str) for field in text_fields): + raise SystemExit("agent-review scheduler claim has a non-string text field") + if type(claim["comment_id"]) is not int or claim["comment_id"] < 1: + raise SystemExit("agent-review scheduler claim has an invalid comment id") + if type(claim["pr_number"]) is not int or claim["pr_number"] < 1: + raise SystemExit("agent-review scheduler claim has an invalid PR number") + for field in ("enable_auto_merge", "trigger_reviews", "update_branches"): + if type(claim[field]) is not bool: + raise SystemExit(f"agent-review scheduler claim has an invalid {field} flag") + if not re.fullmatch(r"ContextualWisdomLab/[A-Za-z0-9_.-]+", claim["repository"]): + raise SystemExit("agent-review scheduler claim has an invalid repository") + if not re.fullmatch(r"[0-9a-f]{40}", claim["head_sha"]): + raise SystemExit("agent-review scheduler claim has an invalid head SHA") + if not re.fullmatch(r"[0-9a-f]{40}", claim["base_sha"]): + raise SystemExit("agent-review scheduler claim has an invalid base SHA") + if ( + not re.fullmatch(r"[A-Za-z0-9._/-]+", claim["base_branch"]) + or claim["base_branch"].startswith("-") + ): + raise SystemExit("agent-review scheduler claim has an invalid base branch") + if not re.fullmatch(r"[A-Za-z0-9-]+", claim["actor"]): + raise SystemExit("agent-review scheduler claim has an invalid actor") + expected_policy = { + "agent": "opencode-agent", + "enable_auto_merge": False, + "merge_mode": "disabled", + "review_dispatch_limit": "1", + "trigger_reviews": True, + "update_branches": False, + } + if any(claim[field] != value for field, value in expected_policy.items()): + raise SystemExit("agent-review scheduler claim violates review-only policy") + canonical = json.dumps( + claim, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + expected_key = hashlib.sha256(canonical).hexdigest() + if not hmac.compare_digest(expected_key, invocation_key): + raise SystemExit("agent-review scheduler invocation key mismatch") + PYTHON + if [ -z "$TARGET_REPOSITORY_INPUT" ]; then { printf 'repository=%s\n' "$GITHUB_REPOSITORY" @@ -280,14 +393,16 @@ jobs: live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_json")" live_head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_json")" live_base_branch="$(jq -r '.base.ref // empty' <<<"$pull_json")" + live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_json")" live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_json")" if [ "$live_number" != "$TARGET_PR_NUMBER" ] || [ "$live_state" != "open" ] || [ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ] || [ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ] || [ -z "$live_base_branch" ] || + ! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || ! [[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then - printf '::error::Targeted scheduler dispatch rejected closed, cross-repository, or malformed live PR metadata. target=%s pr=%s state=%s base_repository=%s head_repository=%s base_branch=%s head_sha=%s\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_branch:-}" "${live_head_sha:-}" + printf '::error::Targeted scheduler dispatch rejected closed, cross-repository, or malformed live PR metadata. target=%s pr=%s state=%s base_repository=%s head_repository=%s base_branch=%s base_sha=%s head_sha=%s\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_branch:-}" "${live_base_sha:-}" "${live_head_sha:-}" exit 1 fi if [ -n "$TARGET_BASE_BRANCH_INPUT" ] && @@ -295,13 +410,27 @@ jobs: printf '::error::Targeted scheduler dispatch base branch does not match the live PR. supplied=%s live=%s\n' "$TARGET_BASE_BRANCH_INPUT" "$live_base_branch" exit 1 fi + if [ "$GITHUB_EVENT_ACTION" = "merge-scheduler-agent-review-v2" ] && + [ "$TARGET_HEAD_SHA_INPUT" != "$live_head_sha" ]; then + printf '::error::Agent-review scheduler dispatch head SHA changed before execution. supplied=%s live=%s\n' "$TARGET_HEAD_SHA_INPUT" "$live_head_sha" + exit 1 + fi + if [ "$GITHUB_EVENT_ACTION" = "merge-scheduler-agent-review-v2" ] && + [ "$TARGET_BASE_SHA_INPUT" != "$live_base_sha" ]; then + printf '::error::Agent-review scheduler dispatch base SHA changed before execution. supplied=%s live=%s\n' "$TARGET_BASE_SHA_INPUT" "$live_base_sha" + exit 1 + fi { printf 'repository=%s\n' "$TARGET_REPOSITORY_INPUT" printf 'base_branch=%s\n' "$live_base_branch" + printf 'base_sha=%s\n' "$live_base_sha" printf 'head_sha=%s\n' "$live_head_sha" + printf 'expected_base_sha=%s\n' "$TARGET_BASE_SHA_INPUT" + printf 'expected_head_sha=%s\n' "$TARGET_HEAD_SHA_INPUT" + printf 'expected_base_branch=%s\n' "$TARGET_EXPECTED_BASE_BRANCH_INPUT" } >>"$GITHUB_OUTPUT" - printf 'Validated exact targeted scheduler dispatch for %s#%s at %s on base %s.\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "$live_head_sha" "$live_base_branch" + printf 'Validated exact targeted scheduler dispatch for %s#%s at %s on base %s@%s.\n' "$TARGET_REPOSITORY_INPUT" "$TARGET_PR_NUMBER" "$live_head_sha" "$live_base_branch" "$live_base_sha" - name: Resolve trusted scheduler source ref id: trusted_source @@ -483,14 +612,17 @@ jobs: GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }} TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }} TARGET_DEFAULT_BRANCH: ${{ steps.targeted_dispatch.outputs.base_branch }} - SCHEDULER_ACTIONS_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }} + EXPECTED_HEAD_SHA: ${{ steps.targeted_dispatch.outputs.expected_head_sha || '' }} + EXPECTED_BASE_SHA: ${{ steps.targeted_dispatch.outputs.expected_base_sha || '' }} + EXPECTED_BASE_BRANCH: ${{ steps.targeted_dispatch.outputs.expected_base_branch || '' }} + SCHEDULER_ACTIONS_TOKEN: ${{ github.event_name == 'repository_dispatch' && (github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.repository != '' || github.event.client_payload.target_repository != '') && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }} # 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.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }} + SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' && (github.event.action == 'merge-scheduler-agent-review-v2' && github.event.client_payload.claim.repository != '' || github.event.client_payload.target_repository != '') && (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 SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} @@ -525,6 +657,13 @@ jobs: if [ -n "$PULL_REQUEST_NUMBER" ]; then args+=(--pr-number "$PULL_REQUEST_NUMBER") fi + if [ -n "$EXPECTED_HEAD_SHA" ] || [ -n "$EXPECTED_BASE_SHA" ] || [ -n "$EXPECTED_BASE_BRANCH" ]; then + args+=( + --expected-head-sha "$EXPECTED_HEAD_SHA" + --expected-base-sha "$EXPECTED_BASE_SHA" + --expected-base-branch "$EXPECTED_BASE_BRANCH" + ) + fi if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run) fi diff --git a/.github/workflows/repair-agent-dispatch-envelope-v2-clean.yml b/.github/workflows/repair-agent-dispatch-envelope-v2-clean.yml new file mode 100644 index 000000000..fb7d770ff --- /dev/null +++ b/.github/workflows/repair-agent-dispatch-envelope-v2-clean.yml @@ -0,0 +1,82 @@ +name: Repair agent dispatch envelope clean successor + +on: + push: + branches: + - fix/agent-mention-dispatch-envelope-v2-clean + +permissions: + contents: write + +concurrency: + group: repair-agent-dispatch-envelope-v2-clean + cancel-in-progress: false + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: fix/agent-mention-dispatch-envelope-v2-clean + fetch-depth: 0 + persist-credentials: true + - name: Apply bounded compatibility repair and self-delete + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin main + git show origin/main:tests/test_required_workflow_queue_contract.py > tests/test_required_workflow_queue_contract.py + python3 - <<'PYTHON' + from pathlib import Path + + workflow_path = Path('.github/workflows/pr-review-merge-scheduler.yml') + workflow = workflow_path.read_text(encoding='utf-8') + replacements = { + 'os.environ["DISPATCH_CLIENT_PAYLOAD_JSON"] or "null"': + 'os.environ.get("DISPATCH_CLIENT_PAYLOAD_JSON") or "null"', + 'os.environ["GITHUB_EVENT_ACTION"] == "merge-scheduler-agent-review-v2"': + 'os.environ.get("GITHUB_EVENT_ACTION", "") == "merge-scheduler-agent-review-v2"', + } + for old, new in replacements.items(): + if workflow.count(old) != 1: + raise SystemExit(f'expected exactly one workflow occurrence: {old}') + workflow = workflow.replace(old, new, 1) + workflow_path.write_text(workflow, encoding='utf-8') + + test_path = Path('tests/test_opencode_agent_contract.py') + test_source = test_path.read_text(encoding='utf-8') + old = ''' assert ( + "SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' " + "&& github.event.client_payload.target_repository != '' && " + "(secrets.PR_REVIEW_MERGE_TOKEN || " + "secrets.OPENCODE_APPROVE_TOKEN || " + "steps.scheduler_app_token.outputs.token) || github.token }}" + in workflow + ) +''' + new = ''' read_token_line = next( + line.strip() + for line in workflow.splitlines() + if line.strip().startswith("SCHEDULER_READ_TOKEN:") + ) + assert "github.event.action == 'merge-scheduler-agent-review-v2'" in read_token_line + assert "github.event.client_payload.claim.repository != ''" in read_token_line + assert "github.event.client_payload.target_repository != ''" in read_token_line + assert "secrets.PR_REVIEW_MERGE_TOKEN" in read_token_line + assert "secrets.OPENCODE_APPROVE_TOKEN" in read_token_line + assert "steps.scheduler_app_token.outputs.token" in read_token_line + assert read_token_line.endswith("|| github.token }}") +''' + if test_source.count(old) != 1: + raise SystemExit('expected exactly one scheduler read-token assertion block') + test_path.write_text(test_source.replace(old, new, 1), encoding='utf-8') + PYTHON + rm .github/workflows/repair-agent-dispatch-envelope-v2-clean.yml + git diff --check + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add .github/workflows/pr-review-merge-scheduler.yml tests/test_opencode_agent_contract.py tests/test_required_workflow_queue_contract.py .github/workflows/repair-agent-dispatch-envelope-v2-clean.yml + git commit -m "fix(automation): preserve legacy scheduler test contracts" + git push origin HEAD:fix/agent-mention-dispatch-envelope-v2-clean diff --git a/docs/doctoring/agent-mention-dispatch-envelope.md b/docs/doctoring/agent-mention-dispatch-envelope.md new file mode 100644 index 000000000..ae01b16b2 --- /dev/null +++ b/docs/doctoring/agent-mention-dispatch-envelope.md @@ -0,0 +1,93 @@ +# Agent-mention repository-dispatch envelope + +## Decision + +Mention-triggered OpenCode review uses the versioned envelope +`cwl.agent-invocation/v2` with exactly three top-level properties: + +```json +{ + "schema": "cwl.agent-invocation/v2", + "claim": { + "repository": "ContextualWisdomLab/example", + "pr_number": 17, + "head_sha": "<40-hex>", + "base_sha": "<40-hex>", + "base_branch": "main", + "actor": "maintainer", + "requested_agent": "opencode-agent", + "source_comment_id": 91, + "merge_mode": "disabled", + "trigger_reviews": true, + "update_branches": false, + "enable_auto_merge": false, + "review_dispatch_limit": 1 + }, + "agent_invocation_key": "" +} +``` + +GitHub repository dispatch limits `client_payload` to ten top-level properties +and 65,535 characters. The earlier flat payload used fourteen properties and +was rejected with HTTP 422 before the review scheduler could run. Nesting the +complete immutable claim preserves the invocation and artifact-ledger identity +while keeping the transport within GitHub's contract. + +## Trust boundary + +The producer, wrapper, and authoritative scheduler independently validate: + +- the exact envelope and claim property sets; +- primitive value types and bounded lengths; +- the canonical SHA-256 invocation key; +- the CWL repository allowlist; +- open pull-request state; +- exact live head SHA, base SHA, and base branch; +- review-only policy with branch updates, auto-merge, and merging disabled; +- serialized ledger acquisition before forwarding; +- a distinct event type for the versioned path so it cannot fall through the + legacy scheduler payload. + +The second hop reuses the exact validated three-property envelope. It does not +reconstruct a new flat payload from environment variables. Versioned stale +claims receive a per-invocation concurrency identity and cannot cancel a newer +review request. The legacy `merge-scheduler` path remains explicit for existing +non-mention callers. + +A reaction placed on the source comment is only user-interface feedback. A +permission failure after the durable repository dispatch does not invalidate or +repeat the queued review; it is emitted as a warning. + +## Test-first evidence + +The clean successor PR records a tests-only RED commit before production +changes. Hosted quality evidence on that exact head reports nineteen focused +failures and 1,116 existing passes, including the fourteen-property payload, +missing producer size check, absent wrapper and scheduler validation, missing +quality-gate scope, and reaction-denial propagation. + +The regression corpus executes extracted workflow validators, malformed and +policy-violating envelopes, valid and legacy scheduler paths, exact invocation +key binding, downstream idempotency, live-ref authority, and full +statement/branch/docstring quality gates. + +## Operational response + +When a mention fails to dispatch: + +1. inspect the router run before retrying the comment; +2. treat any 4xx dispatch response as a transport or contract defect, not a + successful review request; +3. do not change the selected pull-request head to manufacture a new request; +4. repair and merge the central router contract first; +5. request a fresh exact-head review after the protected central revision is + active; +6. never reinterpret predecessor reviews as current-head approval. + +## APA 7 references + +GitHub. (2026). *Create a repository dispatch event*. GitHub Docs. https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event + +GitHub. (2026). *Control the concurrency of workflows and jobs*. GitHub Docs. https://docs.github.com/en/actions/using-jobs/using-concurrency + +National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53, Revision 5). https://doi.org/10.6028/NIST.SP.800-53r5 diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py index bdb8ac3db..97295062d 100644 --- a/scripts/ci/agent_mention_router.py +++ b/scripts/ci/agent_mention_router.py @@ -28,6 +28,7 @@ f"repos/{CENTRAL_AUTOMATION_REPOSITORY}/actions/artifacts" ) LEDGER_ARTIFACT_PREFIX = "cwl-agent-invocation-" +OPENCODE_INVOCATION_SCHEMA = "cwl.agent-invocation/v2" REPOSITORY_RE = re.compile(r"^ContextualWisdomLab/[A-Za-z0-9_.-]+$") HEAD_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") BASE_BRANCH_RE = re.compile(r"^(?!-)[A-Za-z0-9._/-]+$") @@ -394,24 +395,22 @@ def opencode_payload(request: MentionRequest) -> dict[str, Any]: agent = "opencode-agent" claim = agent_invocation_claim(request, agent) + client_payload = { + "schema": OPENCODE_INVOCATION_SCHEMA, + "claim": claim, + "agent_invocation_key": agent_invocation_key(request, agent), + } + encoded = json.dumps( + client_payload, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + if len(client_payload) > 10 or len(encoded) > 65_535: + raise ValueError("OpenCode repository dispatch exceeds GitHub limits") 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, - }, + "client_payload": client_payload, } @@ -478,14 +477,20 @@ def dispatch_request( ledger_artifact_cache[agent_ledger_artifact_name(request, agent)] = True target_api = f"repos/{request.repository}" - target_client.request( - [ - f"{target_api}/issues/comments/{request.comment_id}/reactions", - "-X", - "POST", - ], - input_payload={"content": "eyes"}, - ) + try: + target_client.request( + [ + f"{target_api}/issues/comments/{request.comment_id}/reactions", + "-X", + "POST", + ], + input_payload={"content": "eyes"}, + ) + except RuntimeError as exc: + print( + "::warning::Agent dispatch is durably queued, but the optional " + f"eyes reaction could not be recorded: {exc}" + ) status_parts = [f"Queued {' and '.join(handles)}"] existing_handles = tuple( f"@{agent}" for agent in dispatchable if agent in existing diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 75e18c860..6b8331d2c 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -22,6 +22,7 @@ PULL_REQUEST_FIELDS_FRAGMENT = """\ fragment SchedulerPullRequestFields on PullRequest { number + state title isDraft mergeable @@ -722,6 +723,7 @@ def rest_pr_node(repo: str, pr: dict[str, Any]) -> dict[str, Any]: ) return { "number": number, + "state": str(pr.get("state") or "").upper(), "title": pr.get("title"), "isDraft": bool(pr.get("draft")), "mergeable": pr.get("mergeable"), @@ -2069,7 +2071,14 @@ def cancel_stale_opencode_runs(repo: str, workflow: str, pr: dict[str, Any], *, return [run_id for _, run_id in stale_refs] -def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> str: +def dispatch_opencode_review( + repo: str, + workflow: str, + pr: dict[str, Any], + *, + dry_run: bool, + snapshot_guarded: bool = False, +) -> str: """Dispatch trusted OpenCode for the PR head, or report an active run. The review job is intentionally restricted to ``repository_dispatch``. A @@ -2081,7 +2090,14 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr if not dry_run: require_github_actions_control_actor("inspect-active-opencode-review") current_run_refs, stale_run_refs = active_opencode_run_refs(repo, workflow, pr) - force_cancel_workflow_run_refs(stale_run_refs) + if snapshot_guarded and stale_run_refs: + print( + "OpenCode review dispatch skipped: a different-head workflow run became active " + "after snapshot validation" + ) + return "snapshot_changed" + if not snapshot_guarded: + force_cancel_workflow_run_refs(stale_run_refs) if current_run_refs: print( "OpenCode review dispatch skipped: active same-head workflow run(s) " @@ -2123,12 +2139,20 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr return "dispatched" -def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry_run: bool) -> str: +def dispatch_strix_evidence( + repo: str, + workflow: str, + pr: dict[str, Any], + *, + dry_run: bool, + snapshot_guarded: bool = False, +) -> str: """Dispatch same-head Strix workflow evidence before OpenCode reviews.""" - job_id = matching_actions_job_id(pr, is_strix_context) - if job_id: - rerun_actions_job(repo, job_id, dry_run=dry_run, action="rerun-strix-evidence") - return "rerun" if not dry_run else "dry_run" + if not snapshot_guarded: + job_id = matching_actions_job_id(pr, is_strix_context) + if job_id: + rerun_actions_job(repo, job_id, dry_run=dry_run, action="rerun-strix-evidence") + return "rerun" if not dry_run else "dry_run" if dry_run: return "dry_run" require_github_actions_control_actor("inspect-active-strix-evidence") @@ -2139,7 +2163,14 @@ def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry run_title="Strix Security Scan", workflow_aliases=frozenset({"Strix Security Scan"}), ) - force_cancel_workflow_run_refs(stale_run_refs) + if snapshot_guarded and stale_run_refs: + print( + "Strix evidence dispatch skipped: a different-head workflow run became active " + "after snapshot validation" + ) + return "snapshot_changed" + if not snapshot_guarded: + force_cancel_workflow_run_refs(stale_run_refs) if current_run_refs: print( "Strix evidence dispatch skipped: active same-head workflow run(s) " @@ -2247,6 +2278,219 @@ def current_head_can_attempt_merge(pr: dict[str, Any], merge_state: str) -> bool return False +def inspect_snapshot_bound_review( + repo: str, + pr: dict[str, Any], + *, + dry_run: bool, + trigger_reviews: bool, + review_dispatch_allowed: bool, + workflow: str, + security_workflow: str, + base_branch: str, + stale_opencode_minutes: int, +) -> Decision: + """Dispatch exact-snapshot review work without general scheduler mutations.""" + + number = pr["number"] + base_ref = pr.get("baseRefName") + if str(pr.get("state") or "").upper() != "OPEN": + return Decision(number, "wait", "snapshot-bound target PR is no longer open") + if pr.get("isDraft"): + return Decision(number, "skip", "draft PR") + if base_ref != base_branch: + return Decision( + number, + "wait", + f"snapshot-bound target base branch changed from {base_branch} to {base_ref}", + ) + unresolved = unresolved_thread_count(pr) + if unresolved: + return Decision(number, "block", f"{unresolved} unresolved review thread(s)") + if has_current_head_changes_requested(pr): + return Decision(number, "block", "current-head OpenCode review requested changes") + + merge_state = effective_merge_state(pr) + current_head_approved = has_current_head_approval(pr) + if merge_state in {"DIRTY", "CONFLICTING"}: + return Decision(number, "block", merge_conflict_guidance(pr, merge_state)) + if current_head_approved: + failed_checks = failed_status_checks(pr) + if failed_checks: + return Decision(number, "block", f"failed check(s): {', '.join(failed_checks[:5])}") + workflow_action_required = action_required_checks(pr) + if workflow_action_required: + return Decision( + number, + "wait", + workflow_action_required_reason(workflow_action_required), + ) + + behind_by = branch_outdated_by_base(pr, merge_state) + if behind_by and trigger_reviews: + approval_state = ( + "current head is approved" + if current_head_approved + else "current head has no OpenCode approval" + ) + return Decision( + number, + "wait", + f"{approval_state}; snapshot-bound review cannot update an outdated branch", + ) + if merge_state == "UNKNOWN": + return Decision( + number, + "wait", + "mergeability is still being calculated and no branch freshness evidence is available", + ) + if current_head_approved: + return Decision( + number, + "wait", + "current head is approved; snapshot-bound invocation is review-only", + ) + + opencode_state = opencode_progress_state( + pr, + stale_after_minutes=stale_opencode_minutes, + ) + if opencode_state == "running": + return Decision(number, "wait", "OpenCode review is already in progress") + if ( + os.environ.get("GITHUB_EVENT_NAME") == "workflow_run" + and has_current_head_deterministic_fallback_approval(pr) + ): + return Decision( + number, + "wait", + "current-head deterministic fallback is not merge evidence; defer real-model retry to the next scheduler heartbeat", + ) + if opencode_state == "stale" and not trigger_reviews: + return Decision( + number, + "wait", + f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; review dispatch disabled", + ) + if opencode_state == "stale": + if not review_dispatch_allowed: + return Decision( + number, + "wait", + f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; review dispatch limit reached", + ) + dispatch_result = dispatch_opencode_review( + repo, + workflow, + pr, + dry_run=dry_run, + snapshot_guarded=True, + ) + if dispatch_result == "snapshot_changed": + return Decision( + number, + "wait", + "OpenCode review retry skipped because a different-head review run is active", + ) + if dispatch_result == "already_running": + return Decision( + number, + "wait", + "OpenCode review exceeded the status-check retry threshold, but a same-head workflow run is already active", + ) + return Decision( + number, + "review_dispatch", + f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; same-head OpenCode re-dispatched", + ) + + if trigger_reviews: + strix_state = strix_evidence_state(pr) + if strix_state == "missing": + if not review_dispatch_allowed: + return Decision( + number, + "wait", + "current head has no completed Strix evidence; review dispatch limit reached", + ) + wait_reason = repository_dispatch_wait_reason(repo, security_workflow) + if wait_reason: + return Decision( + number, + "wait", + f"current head has no completed Strix evidence; {wait_reason}", + ) + dispatch_result = dispatch_strix_evidence( + repo, + security_workflow, + pr, + dry_run=dry_run, + snapshot_guarded=True, + ) + if dispatch_result == "snapshot_changed": + return Decision( + number, + "wait", + "Strix dispatch skipped because a different-head Strix run is active", + ) + if dispatch_result == "already_running": + return Decision( + number, + "wait", + "same-head Strix evidence workflow run is already active", + ) + return Decision( + number, + "security_dispatch", + "current head has no completed Strix evidence; same-head Strix dispatched", + ) + if strix_state == "running": + return Decision(number, "wait", "same-head Strix evidence is still running") + if not review_dispatch_allowed: + return Decision( + number, + "wait", + "current head has completed Strix evidence; review dispatch limit reached", + ) + wait_reason = repository_dispatch_wait_reason(repo, workflow) + if wait_reason: + return Decision( + number, + "wait", + f"current head has completed Strix evidence; {wait_reason}", + ) + dispatch_result = dispatch_opencode_review( + repo, + workflow, + pr, + dry_run=dry_run, + snapshot_guarded=True, + ) + if dispatch_result == "snapshot_changed": + return Decision( + number, + "wait", + "OpenCode dispatch skipped because a different-head review run is active", + ) + if dispatch_result == "already_running": + return Decision( + number, + "wait", + "current head has completed Strix evidence; same-head OpenCode workflow run is already active", + ) + return Decision( + number, + "review_dispatch", + "current head has completed Strix evidence; same-head OpenCode dispatched", + ) + + return Decision( + number, + "block", + "current head has no OpenCode approval; review dispatch disabled", + ) + + def inspect_pr( repo: str, pr: dict[str, Any], @@ -2263,8 +2507,21 @@ def inspect_pr( base_branch: str, merge_mode: str = "direct_or_auto", stale_opencode_minutes: int = DEFAULT_STALE_OPENCODE_MINUTES, + snapshot_guarded: bool = False, ) -> Decision: """Decide and optionally act on one pull request's merge-readiness state.""" + if snapshot_guarded: + return inspect_snapshot_bound_review( + repo, + pr, + dry_run=dry_run, + trigger_reviews=trigger_reviews, + review_dispatch_allowed=review_dispatch_allowed, + workflow=workflow, + security_workflow=security_workflow, + base_branch=base_branch, + stale_opencode_minutes=stale_opencode_minutes, + ) number = pr["number"] base_ref = pr.get("baseRefName") @@ -3691,6 +3948,68 @@ def self_test() -> None: print("self-test passed") +def validate_expected_pr_snapshot( + prs: Sequence[dict[str, Any]], + *, + pr_number: int, + expected_head_sha: str, + expected_base_sha: str, + expected_base_branch: str, +) -> None: + """Fail closed when a targeted PR no longer matches its validated refs.""" + + expected_values = ( + expected_head_sha, + expected_base_sha, + expected_base_branch, + ) + if not any(expected_values): + return + if not all(expected_values): + raise SystemExit( + "--expected-head-sha, --expected-base-sha, and --expected-base-branch " + "must be supplied together" + ) + if pr_number < 1: + raise SystemExit("expected PR snapshot guards require --pr-number") + try: + expected_head = validate_git_sha(expected_head_sha).lower() + expected_base = validate_git_sha(expected_base_sha).lower() + expected_branch = validate_git_ref(expected_base_branch) + except ValueError as exc: + raise SystemExit(str(exc)) from exc + if len(prs) != 1 or int(prs[0].get("number") or 0) != pr_number: + raise SystemExit( + f"target PR #{pr_number} snapshot is unavailable or ambiguous" + ) + try: + observed_head = validate_git_sha(str(prs[0].get("headRefOid") or "")).lower() + observed_base = validate_git_sha(str(prs[0].get("baseRefOid") or "")).lower() + observed_branch = validate_git_ref(str(prs[0].get("baseRefName") or "")) + except ValueError as exc: + raise SystemExit(f"target PR #{pr_number} returned malformed refs: {exc}") from exc + observed_state = str(prs[0].get("state") or "").upper() + if observed_state != "OPEN": + raise SystemExit( + f"target PR #{pr_number} is no longer open: observed {observed_state or ''}" + ) + if observed_head != expected_head: + raise SystemExit( + f"target PR #{pr_number} head SHA changed: " + f"expected {expected_head}, observed {observed_head}" + ) + if observed_base != expected_base: + raise SystemExit( + f"target PR #{pr_number} base SHA changed: " + f"expected {expected_base}, observed {observed_base}" + ) + if observed_branch != expected_branch: + raise SystemExit( + f"target PR #{pr_number} base branch changed: " + f"expected {expected_branch}, observed {observed_branch}" + ) + + def parse_args(argv: list[str]) -> argparse.Namespace: """Parse scheduler CLI arguments.""" parser = argparse.ArgumentParser() @@ -3699,6 +4018,18 @@ def parse_args(argv: list[str]) -> argparse.Namespace: parser.add_argument("--project-flow", default=os.environ.get("PROJECT_FLOW", "")) parser.add_argument("--max-prs", type=int, default=100) parser.add_argument("--pr-number", type=int, default=0) + parser.add_argument( + "--expected-head-sha", + default=os.environ.get("EXPECTED_HEAD_SHA", ""), + ) + parser.add_argument( + "--expected-base-sha", + default=os.environ.get("EXPECTED_BASE_SHA", ""), + ) + parser.add_argument( + "--expected-base-branch", + default=os.environ.get("EXPECTED_BASE_BRANCH", ""), + ) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--trigger-reviews", action=argparse.BooleanOptionalAction, default=True) parser.add_argument( @@ -3750,6 +4081,27 @@ def main(argv: list[str]) -> int: if args.branch_update_limit < -1: raise SystemExit("--branch-update-limit must be -1 or greater") prs = fetch_pr(args.repo, args.pr_number) if args.pr_number else fetch_open_prs(args.repo, args.max_prs) + validate_expected_pr_snapshot( + prs, + pr_number=args.pr_number, + expected_head_sha=args.expected_head_sha, + expected_base_sha=args.expected_base_sha, + expected_base_branch=args.expected_base_branch, + ) + snapshot_guarded = bool( + args.expected_head_sha + or args.expected_base_sha + or args.expected_base_branch + ) + if snapshot_guarded: + prs = fetch_pr(args.repo, args.pr_number) + validate_expected_pr_snapshot( + prs, + pr_number=args.pr_number, + expected_head_sha=args.expected_head_sha, + expected_base_sha=args.expected_base_sha, + expected_base_branch=args.expected_base_branch, + ) decisions = [] review_dispatches_used = 0 branch_updates_used = 0 @@ -3774,6 +4126,7 @@ def main(argv: list[str]) -> int: security_workflow=args.security_workflow, base_branch=args.base_branch, stale_opencode_minutes=args.stale_opencode_minutes, + snapshot_guarded=snapshot_guarded, ) except RuntimeError as exc: decision = Decision( diff --git a/tests/test_agent_mention_complete_payload_binding.py b/tests/test_agent_mention_complete_payload_binding.py index 04562e93f..bfb0ff4f3 100644 --- a/tests/test_agent_mention_complete_payload_binding.py +++ b/tests/test_agent_mention_complete_payload_binding.py @@ -74,12 +74,13 @@ def test_event_and_payloads_bind_exact_base_identity() -> None: assert request.pull_request_base_branch == "main" assert request.pull_request_base_sha == "b" * 40 - for payload in ( - router.noema_payload(request)["client_payload"], - router.opencode_payload(request)["client_payload"], - ): - assert payload["base_branch"] == "main" - assert payload["pr_base_sha"] == "b" * 40 + noema_payload = router.noema_payload(request)["client_payload"] + assert noema_payload["base_branch"] == "main" + assert noema_payload["pr_base_sha"] == "b" * 40 + opencode_envelope = router.opencode_payload(request)["client_payload"] + assert opencode_envelope["schema"] == "cwl.agent-invocation/v2" + assert opencode_envelope["claim"]["base_branch"] == "main" + assert opencode_envelope["claim"]["base_sha"] == "b" * 40 malformed = _event() malformed["pull_request"]["base"]["sha"] = "not-a-sha" @@ -150,36 +151,37 @@ def test_wrappers_recompute_complete_claim_before_ledger_access() -> None: noema = NOEMA_WORKFLOW.read_text(encoding="utf-8") opencode = OPENCODE_WORKFLOW.read_text(encoding="utf-8") + assert "PR_BASE_SHA:" in noema + assert "github.event.client_payload.pr_base_sha" in noema + assert '! [[ "$PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]' in noema + assert '"base_sha": os.environ["PR_BASE_SHA"]' in noema + assert "--arg pr_base_sha \"$PR_BASE_SHA\"" in noema + assert "pr_base_sha: $pr_base_sha" in noema + assert noema.count('"base_sha": os.environ["PR_BASE_SHA"]') >= 2 + + assert "CLIENT_PAYLOAD_JSON:" in opencode + assert "github.event.client_payload.claim" in opencode + assert '"base_sha"' in opencode + assert 'r"[0-9a-f]{40}", claim["base_sha"]' in opencode + assert "set(envelope)" in opencode + assert "set(claim)" in opencode + assert '"client_payload": envelope' in opencode + assert '--input "$SCHEDULER_REQUEST_FILE"' in opencode for workflow in (noema, opencode): - assert "PR_BASE_SHA:" in workflow - assert "github.event.client_payload.pr_base_sha" in workflow - assert '! [[ "$PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]' in workflow - assert '"base_sha": os.environ["PR_BASE_SHA"]' in workflow assert "hmac.compare_digest" in workflow assert workflow.index("Validate exact invocation payload") < workflow.index( "Inspect exact-name Actions artifact ledger" ) - assert "--arg pr_base_sha \"$PR_BASE_SHA\"" in workflow - assert "pr_base_sha: $pr_base_sha" in workflow - - for field in ( - '"trigger_reviews": os.environ["TRIGGER_REVIEWS"] == "true"', - '"review_dispatch_limit": os.environ["REVIEW_DISPATCH_LIMIT"]', - '"enable_auto_merge": os.environ["ENABLE_AUTO_MERGE"] == "true"', - '"update_branches": os.environ["UPDATE_BRANCHES"] == "true"', - '"merge_mode": os.environ["MERGE_MODE"]', - ): - assert field in opencode - assert noema.count('"base_sha": os.environ["PR_BASE_SHA"]') >= 2 - for field in ( - '"trigger_reviews": os.environ["TRIGGER_REVIEWS"] == "true"', - '"review_dispatch_limit": os.environ["REVIEW_DISPATCH_LIMIT"]', - '"enable_auto_merge": os.environ["ENABLE_AUTO_MERGE"] == "true"', - '"update_branches": os.environ["UPDATE_BRANCHES"] == "true"', - '"merge_mode": os.environ["MERGE_MODE"]', + for field, value in ( + ('"agent"', '"opencode-agent"'), + ('"trigger_reviews"', "True"), + ('"review_dispatch_limit"', '"1"'), + ('"enable_auto_merge"', "False"), + ('"update_branches"', "False"), + ('"merge_mode"', '"disabled"'), ): - assert opencode.count(field) >= 2 + assert f"{field}: {value}" in opencode def test_no_pr_specific_writer_workflow_remains() -> None: diff --git a/tests/test_agent_mention_downstream_idempotency.py b/tests/test_agent_mention_downstream_idempotency.py index 4fc40a782..a3940924b 100644 --- a/tests/test_agent_mention_downstream_idempotency.py +++ b/tests/test_agent_mention_downstream_idempotency.py @@ -30,12 +30,9 @@ def test_downstream_workflows_claim_artifacts_and_bind_exact_key() -> None: for text in (noema, opencode): assert "github.event.client_payload.agent_invocation_key" in text assert "cwl-agent-invocation:" in text - assert "source_comment_id" in text - assert "requested_agent" in text assert "cancel-in-progress: false" in text assert "queue: max" in text assert "^[0-9a-f]{64}$" in text - assert "^[1-9][0-9]*$" in text assert "actions/artifacts" in text assert "name=${LEDGER_ARTIFACT_NAME}" in text assert f"actions/upload-artifact@{UPLOAD_ARTIFACT_SHA}" in text @@ -49,11 +46,17 @@ def test_downstream_workflows_claim_artifacts_and_bind_exact_key() -> None: assert "types: [agent-mention-noema]" in noema assert 'event_type: "noema-review"' in noema assert 'REQUESTED_AGENT: "cwl-noema-review"' in noema + assert "^[1-9][0-9]*$" in noema + assert "source_comment_id" in noema + assert "requested_agent" in noema assert "types: [agent-mention-opencode]" in opencode - assert 'event_type: "merge-scheduler"' in opencode - assert 'REQUESTED_AGENT: "opencode-agent"' in opencode - assert '[[ "$BASE_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]]' in opencode - assert '[[ "$BASE_BRANCH" == -* ]]' in opencode + assert '"event_type": "merge-scheduler-agent-review-v2"' in opencode + assert '"agent": "opencode-agent"' in opencode + assert '"comment_id"' in opencode + assert 'type(claim["comment_id"]) is not int' in opencode + assert 'type(claim["pr_number"]) is not int' in opencode + assert 're.fullmatch(r"[A-Za-z0-9._/-]+", claim["base_branch"])' in opencode + assert 'claim["base_branch"].startswith("-")' in opencode def test_wrappers_recompute_the_router_canonical_payload_digest() -> None: @@ -74,11 +77,11 @@ def test_wrappers_recompute_the_router_canonical_payload_digest() -> None: '"pr_number"', '"repository"', ) - for text in ( - NOEMA_WORKFLOW.read_text(encoding="utf-8"), - OPENCODE_WORKFLOW.read_text(encoding="utf-8"), - ): - assert "BASE_BRANCH:" in text + noema = NOEMA_WORKFLOW.read_text(encoding="utf-8") + opencode = OPENCODE_WORKFLOW.read_text(encoding="utf-8") + assert "BASE_BRANCH:" in noema + assert '"BASE_BRANCH": claim["base_branch"]' in opencode + for text in (noema, opencode): assert "import hashlib" in text assert "import hmac" in text assert "json.dumps(" in text @@ -104,3 +107,4 @@ def test_quality_gate_runs_full_suite_for_docs_and_exact_diff() -> None: coverage_config = text.split("[run]\n", 1)[1].split("[report]\n", 1)[0] assert "scripts/ci/agent_mention_router.py" in coverage_config assert "scripts/ci/agent_mention_sweep.py" in coverage_config + assert "scripts/ci/pr_review_merge_scheduler.py" in coverage_config diff --git a/tests/test_agent_mention_exact_contract.py b/tests/test_agent_mention_exact_contract.py new file mode 100644 index 000000000..79d96ab63 --- /dev/null +++ b/tests/test_agent_mention_exact_contract.py @@ -0,0 +1,341 @@ +"""Pin complete v2 dispatch, reaction, and receipt contracts.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" + + +def load_module() -> ModuleType: + """Load the agent-mention router under an isolated test module name.""" + + module_name = "agent_mention_router_exact_contract" + spec = importlib.util.spec_from_file_location(module_name, MODULE_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 mention_request(module: ModuleType, *, agents: tuple[str, ...]): + """Build one exact request for dispatch-contract verification.""" + + return module.MentionRequest( + repository="ContextualWisdomLab/.github", + pull_request_number=840, + pull_request_head_sha="a" * 40, + pull_request_base_branch="main", + comment_id=123, + actor="maintainer", + agents=agents, + pull_request_base_sha="b" * 40, + ) + + +def expected_opencode_claim(request) -> dict[str, object]: + """Return the complete canonical v2 OpenCode claim expected by GitHub.""" + + return { + "actor": request.actor, + "agent": "opencode-agent", + "base_branch": request.pull_request_base_branch, + "base_sha": request.pull_request_base_sha, + "comment_id": request.comment_id, + "head_sha": request.pull_request_head_sha, + "pr_number": request.pull_request_number, + "repository": request.repository, + "enable_auto_merge": False, + "merge_mode": "disabled", + "review_dispatch_limit": "1", + "trigger_reviews": True, + "update_branches": False, + } + + +def expected_opencode_payload(request) -> dict[str, object]: + """Return the full three-property v2 repository-dispatch envelope.""" + + claim = expected_opencode_claim(request) + invocation_key = hashlib.sha256( + json.dumps( + claim, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + return { + "event_type": "agent-mention-opencode", + "client_payload": { + "schema": "cwl.agent-invocation/v2", + "claim": claim, + "agent_invocation_key": invocation_key, + }, + } + + +def expected_noema_payload(request) -> dict[str, object]: + """Return the complete legacy Noema dispatch contract.""" + + 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, + "pr_base_sha": request.pull_request_base_sha, + "base_branch": request.pull_request_base_branch, + "requested_agent": "cwl-noema-review", + "agent_invocation_key": request_module_key( + request, + "cwl-noema-review", + ), + "requested_by": request.actor, + "source_comment_id": request.comment_id, + }, + } + + +def request_module_key(request, agent: str) -> str: + """Calculate the router's canonical invocation key independently.""" + + claim: dict[str, object] = { + "actor": request.actor, + "agent": agent, + "base_branch": request.pull_request_base_branch, + "base_sha": request.pull_request_base_sha, + "comment_id": request.comment_id, + "head_sha": request.pull_request_head_sha, + "pr_number": request.pull_request_number, + "repository": request.repository, + } + if agent == "opencode-agent": + claim.update( + { + "enable_auto_merge": False, + "merge_mode": "disabled", + "review_dispatch_limit": "1", + "trigger_reviews": True, + "update_branches": False, + } + ) + return hashlib.sha256( + json.dumps( + claim, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + + +class RecordingCentralClient: + """Record exact artifact and repository-dispatch requests.""" + + def __init__(self) -> None: + """Initialize an empty call ledger.""" + + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Return an empty artifact inventory and record every request.""" + + self.calls.append((list(args), input_payload)) + if args[0].endswith("/actions/artifacts"): + return {"total_count": 0, "artifacts": []} + return None + + +class ReactionDeniedClient: + """Record target mutations while denying only the cosmetic reaction.""" + + def __init__(self) -> None: + """Initialize an empty target request ledger.""" + + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Reject the reaction and accept the durable acknowledgement.""" + + self.calls.append((list(args), input_payload)) + if args[0].endswith("/reactions"): + raise RuntimeError( + "gh api failed: Resource not accessible by integration" + ) + return None + + +def artifact_lookup(module: ModuleType, request, agent: str): + """Return the exact empty-ledger lookup call for one agent.""" + + artifact_name = module.agent_ledger_artifact_name(request, agent) + return ( + [ + "repos/ContextualWisdomLab/.github/actions/artifacts", + "-X", + "GET", + "-f", + f"name={artifact_name}", + "-f", + "per_page=100", + ], + None, + ) + + +def expected_acknowledgement(*, handles: str) -> str: + """Return the exact user-facing receipt for one exact request.""" + + return ( + "\n" + f"Queued {handles} for PR #840 at head `{'a' * 40}`. " + "Central exact-name Actions artifacts are the durable dispatch ledger; " + "existing review workflows remain authoritative for the final verdict " + "and failure evidence." + ) + + +def test_opencode_payload_pins_every_v2_field_and_value() -> None: + """The v2 client payload contains exactly three fully bound properties.""" + + module = load_module() + request = mention_request(module, agents=("opencode-agent",)) + expected = expected_opencode_payload(request) + + assert module.opencode_payload(request) == expected + client_payload = expected["client_payload"] + assert isinstance(client_payload, dict) + assert set(client_payload) == { + "schema", + "claim", + "agent_invocation_key", + } + claim = client_payload["claim"] + assert isinstance(claim, dict) + assert claim["base_sha"] == request.pull_request_base_sha + + +def test_reaction_denial_pins_single_agent_requests_and_prose(capsys) -> None: + """A denied reaction leaves the full Noema dispatch and receipt intact.""" + + module = load_module() + request = mention_request(module, agents=("cwl-noema-review",)) + central = RecordingCentralClient() + target = ReactionDeniedClient() + + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + ) == ("@cwl-noema-review",) + + assert central.calls == [ + artifact_lookup(module, request, "cwl-noema-review"), + ( + [ + "repos/ContextualWisdomLab/.github/dispatches", + "-X", + "POST", + ], + expected_noema_payload(request), + ), + ] + acknowledgement = expected_acknowledgement(handles="@cwl-noema-review") + assert target.calls == [ + ( + [ + "repos/ContextualWisdomLab/.github/issues/comments/123/reactions", + "-X", + "POST", + ], + {"content": "eyes"}, + ), + ( + [ + "repos/ContextualWisdomLab/.github/issues/840/comments", + "-X", + "POST", + ], + {"body": acknowledgement}, + ), + ] + assert capsys.readouterr().out == ( + "::warning::Agent dispatch is durably queued, but the optional eyes " + "reaction could not be recorded: gh api failed: Resource not accessible " + "by integration\n" + ) + + +def test_reaction_denial_pins_two_agent_requests_and_prose(capsys) -> None: + """A denied reaction preserves both dispatches and the combined receipt.""" + + module = load_module() + request = mention_request( + module, + agents=("cwl-noema-review", "opencode-agent"), + ) + central = RecordingCentralClient() + target = ReactionDeniedClient() + + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset({request.repository}), + ) == ("@cwl-noema-review", "@opencode-agent") + + assert central.calls == [ + artifact_lookup(module, request, "cwl-noema-review"), + artifact_lookup(module, request, "opencode-agent"), + ( + [ + "repos/ContextualWisdomLab/.github/dispatches", + "-X", + "POST", + ], + expected_noema_payload(request), + ), + ( + [ + "repos/ContextualWisdomLab/.github/dispatches", + "-X", + "POST", + ], + expected_opencode_payload(request), + ), + ] + acknowledgement = expected_acknowledgement( + handles="@cwl-noema-review and @opencode-agent" + ) + assert target.calls == [ + ( + [ + "repos/ContextualWisdomLab/.github/issues/comments/123/reactions", + "-X", + "POST", + ], + {"content": "eyes"}, + ), + ( + [ + "repos/ContextualWisdomLab/.github/issues/840/comments", + "-X", + "POST", + ], + {"body": acknowledgement}, + ), + ] + assert capsys.readouterr().out == ( + "::warning::Agent dispatch is durably queued, but the optional eyes " + "reaction could not be recorded: gh api failed: Resource not accessible " + "by integration\n" + ) diff --git a/tests/test_agent_mention_idempotency.py b/tests/test_agent_mention_idempotency.py index 499730a22..b95f9a80e 100644 --- a/tests/test_agent_mention_idempotency.py +++ b/tests/test_agent_mention_idempotency.py @@ -37,6 +37,7 @@ def request(module: ModuleType): 12345, "maintainer", ("cwl-noema-review", "opencode-agent"), + "b" * 40, ) @@ -194,15 +195,20 @@ def test_payloads_carry_exact_agent_invocation_identity() -> None: assert noema["agent_invocation_key"] == module.agent_invocation_key( mention_request, "cwl-noema-review" ) - assert opencode["requested_agent"] == "opencode-agent" + assert opencode["schema"] == "cwl.agent-invocation/v2" + assert opencode["claim"]["agent"] == "opencode-agent" assert opencode["agent_invocation_key"] == module.agent_invocation_key( mention_request, "opencode-agent" ) - for payload in (noema, opencode): - assert payload["target_repository"] == mention_request.repository - assert payload["pr_number"] == mention_request.pull_request_number - assert payload["pr_head_sha"] == mention_request.pull_request_head_sha - assert payload["source_comment_id"] == mention_request.comment_id + assert noema["target_repository"] == mention_request.repository + assert noema["pr_number"] == mention_request.pull_request_number + assert noema["pr_head_sha"] == mention_request.pull_request_head_sha + assert noema["source_comment_id"] == mention_request.comment_id + assert opencode["claim"]["repository"] == mention_request.repository + assert opencode["claim"]["pr_number"] == mention_request.pull_request_number + assert opencode["claim"]["head_sha"] == mention_request.pull_request_head_sha + assert opencode["claim"]["base_sha"] == mention_request.pull_request_base_sha + assert opencode["claim"]["comment_id"] == mention_request.comment_id def test_existing_artifacts_are_per_agent_durable_evidence() -> None: @@ -313,24 +319,26 @@ def test_partial_failure_retries_only_the_missing_agent() -> None: assert dispatch_events(retry) == ["agent-mention-opencode"] -def test_reaction_or_ack_failure_cannot_redispatch_completed_agents() -> None: - """Target-repository UX failure is separate from durable dispatch evidence.""" +def test_reaction_failure_cannot_redispatch_completed_agents() -> None: + """A cosmetic reaction failure preserves durable dispatch idempotency.""" module = load_module() mention_request = request(module) central = ArtifactAwareClient() failing_target = ArtifactAwareClient(fail_target_call=1) - with pytest.raises(RuntimeError, match="target call"): - module.dispatch_request( - mention_request, - target_client=failing_target, - dispatch_client=central, - opencode_allowlist=frozenset({mention_request.repository}), - ) + assert module.dispatch_request( + mention_request, + target_client=failing_target, + dispatch_client=central, + opencode_allowlist=frozenset({mention_request.repository}), + ) == ("@cwl-noema-review", "@opencode-agent") assert dispatch_events(central) == [ "agent-mention-noema", "agent-mention-opencode", ] + assert len(failing_target.calls) == 2 + assert failing_target.calls[0][0][0].endswith("/reactions") + assert failing_target.calls[1][0][0].endswith("/issues/65/comments") retry = ArtifactAwareClient( artifacts=artifact_inventory( diff --git a/tests/test_agent_mention_reaction_resilience.py b/tests/test_agent_mention_reaction_resilience.py new file mode 100644 index 000000000..d80649337 --- /dev/null +++ b/tests/test_agent_mention_reaction_resilience.py @@ -0,0 +1,95 @@ +"""Regression tests for non-authoritative agent-mention reactions.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" + + +def load_module() -> ModuleType: + """Load the agent-mention router under an isolated test module name.""" + + module_name = "agent_mention_router_reaction_resilience" + spec = importlib.util.spec_from_file_location(module_name, MODULE_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 + + +class CentralClient: + """Capture durable dispatch and artifact-ledger requests.""" + + def __init__(self) -> None: + """Initialize an empty request ledger.""" + + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Return an empty artifact inventory and record all requests.""" + + self.calls.append((list(args), input_payload)) + if args[0].endswith("/actions/artifacts"): + return {"total_count": 0, "artifacts": []} + return None + + +class ReactionDeniedClient: + """Model a token that may comment but cannot add issue-comment reactions.""" + + def __init__(self) -> None: + """Initialize an empty target-repository request ledger.""" + + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Reject only the cosmetic reaction while allowing the durable receipt.""" + + self.calls.append((list(args), input_payload)) + if args[0].endswith("/reactions"): + raise RuntimeError("gh api failed: Resource not accessible by integration") + return None + + +def test_reaction_403_does_not_discard_durable_dispatch(capsys) -> None: + """A cosmetic reaction denial must not fail an already queued review.""" + + module = load_module() + request = module.MentionRequest( + repository="ContextualWisdomLab/.github", + pull_request_number=840, + pull_request_head_sha="a" * 40, + pull_request_base_branch="main", + comment_id=123, + actor="maintainer", + agents=("cwl-noema-review",), + pull_request_base_sha="b" * 40, + ) + target = ReactionDeniedClient() + central = CentralClient() + + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + ) == ("@cwl-noema-review",) + + dispatches = [ + payload + for args, payload in central.calls + if args[0].endswith("/dispatches") and payload is not None + ] + assert [payload["event_type"] for payload in dispatches] == [ + "agent-mention-noema" + ] + assert target.calls[0][0][0].endswith("/reactions") + assert target.calls[1][0][0].endswith("/issues/840/comments") + assert "cwl-agent-mention-receipt:123" in target.calls[1][1]["body"] + assert "optional eyes reaction could not be recorded" in capsys.readouterr().out diff --git a/tests/test_agent_mention_repository_dispatch_envelope.py b/tests/test_agent_mention_repository_dispatch_envelope.py new file mode 100644 index 000000000..786b4e56b --- /dev/null +++ b/tests/test_agent_mention_repository_dispatch_envelope.py @@ -0,0 +1,396 @@ +"""Contracts for the bounded OpenCode repository-dispatch envelope.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import subprocess +import sys +import textwrap +from copy import deepcopy +from dataclasses import replace +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" +WRAPPER_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-opencode-dispatch.yml" +SCHEDULER_WORKFLOW = ROOT / ".github" / "workflows" / "pr-review-merge-scheduler.yml" +QUALITY_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router-quality-ci.yml" +SCHEMA = "cwl.agent-invocation/v2" +ENVELOPE_KEYS = {"schema", "claim", "agent_invocation_key"} + + +def _load_router() -> ModuleType: + """Load the mention router from the repository under test.""" + + module_name = "agent_mention_repository_dispatch_envelope" + 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(router: ModuleType): + """Return one complete OpenCode mention request.""" + + return router.MentionRequest( + repository="ContextualWisdomLab/example", + pull_request_number=17, + pull_request_head_sha="a" * 40, + pull_request_base_branch="main", + comment_id=91, + actor="maintainer", + agents=("opencode-agent",), + pull_request_base_sha="b" * 40, + ) + + +def _named_step(workflow: str, name: str) -> str: + """Return one exact named workflow step.""" + + marker = f" - name: {name}\n" + start = workflow.index(marker) + try: + end = workflow.index("\n - name:", start + len(marker)) + except ValueError: + end = len(workflow) + return workflow[start:end] + + +def _python_heredoc(step: str) -> str: + """Return executable Python from one workflow heredoc.""" + + marker = "python3 - <<'PYTHON'\n" + start = step.index(marker) + len(marker) + end = step.index("\n PYTHON", start) + return textwrap.dedent(step[start:end]) + + +def _run_python_contract( + code: str, environment: dict[str, str] +) -> subprocess.CompletedProcess: + """Execute one extracted workflow validator with an isolated environment.""" + + return subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + check=False, + env={**os.environ, **environment}, + ) + + +def _rebind_invocation_key(payload: dict[str, object]) -> None: + """Recompute the canonical claim digest after an intentional claim mutation.""" + + canonical = json.dumps( + payload["claim"], + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + payload["agent_invocation_key"] = hashlib.sha256(canonical).hexdigest() + + +def test_router_emits_three_key_versioned_envelope_without_changing_claim_key() -> None: + """Keep the transport bounded while preserving existing ledger identities.""" + + router = _load_router() + request = _request(router) + body = router.opencode_payload(request) + payload = body["client_payload"] + + assert body["event_type"] == "agent-mention-opencode" + assert set(payload) == ENVELOPE_KEYS + assert len(payload) == 3 + assert payload["schema"] == SCHEMA + assert payload["claim"] == router.agent_invocation_claim( + request, "opencode-agent" + ) + assert payload["agent_invocation_key"] == ( + "8c73b6aa8ca5ef7b610b997e6913b71bfad29e74330836263087617fb3d0b9ff" + ) + assert router.agent_ledger_artifact_name(request, "opencode-agent") == ( + "cwl-agent-invocation-" + "8c73b6aa8ca5ef7b610b997e6913b71bfad29e74330836263087617fb3d0b9ff" + ) + assert len(json.dumps(payload, separators=(",", ":"))) <= 65_535 + + +def test_router_rejects_an_oversized_first_hop_before_calling_github() -> None: + """The producer enforces GitHub's size contract before any API request.""" + + router = _load_router() + oversized = replace(_request(router), actor="a" * 70_000) + + with pytest.raises(ValueError, match="repository dispatch exceeds GitHub limits"): + router.opencode_payload(oversized) + + +def test_wrapper_validates_and_reuses_the_same_bounded_envelope_before_ledger() -> None: + """Validate the complete second hop before claiming an immutable artifact.""" + + workflow = WRAPPER_WORKFLOW.read_text(encoding="utf-8") + validate = _named_step( + workflow, + "Validate exact invocation payload and prepare scheduler request", + ) + forward = _named_step( + workflow, + "Forward once to the authoritative review-only scheduler", + ) + + assert "github.event.client_payload.claim.repository" in workflow + assert "github.event.client_payload.claim.pr_number" in workflow + assert "PAYLOAD_SCHEMA: ${{ github.event.client_payload.schema || '' }}" in workflow + assert "set(envelope)" in validate + for key in sorted(ENVELOPE_KEYS): + assert f'"{key}"' in validate + assert "set(claim)" in validate + assert "hmac.compare_digest" in validate + assert "65_535" in validate + assert '"event_type": "merge-scheduler-agent-review-v2"' in validate + assert '"client_payload": envelope' in validate + assert workflow.index( + "Validate exact invocation payload and prepare scheduler request" + ) < workflow.index("Inspect exact-name Actions artifact ledger") + assert '--input "$SCHEDULER_REQUEST_FILE"' in forward + assert "client_payload:" not in forward + + +def test_wrapper_executes_the_validated_envelope_as_the_exact_second_hop( + tmp_path: Path, +) -> None: + """The materialized scheduler request reuses the exact three-key payload.""" + + router = _load_router() + payload = router.opencode_payload(_request(router))["client_payload"] + workflow = WRAPPER_WORKFLOW.read_text(encoding="utf-8") + code = _python_heredoc( + _named_step( + workflow, + "Validate exact invocation payload and prepare scheduler request", + ) + ) + environment_file = tmp_path / "github-env" + completed = _run_python_contract( + code, + { + "CLIENT_PAYLOAD_JSON": json.dumps(payload), + "GITHUB_ENV": str(environment_file), + "INVOCATION_KEY": payload["agent_invocation_key"], + "PAYLOAD_SCHEMA": payload["schema"], + "RUNNER_TEMP": str(tmp_path), + }, + ) + + assert completed.returncode == 0, completed.stderr + request_path = tmp_path / "agent-review-scheduler-request.json" + request = json.loads(request_path.read_text(encoding="utf-8")) + assert request == { + "event_type": "merge-scheduler-agent-review-v2", + "client_payload": payload, + } + assert len(request["client_payload"]) == 3 + assert request_path.stat().st_size <= 65_535 + assert f"SCHEDULER_REQUEST_FILE={request_path}" in environment_file.read_text( + encoding="utf-8" + ) + + +@pytest.mark.parametrize( + ("mutation", "error_fragment"), + [ + ("extra-envelope-field", "invalid OpenCode invocation envelope"), + ("missing-claim-field", "invalid OpenCode invocation claim fields"), + ("wrong-boolean-type", "invalid enable_auto_merge flag"), + ("altered-bound-field", "invocation key does not match canonical payload"), + ("unsupported-schema", "unsupported OpenCode invocation schema"), + ("policy-violating-claim", "violates review-only policy"), + ("invalid-repository", "invalid repository"), + ("invalid-head-sha", "invalid head SHA"), + ("invalid-base-sha", "invalid base SHA"), + ("invalid-base-branch", "invalid base branch"), + ("invalid-actor", "invalid actor"), + ], +) +def test_wrapper_rejects_malformed_or_unbound_envelopes_before_materialization( + tmp_path: Path, + mutation: str, + error_fragment: str, +) -> None: + """Unknown, malformed, or key-mismatched claims fail before ledger access.""" + + router = _load_router() + payload = deepcopy(router.opencode_payload(_request(router))["client_payload"]) + if mutation == "extra-envelope-field": + payload["extra"] = "rejected" + elif mutation == "missing-claim-field": + del payload["claim"]["base_sha"] + elif mutation == "wrong-boolean-type": + payload["claim"]["enable_auto_merge"] = "false" + elif mutation == "altered-bound-field": + payload["claim"]["head_sha"] = "c" * 40 + elif mutation == "unsupported-schema": + payload["schema"] = "cwl.agent-invocation/v3" + elif mutation == "policy-violating-claim": + payload["claim"]["update_branches"] = True + elif mutation == "invalid-repository": + payload["claim"]["repository"] = "OtherOrg/example" + elif mutation == "invalid-head-sha": + payload["claim"]["head_sha"] = "z" * 40 + elif mutation == "invalid-base-sha": + payload["claim"]["base_sha"] = "z" * 40 + elif mutation == "invalid-base-branch": + payload["claim"]["base_branch"] = "-main" + elif mutation == "invalid-actor": + payload["claim"]["actor"] = "invalid_actor" + else: # pragma: no cover - the parameter list is exhaustive + raise AssertionError(mutation) + + if mutation in { + "policy-violating-claim", + "invalid-repository", + "invalid-head-sha", + "invalid-base-sha", + "invalid-base-branch", + "invalid-actor", + }: + _rebind_invocation_key(payload) + + workflow = WRAPPER_WORKFLOW.read_text(encoding="utf-8") + code = _python_heredoc( + _named_step( + workflow, + "Validate exact invocation payload and prepare scheduler request", + ) + ) + completed = _run_python_contract( + code, + { + "CLIENT_PAYLOAD_JSON": json.dumps(payload), + "GITHUB_ENV": str(tmp_path / "github-env"), + "INVOCATION_KEY": payload["agent_invocation_key"], + "PAYLOAD_SCHEMA": payload["schema"], + "RUNNER_TEMP": str(tmp_path), + }, + ) + + assert completed.returncode != 0 + assert error_fragment in completed.stderr + assert not (tmp_path / "agent-review-scheduler-request.json").exists() + + +def test_scheduler_has_strict_v2_and_explicit_legacy_dispatch_paths() -> None: + """The dedicated review event must not fall through generic flat defaults.""" + + workflow = SCHEDULER_WORKFLOW.read_text(encoding="utf-8") + targeted = _named_step(workflow, "Validate targeted repository dispatch") + inspect = _named_step(workflow, "Inspect PR review and merge queue") + + assert "types: [merge-scheduler, merge-scheduler-agent-review-v2]" in workflow + assert "github.event.client_payload.claim.repository" in workflow + assert "github.event.client_payload.claim.pr_number" in workflow + assert ( + 'os.environ["GITHUB_EVENT_ACTION"] == "merge-scheduler-agent-review-v2"' + in targeted + ) + assert f'EXPECTED_SCHEMA="{SCHEMA}"' in targeted + assert "set(envelope)" in targeted + assert "set(claim)" in targeted + assert "hmac.compare_digest" in targeted + assert 'live_base_sha="$(jq -r \'.base.sha // empty\'' in targeted + assert '"$TARGET_HEAD_SHA_INPUT" != "$live_head_sha"' in targeted + assert '"$TARGET_BASE_SHA_INPUT" != "$live_base_sha"' in targeted + assert "--expected-head-sha" in inspect + assert "--expected-base-sha" in inspect + assert "--expected-base-branch" in inspect + concurrency = workflow.split("concurrency:", 1)[1].split("jobs:", 1)[0] + assert "github.event.client_payload.agent_invocation_key" in concurrency + assert "github.event.action != 'merge-scheduler-agent-review-v2'" in concurrency + assert "github.run_id" in concurrency + + +def test_scheduler_executes_strict_v2_validation_and_keeps_legacy_explicit() -> None: + """Only a valid v2 envelope or a schema-free legacy payload is accepted.""" + + router = _load_router() + payload = router.opencode_payload(_request(router))["client_payload"] + workflow = SCHEDULER_WORKFLOW.read_text(encoding="utf-8") + code = _python_heredoc( + _named_step(workflow, "Validate targeted repository dispatch") + ) + common = {"EXPECTED_SCHEMA": SCHEMA} + + valid = _run_python_contract( + code, + { + **common, + "DISPATCH_CLIENT_PAYLOAD_JSON": json.dumps(payload), + "GITHUB_EVENT_ACTION": "merge-scheduler-agent-review-v2", + }, + ) + assert valid.returncode == 0, valid.stderr + + legacy = _run_python_contract( + code, + { + **common, + "DISPATCH_CLIENT_PAYLOAD_JSON": json.dumps( + {"target_repository": "ContextualWisdomLab/example", "pr_number": 17} + ), + "GITHUB_EVENT_ACTION": "merge-scheduler", + }, + ) + assert legacy.returncode == 0, legacy.stderr + + wrong_event = _run_python_contract( + code, + { + **common, + "DISPATCH_CLIENT_PAYLOAD_JSON": json.dumps(payload), + "GITHUB_EVENT_ACTION": "merge-scheduler", + }, + ) + assert wrong_event.returncode != 0 + + malformed = deepcopy(payload) + malformed["claim"]["update_branches"] = True + invalid_policy = _run_python_contract( + code, + { + **common, + "DISPATCH_CLIENT_PAYLOAD_JSON": json.dumps(malformed), + "GITHUB_EVENT_ACTION": "merge-scheduler-agent-review-v2", + }, + ) + assert invalid_policy.returncode != 0 + + +def test_agent_mention_quality_gate_covers_the_downstream_scheduler_contract() -> None: + """Every production and regression path in this transport runs its quality gate.""" + + workflow = QUALITY_WORKFLOW.read_text(encoding="utf-8") + + for path in ( + '.github/workflows/pr-review-merge-scheduler.yml', + 'scripts/ci/pr_review_merge_scheduler.py', + 'scripts/ci/pr_review_fix_scheduler.py', + 'tests/test_pr_review_merge_scheduler.py', + ): + assert workflow.count(f' - "{path}"') == 2 + + coverage_config = workflow.split("[run]\n", 1)[1].split("[report]\n", 1)[0] + assert "scripts/ci/pr_review_merge_scheduler.py" in coverage_config + interrogate = workflow.split("python -m interrogate --fail-under=100", 1)[1].split( + "python -m compileall", 1 + )[0] + assert "scripts/ci/pr_review_merge_scheduler.py" in interrogate diff --git a/tests/test_agent_mention_router.py b/tests/test_agent_mention_router.py index 4509d43f0..1520a204d 100644 --- a/tests/test_agent_mention_router.py +++ b/tests/test_agent_mention_router.py @@ -220,11 +220,13 @@ def test_eligible_agents_and_payloads() -> None: assert noema["client_payload"]["pr_base_sha"] == "b" * 40 opencode = module.opencode_payload(request) 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 opencode["client_payload"]["schema"] == "cwl.agent-invocation/v2" + claim = opencode["client_payload"]["claim"] + assert claim["base_branch"] == "develop" + assert claim["base_sha"] == "b" * 40 + 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: diff --git a/tests/test_agent_mention_workflow_contract.py b/tests/test_agent_mention_workflow_contract.py index c5fc4cae5..6958a5a05 100644 --- a/tests/test_agent_mention_workflow_contract.py +++ b/tests/test_agent_mention_workflow_contract.py @@ -52,11 +52,22 @@ def test_workflow_uses_local_event_and_central_sweep_with_job_scoped_writes() -> def test_quality_workflow_measures_exact_files_without_module_name_warnings() -> None: - """Coverage includes the two script paths instead of treating paths as modules.""" + """Coverage and docstring gates include every changed production helper.""" text = QUALITY_WORKFLOW.read_text(encoding="utf-8") + trigger_block = text.split("\nconcurrency:\n", 1)[0] coverage_config = text.split("[run]\n", 1)[1].split("[report]\n", 1)[0] + interrogate_block = text.split( + "python -m interrogate --fail-under=100 \\\n", 1 + )[1].split("\n python -m compileall", 1)[0] + assert "include =" in coverage_config assert "source =" not in coverage_config - assert "scripts/ci/agent_mention_router.py" in coverage_config - assert "scripts/ci/agent_mention_sweep.py" in coverage_config + for production_path in ( + "scripts/ci/agent_mention_router.py", + "scripts/ci/agent_mention_sweep.py", + "scripts/ci/pr_review_merge_scheduler.py", + ): + assert production_path in coverage_config + assert production_path in interrogate_block + assert trigger_block.count('"scripts/ci/pr_review_fix_scheduler.py"') == 2 diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 3e421e903..ce9434377 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -33,6 +33,7 @@ def fake_fine_grained_github_token(body): def make_pr(**overrides): value = { "number": 1, + "state": "OPEN", "title": "Central review", "isDraft": False, "mergeable": "MERGEABLE", @@ -3399,6 +3400,405 @@ def test_inspect_pr_cancels_stale_queued_runs_before_decision(monkeypatch): assert cancelled == [("owner/repo", 1, True)] +def test_snapshot_bound_inspection_performs_review_dispatch_without_general_mutations( + monkeypatch, +): + """A digest-bound mention cannot clean up, merge, or update unrelated state.""" + + def forbidden(*args, **kwargs): + raise AssertionError(f"unexpected general scheduler mutation: {args!r} {kwargs!r}") + + for name in ( + "cancel_stale_pr_runs", + "resolve_outdated_review_threads", + "dismiss_stale_opencode_approvals", + "dismiss_stale_opencode_change_requests", + "disable_auto_merge", + "enable_auto_merge", + "merge_pr", + "restamp_pr_head_for_last_push_approval", + "update_branch", + ): + monkeypatch.setattr(sched, name, forbidden) + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda repo, workflow: None) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_strix_evidence", + lambda repo, workflow, pr, dry_run, snapshot_guarded: dispatched.append( + (repo, workflow, pr["headRefOid"], dry_run, snapshot_guarded) + ) + or "dispatched", + ) + + decision = inspect( + make_pr(), + dry_run=False, + snapshot_guarded=True, + enable_auto_merge_flag=False, + merge_mode="disabled", + update_branches=False, + ) + + assert decision.action == "security_dispatch" + assert dispatched == [ + ("owner/repo", "Strix Security Scan", "head", False, True) + ] + + +def test_snapshot_bound_review_covers_every_fail_closed_decision_boundary(monkeypatch): + """The review-only path remains explicit for every terminal evidence state.""" + + monkeypatch.setattr( + sched, + "unresolved_thread_count", + lambda pr: pr.get("test_unresolved", 0), + ) + monkeypatch.setattr( + sched, + "has_current_head_changes_requested", + lambda pr: pr.get("test_changes_requested", False), + ) + monkeypatch.setattr( + sched, + "effective_merge_state", + lambda pr: pr.get("test_merge_state", "CLEAN"), + ) + monkeypatch.setattr( + sched, + "has_current_head_approval", + lambda pr: pr.get("test_approved", False), + ) + monkeypatch.setattr( + sched, + "failed_status_checks", + lambda pr: pr.get("test_failed", []), + ) + monkeypatch.setattr( + sched, + "action_required_checks", + lambda pr: pr.get("test_action_required", []), + ) + monkeypatch.setattr( + sched, + "branch_outdated_by_base", + lambda pr, merge_state: pr.get("test_behind", 0), + ) + monkeypatch.setattr( + sched, + "opencode_progress_state", + lambda pr, stale_after_minutes: pr.get("test_opencode", "absent"), + ) + monkeypatch.setattr( + sched, + "has_current_head_deterministic_fallback_approval", + lambda pr: pr.get("test_fallback", False), + ) + monkeypatch.setattr( + sched, + "strix_evidence_state", + lambda pr: pr.get("test_strix", "missing"), + ) + monkeypatch.setattr( + sched, + "repository_dispatch_wait_reason", + lambda repo, workflow: "dispatch unavailable" if workflow.startswith("wait-") else None, + ) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run, snapshot_guarded: pr.get( + "test_dispatch_result", "dispatched" + ), + ) + monkeypatch.setattr( + sched, + "dispatch_strix_evidence", + lambda repo, workflow, pr, dry_run, snapshot_guarded: pr.get( + "test_dispatch_result", "dispatched" + ), + ) + + def decide( + pr, + *, + trigger_reviews=True, + review_dispatch_allowed=True, + workflow="review", + security_workflow="security", + ): + return sched.inspect_snapshot_bound_review( + "owner/repo", + pr, + dry_run=False, + trigger_reviews=trigger_reviews, + review_dispatch_allowed=review_dispatch_allowed, + workflow=workflow, + security_workflow=security_workflow, + base_branch="main", + stale_opencode_minutes=60, + ) + + cases = [ + (make_pr(state="CLOSED"), {}, "wait", "no longer open"), + (make_pr(isDraft=True), {}, "skip", "draft"), + (make_pr(baseRefName="release"), {}, "wait", "base branch changed"), + (make_pr(test_unresolved=2), {}, "block", "2 unresolved"), + (make_pr(test_changes_requested=True), {}, "block", "requested changes"), + (make_pr(test_merge_state="DIRTY"), {}, "block", "merge conflict"), + ( + make_pr(test_approved=True, test_failed=["quality"]), + {}, + "block", + "failed check(s)", + ), + ( + make_pr(test_action_required=["deploy"]), + {}, + "wait", + "workflow action required", + ), + ( + make_pr(test_behind=2), + {}, + "wait", + "current head has no OpenCode approval; snapshot-bound review cannot update", + ), + (make_pr(test_merge_state="UNKNOWN"), {}, "wait", "still being calculated"), + (make_pr(test_approved=True), {}, "wait", "review-only"), + (make_pr(test_opencode="running"), {}, "wait", "already in progress"), + ( + make_pr(test_opencode="stale"), + {"trigger_reviews": False}, + "wait", + "dispatch disabled", + ), + ( + make_pr(test_opencode="stale"), + {"review_dispatch_allowed": False}, + "wait", + "limit reached", + ), + ( + make_pr(test_opencode="stale", test_dispatch_result="already_running"), + {}, + "wait", + "already active", + ), + ( + make_pr(test_opencode="stale", test_dispatch_result="snapshot_changed"), + {}, + "wait", + "different-head review run is active", + ), + (make_pr(test_opencode="stale"), {}, "review_dispatch", "re-dispatched"), + ( + make_pr(test_strix="missing"), + {"review_dispatch_allowed": False}, + "wait", + "limit reached", + ), + ( + make_pr(test_strix="missing"), + {"security_workflow": "wait-security"}, + "wait", + "dispatch unavailable", + ), + ( + make_pr(test_strix="missing", test_dispatch_result="snapshot_changed"), + {}, + "wait", + "different-head Strix run is active", + ), + ( + make_pr(test_strix="missing", test_dispatch_result="already_running"), + {}, + "wait", + "same-head Strix evidence", + ), + (make_pr(test_strix="running"), {}, "wait", "still running"), + ( + make_pr(test_strix="complete"), + {"review_dispatch_allowed": False}, + "wait", + "limit reached", + ), + ( + make_pr(test_strix="complete"), + {"workflow": "wait-review"}, + "wait", + "dispatch unavailable", + ), + ( + make_pr(test_strix="complete", test_dispatch_result="already_running"), + {}, + "wait", + "already active", + ), + ( + make_pr(test_strix="complete", test_dispatch_result="snapshot_changed"), + {}, + "wait", + "different-head review run is active", + ), + ( + make_pr(test_strix="complete"), + {}, + "review_dispatch", + "OpenCode dispatched", + ), + ( + make_pr(test_behind=1), + {"trigger_reviews": False}, + "block", + "dispatch disabled", + ), + ] + for pr, kwargs, action, reason in cases: + decision = decide(pr, **kwargs) + assert decision.action == action + assert reason in decision.reason + + approved_behind = decide(make_pr(test_behind=2, test_approved=True)) + assert approved_behind == sched.Decision( + 1, + "wait", + "current head is approved; snapshot-bound review cannot update an outdated branch", + ) + assert "no OpenCode approval" not in approved_behind.reason + + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_run") + fallback = decide(make_pr(test_fallback=True)) + assert fallback.action == "wait" + assert "deterministic fallback" in fallback.reason + no_fallback = decide( + make_pr(test_fallback=False), + trigger_reviews=False, + ) + assert no_fallback.action == "block" + + +def test_snapshot_guarded_review_dispatch_does_not_post_when_another_head_is_active(monkeypatch): + """A post-validation push prevents stale dispatch and receiver-side cancellation.""" + + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda action: None) + monkeypatch.setattr( + sched, + "active_opencode_run_refs", + lambda repo, workflow, pr: ([], [(repo, "new-head-run")]), + ) + monkeypatch.setattr( + sched, + "force_cancel_workflow_run_refs", + lambda refs: (_ for _ in ()).throw(AssertionError(f"unexpected cancellation: {refs}")), + ) + payloads = [] + monkeypatch.setattr( + sched, + "run_github_dispatch", + lambda args, stdin=None: payloads.append(json.loads(stdin)), + ) + pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + + assert ( + sched.dispatch_opencode_review( + "owner/repo", + "OpenCode Review", + pr, + dry_run=False, + snapshot_guarded=True, + ) + == "snapshot_changed" + ) + assert payloads == [] + + +def test_snapshot_guarded_strix_dispatch_does_not_post_or_cancel_newer_runs( + monkeypatch, +): + """Snapshot mode leaves later-head Strix runs alone without queuing stale work.""" + + monkeypatch.setattr( + sched, + "matching_actions_job_id", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("unexpected rerun lookup")), + ) + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda action: None) + monkeypatch.setattr( + sched, + "active_review_run_refs", + lambda *args, **kwargs: ([], [("owner/repo", "new-head-run")]), + ) + monkeypatch.setattr( + sched, + "force_cancel_workflow_run_refs", + lambda refs: (_ for _ in ()).throw(AssertionError(f"unexpected cancellation: {refs}")), + ) + payloads = [] + monkeypatch.setattr( + sched, + "run_github_dispatch", + lambda args, stdin=None: payloads.append(json.loads(stdin)), + ) + pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + + assert ( + sched.dispatch_strix_evidence( + "owner/repo", + "Strix Security Scan", + pr, + dry_run=False, + snapshot_guarded=True, + ) + == "snapshot_changed" + ) + assert payloads == [] + + +def test_snapshot_guarded_dispatch_deduplicates_same_head_without_cancellation( + monkeypatch, +): + """An exact-head active run suppresses duplicate OpenCode and Strix POSTs.""" + + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda action: None) + monkeypatch.setattr( + sched, + "active_opencode_run_refs", + lambda repo, workflow, pr: ([(repo, "same-head")], []), + ) + monkeypatch.setattr( + sched, + "active_review_run_refs", + lambda *args, **kwargs: ([("owner/repo", "same-head")], []), + ) + monkeypatch.setattr( + sched, + "force_cancel_workflow_run_refs", + lambda refs: (_ for _ in ()).throw(AssertionError(f"unexpected cancellation: {refs}")), + ) + monkeypatch.setattr( + sched, + "run_github_dispatch", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("unexpected POST")), + ) + pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + + assert sched.dispatch_opencode_review( + "owner/repo", + "OpenCode Review", + pr, + dry_run=False, + snapshot_guarded=True, + ) == "already_running" + assert sched.dispatch_strix_evidence( + "owner/repo", + "Strix Security Scan", + pr, + dry_run=False, + snapshot_guarded=True, + ) == "already_running" + + def test_inspect_pr_blocks_auto_merge_for_approved_conflicts(monkeypatch): auto_merges = [] disables = [] @@ -4365,6 +4765,228 @@ def test_main_rejects_invalid_branch_update_limit(): ) +@pytest.mark.parametrize( + ("observed_head", "observed_base", "observed_branch", "observed_state", "message"), + [ + ("c" * 40, "b" * 40, "main", "OPEN", "head SHA changed"), + ("a" * 40, "d" * 40, "main", "OPEN", "base SHA changed"), + ("a" * 40, "b" * 40, "release", "OPEN", "base branch changed"), + ("a" * 40, "b" * 40, "main", "CLOSED", "no longer open"), + ], +) +def test_main_rejects_target_snapshot_drift_before_inspection( + monkeypatch, + observed_head, + observed_base, + observed_branch, + observed_state, + message, +): + """A validated mention cannot act after the PR head or base advances.""" + + inspected = [] + monkeypatch.setattr( + sched, + "fetch_pr", + lambda repo, number: [ + make_pr( + number=number, + headRefOid=observed_head, + baseRefOid=observed_base, + baseRefName=observed_branch, + state=observed_state, + ) + ], + ) + monkeypatch.setattr( + sched, + "inspect_pr", + lambda *args, **kwargs: inspected.append((args, kwargs)), + ) + + with pytest.raises(SystemExit, match=message): + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--pr-number", + "7", + "--expected-head-sha", + "a" * 40, + "--expected-base-sha", + "b" * 40, + "--expected-base-branch", + "main", + ] + ) + + assert inspected == [] + + +def test_main_accepts_the_exact_expected_target_snapshot(monkeypatch, capsys): + """Matching immutable refs reach inspection with the review-only policy.""" + + inspected = [] + monkeypatch.setattr( + sched, + "fetch_pr", + lambda repo, number: [ + make_pr(number=number, headRefOid="a" * 40, baseRefOid="b" * 40) + ], + ) + + def fake_inspect(repo, pr, **kwargs): + inspected.append((repo, pr, kwargs)) + return sched.Decision(pr["number"], "skip", "exact snapshot") + + monkeypatch.setattr(sched, "inspect_pr", fake_inspect) + + assert ( + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--pr-number", + "7", + "--expected-head-sha", + "a" * 40, + "--expected-base-sha", + "b" * 40, + "--expected-base-branch", + "main", + "--no-enable-auto-merge", + "--merge-mode", + "disabled", + "--no-update-branches", + ] + ) + == 0 + ) + assert len(inspected) == 1 + assert inspected[0][1]["headRefOid"] == "a" * 40 + assert inspected[0][1]["baseRefOid"] == "b" * 40 + assert inspected[0][2]["enable_auto_merge_flag"] is False + assert inspected[0][2]["merge_mode"] == "disabled" + assert inspected[0][2]["update_branches"] is False + assert inspected[0][2]["snapshot_guarded"] is True + assert "exact snapshot" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("prs", "pr_number", "head", "base", "branch", "message"), + [ + ([], 7, "a" * 40, "", "main", "must be supplied together"), + ([], 7, "", "b" * 40, "main", "must be supplied together"), + ([], 7, "a" * 40, "b" * 40, "", "must be supplied together"), + ([], 0, "a" * 40, "b" * 40, "main", "require --pr-number"), + ([], 7, "invalid", "b" * 40, "main", "invalid git sha"), + ([], 7, "a" * 40, "b" * 40, "bad..branch", "invalid git ref"), + ([], 7, "a" * 40, "b" * 40, "main", "unavailable or ambiguous"), + ( + [make_pr(number=8, headRefOid="a" * 40, baseRefOid="b" * 40)], + 7, + "a" * 40, + "b" * 40, + "main", + "unavailable or ambiguous", + ), + ( + [make_pr(number=7, headRefOid="invalid", baseRefOid="b" * 40)], + 7, + "a" * 40, + "b" * 40, + "main", + "returned malformed refs", + ), + ( + [make_pr(number=7, headRefOid="a" * 40, baseRefOid="invalid")], + 7, + "a" * 40, + "b" * 40, + "main", + "returned malformed refs", + ), + ], +) +def test_expected_snapshot_guard_rejects_incomplete_or_malformed_inputs( + prs, + pr_number, + head, + base, + branch, + message, +): + """Target snapshot guards reject incomplete identities before inspection.""" + + with pytest.raises(SystemExit, match=message): + sched.validate_expected_pr_snapshot( + prs, + pr_number=pr_number, + expected_head_sha=head, + expected_base_sha=base, + expected_base_branch=branch, + ) + + +def test_main_refetches_a_guarded_snapshot_before_any_general_scheduler_action( + monkeypatch, +): + """A push after initial validation aborts before cleanup or dispatch.""" + + snapshots = [ + make_pr(number=7, headRefOid="a" * 40, baseRefOid="b" * 40), + make_pr(number=7, headRefOid="c" * 40, baseRefOid="b" * 40), + ] + fetches = [] + general_inspections = [] + + def fake_fetch(repo, number): + fetches.append((repo, number)) + return [snapshots.pop(0)] + + monkeypatch.setattr(sched, "fetch_pr", fake_fetch) + monkeypatch.setattr( + sched, + "inspect_pr", + lambda *args, **kwargs: general_inspections.append((args, kwargs)), + ) + + with pytest.raises(SystemExit, match="head SHA changed"): + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--pr-number", + "7", + "--expected-head-sha", + "a" * 40, + "--expected-base-sha", + "b" * 40, + "--expected-base-branch", + "main", + "--no-enable-auto-merge", + "--merge-mode", + "disabled", + "--no-update-branches", + ] + ) + + assert fetches == [("owner/repo", 7), ("owner/repo", 7)] + assert general_inspections == [] + + def test_print_summary_self_test_parse_args_and_main(monkeypatch, capsys): sched.print_summary( [sched.Decision(1, "wait", "ready"), sched.Decision(2, "wait", "queued")], diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 233c08584..cf901fc23 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -77,6 +77,11 @@ def test_targeted_scheduler_dispatch_is_allowlisted_and_exact_pr_scoped() -> Non assert '[ "$live_base_repository" != "$TARGET_REPOSITORY_INPUT" ]' in validation assert '[ "$live_head_repository" != "$TARGET_REPOSITORY_INPUT" ]' in validation assert "Targeted scheduler dispatch base branch does not match the live PR" in validation + assert "TARGET_HEAD_SHA_INPUT:" in validation + assert "TARGET_BASE_SHA_INPUT:" in validation + assert "live_base_sha=" in validation + assert '"$TARGET_HEAD_SHA_INPUT" != "$live_head_sha"' in validation + assert '"$TARGET_BASE_SHA_INPUT" != "$live_base_sha"' in validation assert "TARGET_REPOSITORY: ${{ steps.targeted_dispatch.outputs.repository }}" in inspect assert ( "TARGET_DEFAULT_BRANCH: ${{ steps.targeted_dispatch.outputs.base_branch }}" @@ -85,9 +90,13 @@ def test_targeted_scheduler_dispatch_is_allowlisted_and_exact_pr_scoped() -> Non assert '--repo "$TARGET_REPOSITORY"' in inspect assert '--base-branch "$TARGET_DEFAULT_BRANCH"' in inspect assert 'args+=(--pr-number "$PULL_REQUEST_NUMBER")' in inspect + assert '--expected-head-sha "$EXPECTED_HEAD_SHA"' in inspect + assert '--expected-base-sha "$EXPECTED_BASE_SHA"' in inspect assert ( "github.event_name == 'repository_dispatch' && " - "github.event.client_payload.target_repository != '' && " + "(github.event.action == 'merge-scheduler-agent-review-v2' && " + "github.event.client_payload.claim.repository != '' || " + "github.event.client_payload.target_repository != '') && " "(secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || " "steps.scheduler_app_token.outputs.token) || github.token" ) in inspect @@ -111,7 +120,13 @@ def test_privileged_review_retries_use_default_branch_repository_dispatch() -> N trigger_contract = workflow.split("concurrency:", 1)[0] assert "repository_dispatch:" in trigger_contract - assert f"types: [{event_type}]" in trigger_contract + if filename == "pr-review-merge-scheduler.yml": + assert ( + "types: [merge-scheduler, merge-scheduler-agent-review-v2]" + in trigger_contract + ) + else: + assert f"types: [{event_type}]" in trigger_contract assert "workflow_dispatch:" not in trigger_contract assert "github.event.inputs" not in workflow assert "github.event.client_payload" in workflow @@ -206,7 +221,7 @@ def test_central_semgrep_logs_every_finding_and_distinguishes_engine_failure() - assert "Semgrep engine/configuration failed with rc=${SEMGREP_RC}" in workflow -def test_strix_cancels_superseded_pr_head_security_evidence() -> None: +def test_strix_isolates_repository_dispatch_runs_from_stale_event_cancellation() -> None: workflow = workflow_text("strix.yml") concurrency_contract = workflow.split("concurrency:", 1)[1].split( "permissions:", 1 @@ -221,13 +236,13 @@ def test_strix_cancels_superseded_pr_head_security_evidence() -> None: "github.event.pull_request.base.repo.full_name || github.repository }}" ) in concurrency_contract assert "format('pr-{0}', github.event.pull_request.number)" in concurrency_contract - assert "github.event.client_payload.pr_number != '' && format('pr-{0}'," in workflow - assert "format('pr-{0}-{1}'" not in concurrency_contract - assert "github.event.pull_request.head.sha" not in concurrency_contract - assert "github.event.client_payload.pr_head_sha" not in concurrency_contract - assert "cancel-in-progress: true" in workflow + assert "github.event_name == 'repository_dispatch' && github.run_id" in concurrency_contract + assert "cancel-in-progress: ${{ github.event_name != 'repository_dispatch' }}" in workflow assert "default-branch repository_dispatch evidence cannot cancel" in workflow - assert "PR-number scope keeps the queue on the current HEAD" in workflow + assert "run-id scope prevents an out-of-order stale dispatch" in workflow + assert "default pending-run replacement could discard newer valid evidence" in workflow + assert "queue: max cannot be combined with the pull-request cancellation policy" in workflow + assert "\n queue: max\n" not in concurrency_contract assert ( "refs/pull//head has already advanced before this queued run starts" in workflow @@ -281,8 +296,9 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - assert "${{ secrets." not in opencode_bootstrap strix_workflow = workflow_text("strix.yml") - assert "cancel-in-progress: true" in strix_workflow - assert "PR-number scope keeps the queue on the current HEAD" in strix_workflow + assert "cancel-in-progress: ${{ github.event_name != 'repository_dispatch' }}" in strix_workflow + assert "run-id scope prevents an out-of-order stale dispatch" in strix_workflow + assert "default pending-run replacement could discard newer valid evidence" in strix_workflow def test_close_empty_pr_metadata_lookup_retries_and_fails_open() -> None: