diff --git a/.github/workflows/agent-mention-noema-dispatch.yml b/.github/workflows/agent-mention-noema-dispatch.yml new file mode 100644 index 000000000..4912e5add --- /dev/null +++ b/.github/workflows/agent-mention-noema-dispatch.yml @@ -0,0 +1,196 @@ +name: Agent Mention Noema Dispatch +run-name: >- + Agent Mention Noema ${{ github.event.client_payload.target_repository }}#${{ + github.event.client_payload.pr_number }} [cwl-agent-invocation:${{ + github.event.client_payload.agent_invocation_key }}] + +on: + repository_dispatch: + types: [agent-mention-noema] + +concurrency: + group: agent-mention-noema-${{ github.event.client_payload.agent_invocation_key || github.run_id }} + cancel-in-progress: false + queue: max + +permissions: + contents: read + +jobs: + validate-and-forward: + if: github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: write + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_AGENT: "cwl-noema-review" + PAYLOAD_AGENT: ${{ github.event.client_payload.requested_agent || '' }} + 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 || '' }} + steps: + - name: Validate exact invocation payload + 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]*$ ]] || + ! [[ "$REQUESTED_BY" =~ ^[A-Za-z0-9-]+$ ]]; then + echo "::error::Rejected malformed or mismatched Noema agent invocation payload." + exit 1 + fi + + python3 - <<'PYTHON' + import hashlib + import hmac + import json + import os + + 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"]), + "head_sha": os.environ["PR_HEAD_SHA"], + "pr_number": int(os.environ["PR_NUMBER"]), + "repository": os.environ["TARGET_REPOSITORY"], + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + expected = hashlib.sha256(canonical).hexdigest() + if not hmac.compare_digest(expected, os.environ["INVOCATION_KEY"]): + raise SystemExit("invocation key does not match canonical payload") + PYTHON + + - name: Inspect exact-name Actions artifact ledger + id: ledger + run: | + set -euo pipefail + LEDGER_ARTIFACT_NAME="cwl-agent-invocation-${INVOCATION_KEY}" + export LEDGER_ARTIFACT_NAME + echo "LEDGER_ARTIFACT_NAME=$LEDGER_ARTIFACT_NAME" >>"$GITHUB_ENV" + response_file="${RUNNER_TEMP}/agent-mention-artifacts.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts" \ + -X GET \ + -f "name=${LEDGER_ARTIFACT_NAME}" \ + -f "per_page=100" >"$response_file" + python3 - "$response_file" <<'PYTHON' + import json + import os + from pathlib import Path + import sys + + response_path = Path(sys.argv[1]) + payload = json.loads(response_path.read_text(encoding="utf-8")) + expected_name = os.environ["LEDGER_ARTIFACT_NAME"] + if not isinstance(payload, dict): + raise SystemExit("artifact response must be an object") + total_count = payload.get("total_count") + artifacts = payload.get("artifacts") + if type(total_count) is not int or total_count < 0: + raise SystemExit("artifact response has an invalid total_count") + if not isinstance(artifacts, list): + raise SystemExit("artifact response has an invalid artifacts collection") + if total_count != len(artifacts): + raise SystemExit("artifact response is truncated or inconsistent") + live = False + for artifact in artifacts: + if not isinstance(artifact, dict): + raise SystemExit("artifact response contains a non-object record") + artifact_id = artifact.get("id") + name = artifact.get("name") + expired = artifact.get("expired") + if type(artifact_id) is not int or artifact_id < 1: + raise SystemExit("artifact response contains an invalid artifact id") + if not isinstance(name, str) or name != expected_name: + raise SystemExit("artifact response contains a mismatched artifact name") + if type(expired) is not bool: + raise SystemExit("artifact response contains an invalid expired flag") + live = live or not expired + + output_path = Path(os.environ["GITHUB_OUTPUT"]) + if live: + with output_path.open("a", encoding="utf-8") as handle: + handle.write("claim=false\n") + raise SystemExit(0) + + claim_dir = Path(os.environ["RUNNER_TEMP"]) / "cwl-agent-invocation-ledger" + claim_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + claim = { + "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"]), + "head_sha": os.environ["PR_HEAD_SHA"], + "invocation_key": os.environ["INVOCATION_KEY"], + "pr_number": int(os.environ["PR_NUMBER"]), + "repository": os.environ["TARGET_REPOSITORY"], + } + (claim_dir / "claim.json").write_text( + json.dumps(claim, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + with output_path.open("a", encoding="utf-8") as handle: + handle.write("claim=true\n") + PYTHON + + - name: Claim exact invocation in the durable artifact ledger + if: steps.ledger.outputs.claim == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cwl-agent-invocation-${{ env.INVOCATION_KEY }} + path: ${{ runner.temp }}/cwl-agent-invocation-ledger/claim.json + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + include-hidden-files: false + + - name: Forward once to the authoritative Noema workflow + 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: "noema-review", + 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, + 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 - diff --git a/.github/workflows/agent-mention-opencode-dispatch.yml b/.github/workflows/agent-mention-opencode-dispatch.yml new file mode 100644 index 000000000..160b4723d --- /dev/null +++ b/.github/workflows/agent-mention-opencode-dispatch.yml @@ -0,0 +1,221 @@ +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:${{ + github.event.client_payload.agent_invocation_key }}] + +on: + repository_dispatch: + types: [agent-mention-opencode] + +concurrency: + group: agent-mention-opencode-${{ github.event.client_payload.agent_invocation_key || github.run_id }} + cancel-in-progress: false + queue: max + +permissions: + contents: read + +jobs: + validate-and-forward: + if: github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: write + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_AGENT: "opencode-agent" + PAYLOAD_AGENT: ${{ github.event.client_payload.requested_agent || '' }} + 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 + 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 + echo "::error::Rejected malformed or mismatched OpenCode agent invocation payload." + exit 1 + fi + + python3 - <<'PYTHON' + import hashlib + import hmac + import json + import os + + 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", + }, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + expected = hashlib.sha256(canonical).hexdigest() + if not hmac.compare_digest(expected, os.environ["INVOCATION_KEY"]): + raise SystemExit("invocation key does not match canonical payload") + PYTHON + + - name: Inspect exact-name Actions artifact ledger + id: ledger + run: | + set -euo pipefail + LEDGER_ARTIFACT_NAME="cwl-agent-invocation-${INVOCATION_KEY}" + export LEDGER_ARTIFACT_NAME + echo "LEDGER_ARTIFACT_NAME=$LEDGER_ARTIFACT_NAME" >>"$GITHUB_ENV" + response_file="${RUNNER_TEMP}/agent-mention-artifacts.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts" \ + -X GET \ + -f "name=${LEDGER_ARTIFACT_NAME}" \ + -f "per_page=100" >"$response_file" + python3 - "$response_file" <<'PYTHON' + import json + import os + from pathlib import Path + import sys + + response_path = Path(sys.argv[1]) + payload = json.loads(response_path.read_text(encoding="utf-8")) + expected_name = os.environ["LEDGER_ARTIFACT_NAME"] + if not isinstance(payload, dict): + raise SystemExit("artifact response must be an object") + total_count = payload.get("total_count") + artifacts = payload.get("artifacts") + if type(total_count) is not int or total_count < 0: + raise SystemExit("artifact response has an invalid total_count") + if not isinstance(artifacts, list): + raise SystemExit("artifact response has an invalid artifacts collection") + if total_count != len(artifacts): + raise SystemExit("artifact response is truncated or inconsistent") + live = False + for artifact in artifacts: + if not isinstance(artifact, dict): + raise SystemExit("artifact response contains a non-object record") + artifact_id = artifact.get("id") + name = artifact.get("name") + expired = artifact.get("expired") + if type(artifact_id) is not int or artifact_id < 1: + raise SystemExit("artifact response contains an invalid artifact id") + if not isinstance(name, str) or name != expected_name: + raise SystemExit("artifact response contains a mismatched artifact name") + if type(expired) is not bool: + raise SystemExit("artifact response contains an invalid expired flag") + live = live or not expired + + output_path = Path(os.environ["GITHUB_OUTPUT"]) + if live: + with output_path.open("a", encoding="utf-8") as handle: + handle.write("claim=false\n") + raise SystemExit(0) + + claim_dir = Path(os.environ["RUNNER_TEMP"]) / "cwl-agent-invocation-ledger" + claim_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + claim = { + "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"], + "invocation_key": os.environ["INVOCATION_KEY"], + "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_dir / "claim.json").write_text( + json.dumps(claim, ensure_ascii=True, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + with output_path.open("a", encoding="utf-8") as handle: + handle.write("claim=true\n") + PYTHON + + - name: Claim exact invocation in the durable artifact ledger + if: steps.ledger.outputs.claim == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cwl-agent-invocation-${{ env.INVOCATION_KEY }} + path: ${{ runner.temp }}/cwl-agent-invocation-ledger/claim.json + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + include-hidden-files: false + + - name: Forward once to the authoritative review-only scheduler + 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 - diff --git a/.github/workflows/agent-mention-router-quality-ci.yml b/.github/workflows/agent-mention-router-quality-ci.yml new file mode 100644 index 000000000..f69cdce10 --- /dev/null +++ b/.github/workflows/agent-mention-router-quality-ci.yml @@ -0,0 +1,114 @@ +name: Agent Mention Router Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/agent-mention-router.yml" + - ".github/workflows/agent-mention-router-quality-ci.yml" + - ".github/workflows/agent-mention-noema-dispatch.yml" + - ".github/workflows/agent-mention-opencode-dispatch.yml" + - "docs/automation/review-agent-comment-invocation.md" + - "scripts/ci/agent_mention_router.py" + - "scripts/ci/agent_mention_sweep.py" + - "tests/test_agent_mention_*.py" + - "tests/test_pr_review_fix_scheduler_coverage.py" + - "requirements-opencode-review-ci-hashes.txt" + push: + branches: [main] + paths: + - ".github/workflows/agent-mention-router.yml" + - ".github/workflows/agent-mention-router-quality-ci.yml" + - ".github/workflows/agent-mention-noema-dispatch.yml" + - ".github/workflows/agent-mention-opencode-dispatch.yml" + - "docs/automation/review-agent-comment-invocation.md" + - "scripts/ci/agent_mention_router.py" + - "scripts/ci/agent_mention_sweep.py" + - "tests/test_agent_mention_*.py" + - "tests/test_pr_review_fix_scheduler_coverage.py" + - "requirements-opencode-review-ci-hashes.txt" + +concurrency: + group: agent-mention-router-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + quality: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout exact head with comparison history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Determine exact changed range + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha || '' }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} + PUSH_BEFORE_SHA: ${{ github.event.before || '' }} + PUSH_HEAD_SHA: ${{ github.sha }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_sha="$PR_BASE_SHA" + head_sha="$PR_HEAD_SHA" + diff_range="${base_sha}...${head_sha}" + else + base_sha="$PUSH_BEFORE_SHA" + head_sha="$PUSH_HEAD_SHA" + if [[ "$base_sha" =~ ^0+$ ]]; then + base_sha="$(git rev-parse "${head_sha}^")" + fi + diff_range="${base_sha}..${head_sha}" + fi + git cat-file -e "${base_sha}^{commit}" + git cat-file -e "${head_sha}^{commit}" + { + echo "CHANGE_BASE_SHA=$base_sha" + echo "CHANGE_HEAD_SHA=$head_sha" + echo "CHANGE_DIFF_RANGE=$diff_range" + } >>"$GITHUB_ENV" + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + - name: Install exact hash-locked tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + - name: Run complete repository suite and bounded branch coverage + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/agent-mention-coveragerc" <<'EOF' + [run] + branch = True + include = + scripts/ci/agent_mention_router.py + scripts/ci/agent_mention_sweep.py + [report] + fail_under = 100 + show_missing = True + EOF + export COVERAGE_RCFILE="${RUNNER_TEMP}/agent-mention-coveragerc" + python -m coverage erase + python -m coverage run -m pytest -q + 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 + python -m compileall -q scripts/ci tests + git diff --check "$CHANGE_DIFF_RANGE" diff --git a/.github/workflows/agent-mention-router.yml b/.github/workflows/agent-mention-router.yml new file mode 100644 index 000000000..f14667a93 --- /dev/null +++ b/.github/workflows/agent-mention-router.yml @@ -0,0 +1,185 @@ +name: Review Agent Mention Router + +on: + issue_comment: + types: [created] + schedule: + - cron: "*/5 * * * *" + +concurrency: + group: review-agent-mention-router-${{ github.repository }} + cancel-in-progress: false + +# Organization required-workflow rules do not propagate issue_comment events +# into sibling repositories. Keep the workflow default read-only; each bounded +# job declares only the writes it actually needs. +permissions: + contents: read + +jobs: + route-local-agent-mention: + if: >- + github.repository == 'ContextualWisdomLab/.github' + && github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.type != 'Bot' + && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + && ( + contains(github.event.comment.body, '@cwl-noema-review') + || contains(github.event.comment.body, '@opencode-agent') + ) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: write + issues: write + pull-requests: read + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY_TOKEN: ${{ github.token }} + AGENT_DISPATCH_TOKEN: ${{ github.token }} + OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + steps: + - name: Check out trusted default-branch router + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Resolve immutable pull-request head + env: + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number }} + SOURCE_EVENT_PATH: ${{ github.event_path }} + run: | + set -euo pipefail + pr_json="$(gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}")" + jq \ + --argjson pull_request "$pr_json" \ + '. + {pull_request: $pull_request}' \ + "$SOURCE_EVENT_PATH" >"${RUNNER_TEMP}/agent-mention-event.json" + + - name: Route trusted local agent mention + run: >- + python3 scripts/ci/agent_mention_router.py + --event-path "${RUNNER_TEMP}/agent-mention-event.json" + + sweep-organization-agent-mentions: + if: >- + github.repository == 'ContextualWisdomLab/.github' + && github.event_name == 'schedule' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read + contents: write + id-token: write + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + LOOKBACK_HOURS: ${{ vars.AGENT_MENTION_LOOKBACK_HOURS || '168' }} + MAX_DISPATCHES: ${{ vars.AGENT_MENTION_MAX_DISPATCHES || '20' }} + DRY_RUN: "false" + steps: + - name: Exchange OpenCode app token for sibling-repository comments + id: sweep_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + USER_TOKEN_CONFIGURED: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' }} + run: | + set -euo pipefail + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + if [ "$USER_TOKEN_CONFIGURED" = "true" ]; then + echo "A configured cross-repository user token takes precedence." + mark_unavailable + exit 0 + fi + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + if ! oidc_response="$( + curl -fsS --connect-timeout 10 --max-time 30 \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + if ! token_response="$( + curl -fsS --connect-timeout 10 --max-time 30 \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + echo "::add-mask::$app_token" + echo "available=true" >>"$GITHUB_OUTPUT" + echo "SWEEP_APP_TOKEN=$app_token" >>"$GITHUB_ENV" + + - name: Check out trusted central router + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Sweep recent organization PR comments + env: + PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} + AGENT_DISPATCH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -n "$PR_REVIEW_MERGE_TOKEN" ]; then + TARGET_REPOSITORY_TOKEN="$PR_REVIEW_MERGE_TOKEN" + TARGET_REPOSITORY_SOURCE="organization" + elif [ -n "$OPENCODE_APPROVE_TOKEN" ]; then + TARGET_REPOSITORY_TOKEN="$OPENCODE_APPROVE_TOKEN" + TARGET_REPOSITORY_SOURCE="organization" + else + TARGET_REPOSITORY_TOKEN="${SWEEP_APP_TOKEN:-}" + TARGET_REPOSITORY_SOURCE="${TARGET_REPOSITORY_TOKEN:+installation}" + fi + export TARGET_REPOSITORY_TOKEN + if [ -z "$TARGET_REPOSITORY_TOKEN" ] || [ -z "$TARGET_REPOSITORY_SOURCE" ]; then + echo "::error::Agent mention sweep requires PR_REVIEW_MERGE_TOKEN, OPENCODE_APPROVE_TOKEN, or the OpenCode app token exchange." + exit 1 + fi + args=( + --organization ContextualWisdomLab + --repository-source "$TARGET_REPOSITORY_SOURCE" + --lookback-hours "$LOOKBACK_HOURS" + --max-dispatches "$MAX_DISPATCHES" + ) + if [ "$DRY_RUN" = "true" ]; then + args+=(--dry-run) + fi + python3 scripts/ci/agent_mention_sweep.py "${args[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index e601de81b..c993bf7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,11 @@ Semantic Versioning where the repository publishes a release. ### Added +- Added a trusted pull-request comment router for `@cwl-noema-review` and review-only `@opencode-agent` dispatches, with an organization sweep, exact-head receipts, repository allowlisting, fixed runners, immutable checkout pins, and a permanent 100% statement/branch/docstring quality gate. - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. ### Fixed +- Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. - Bound both trusted-uv quality jobs to `github.event.pull_request.head.sha` and added a permanent two-checkout regression contract so exact-head compatibility, coverage, docstring, and compilation claims cannot silently measure GitHub's generated pull-request merge revision. - Made Strix treat only a single LiteLLM provider-error line containing NVIDIA NIM context and model-catalog 404 evidence as cross-model fallback evidence, rejecting cross-line signal assembly and provider-like target source literals; moved the public default to Nemotron 3 Super 120B and added a second NVIDIA hosted candidate before GitHub Models without neutralizing reported vulnerabilities. diff --git a/docs/automation/review-agent-comment-invocation.md b/docs/automation/review-agent-comment-invocation.md new file mode 100644 index 000000000..51c84dcde --- /dev/null +++ b/docs/automation/review-agent-comment-invocation.md @@ -0,0 +1,90 @@ +# Review-agent comment invocation + +Updated: 2026-08-06 + +## Purpose + +Trusted ContextualWisdomLab maintainers can invoke the existing review planes from a pull-request conversation: + +- `@cwl-noema-review` requests the independent Noema review. +- `@opencode-agent` requests a bounded current-head OpenCode review only; the invocation itself disables branch updates, automatic merge, and direct merge. + +The router never checks out or executes pull-request-controlled code. It reads live PR metadata, binds the request to the current head SHA and base branch, and dispatches the already deployed central workflows in `ContextualWisdomLab/.github`. + +## Architecture + +GitHub organization ruleset workflows support `pull_request`, `pull_request_target`, and `merge_group`, but not `issue_comment`. Separately, an `issue_comment` workflow runs only when that workflow file exists on the commented repository's default branch. Therefore, a workflow stored only in the central `.github` repository cannot directly receive comments created in sibling repositories. + +The implementation uses two bounded paths: + +1. **Local fast path.** Comments on `ContextualWisdomLab/.github` trigger `issue_comment` immediately. +2. **Organization sweep.** Every five minutes, the central workflow enumerates repositories visible to its cross-repository credential, finds recently updated open PRs and recent comments, validates trusted exact mentions, and consults the central exact-name Actions artifact ledger before queuing work. + +Each requested agent receives a deterministic invocation key containing the target repository, PR number, exact head SHA, base branch, requested agent, source comment ID, and requesting actor. Each agent-specific wrapper reconstructs the same canonical JSON from its validated payload, hashes it with SHA-256, and compares the result in constant time with the supplied key. Altering any bound field while retaining a syntactically valid key therefore fails closed. + +The exact-name Actions artifact ledger uses `cwl-agent-invocation-` as the artifact name. The router queries GitHub's repository artifact endpoint with the server-side exact `name` filter, validates the complete response, and treats any live exact-name artifact as durable dispatch evidence. This avoids depending on filtered workflow-run enumeration, which GitHub caps at 1,000 results even when pagination is requested. + +Wrapper workflows use the verified key in their non-cancelling concurrency group, inspect the exact artifact name, and upload a 30-day immutable claim before forwarding to the authoritative review plane. Exact-key concurrency serializes duplicate wrapper runs. If a prior live claim exists, the wrapper performs no forward. If artifact visibility is delayed and a duplicate upload collides, the upload fails before the forwarding step, so the control plane fails closed rather than forwarding twice. Completed or failed authoritative work remains claimed for the retention window; a maintainer who needs a new attempt creates a new trusted source comment, which produces a distinct key. + +Target-repository acknowledgement comments and reactions are user-experience signals only. They are not dispatch authority because repository writers, bot identities, or credential rotation could otherwise forge or invalidate a marker. A failed acknowledgement cannot cause completed agent work to be redispatched. + +A user or fine-grained token enumerates organization repositories. When the OpenCode GitHub App installation token is the available credential, the sweep instead uses GitHub's installation-repositories endpoint, which returns only repositories accessible to that installation. This avoids depending on an organization-issues endpoint whose documented fine-grained token support is user-token-oriented. + +This preserves the central MSA boundary without copying privileged workflow code into every product repository. + +## Trust and permission boundary + +- Accepted comment associations: `OWNER`, `MEMBER`, and `COLLABORATOR`. +- Bot comments, ordinary contributors, issue comments outside PRs, closed PRs, malformed metadata, and lookalike handles fail closed. +- Historical, duplicate, rejected, or already-ledgered requests do not consume the bounded new-work dispatch budget. +- The workflow default token is read-only. +- The local routing job receives job-scoped `actions: read`, `contents: write`, `issues: write`, and `pull-requests: read`. +- The organization sweep receives job-scoped `actions: read`, `contents: write`, and `id-token: write`. +- The two agent-specific wrapper workflows receive only job-scoped `actions: read` and `contents: write`; their workflow defaults remain `contents: read`. +- `actions: read` permits exact-name artifact inventory checks. Artifact upload uses the workflow artifact service and is pinned to immutable `actions/upload-artifact` v7.0.1. +- `contents: write` is intentionally retained only on jobs that call GitHub's create-repository-dispatch endpoint. GitHub documents that endpoint as requiring Contents repository permission at write level. Removing it would disable the bounded central dispatch path; broad workflow-default write access is not granted. +- The organization sweep uses the established cross-repository credential chain for reading target comments, while the central repository's own short-lived job token dispatches the central workflows. +- OpenCode dispatch is restricted to the exact `OPENCODE_REPOSITORY_DISPATCH_TARGETS` allowlist. +- An invocation cannot merge: `enable_auto_merge=false`, `update_branches=false`, and `merge_mode=disabled` are explicit in the dispatch payload. +- Every dispatch is bound to live PR number, current head SHA, base branch, source comment, requested agent, and requesting actor metadata fetched or validated immediately before dispatch. +- Router jobs use the fixed `ubuntu-24.04` runner and an immutable `actions/checkout` v7.0.1 commit pin; checkout credentials are not persisted. +- A branch-selectable `workflow_dispatch` trigger is intentionally absent. This prevents a repository writer from choosing an unreviewed branch version of the central router while the job holds dispatch permissions. + +## Operator controls + +- `AGENT_MENTION_LOOKBACK_HOURS`: default `168`, allowed range 1–720. +- `AGENT_MENTION_MAX_DISPATCHES`: default `20`, allowed range 1–100. The bound counts source requests that actually queue at least one new agent, not historical no-ops. +- Durable invocation claims use 30-day artifact retention. A new source comment creates a new invocation key when an intentional retry is required. +- Operators request immediate work by writing an exact trusted mention on the target pull request; otherwise, the five-minute protected-default-branch sweep processes it. +- The sweep fails visibly when no cross-repository credential is available. +- `PR_REVIEW_MERGE_TOKEN` or `OPENCODE_APPROVE_TOKEN` takes precedence. Otherwise, the workflow exchanges its OIDC token for the existing OpenCode installation token and enumerates that installation's repositories. + +## Verification and rollback + +The permanent quality workflow runs the deterministic router, sweep, exact-name artifact ledger, wrapper, receipt-authority, and workflow-contract suites under Python 3.14 and requires 100% production statement coverage, branch coverage, and public docstring coverage. It also compiles the Python files and checks the final diff for whitespace errors. A permanent regression contract also rejects the transient PR-specific branch-writer workflows and repair helpers used during development, so they cannot ship with the control plane. + +### Activation gate + +The router is inactive until its workflows and helper code are merged into the protected default branch. A materialization, predecessor, cancelled, queued, or stale-head run is not activation evidence. Production activation requires the exact final head to pass the permanent quality workflow, security and supply-chain checks, current-head automated review, an independent approval, unresolved-thread policy, and branch protection without bypass. + +Rollback is deletion of the four mention-router workflows, the two Python helpers, and their focused tests. Existing Noema and OpenCode review workflows remain independently invocable and authoritative; the router does not own reviewer identity, credentials, verdict acceptance, approval, merge, or release. + +## References + +GitHub. (n.d.). *Available rules for rulesets*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets + +GitHub. (n.d.). *Events that trigger workflows*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/enterprise-cloud@latest/actions/reference/workflows-and-actions/events-that-trigger-workflows + +GitHub. (n.d.). *GITHUB_TOKEN*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/actions/concepts/security/github_token + +GitHub. (n.d.). *REST API endpoints for GitHub Actions artifacts*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/rest/actions/artifacts + +GitHub. (n.d.). *REST API endpoints for GitHub Actions: List workflow runs for a workflow*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/rest/actions/workflow-runs#list-workflow-runs-for-a-workflow + +GitHub. (n.d.). *REST API endpoints for GitHub App installations*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/rest/apps/installations + +GitHub. (n.d.). *REST API endpoints for issues*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/rest/issues/issues + +GitHub. (n.d.). *REST API endpoints for repositories: Create a repository dispatch event*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/rest/repos/repos#create-a-repository-dispatch-event + +GitHub. (n.d.). *Store and share data with workflow artifacts*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/actions/tutorials/store-and-share-data diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py new file mode 100644 index 000000000..bdb8ac3db --- /dev/null +++ b/scripts/ci/agent_mention_router.py @@ -0,0 +1,564 @@ +#!/usr/bin/env python3 +"""Route trusted pull-request comment mentions to CWL review agents.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +from dataclasses import dataclass +from typing import Any, Sequence + +CENTRAL_AUTOMATION_REPOSITORY = "ContextualWisdomLab/.github" +TRUSTED_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"}) +MENTION_PATTERNS = { + "cwl-noema-review": re.compile( + r"(?") + + +@dataclass(frozen=True) +class MentionRequest: + """Validated agent-mention request extracted from one issue comment event.""" + + repository: str + pull_request_number: int + pull_request_head_sha: str + pull_request_base_branch: str + comment_id: int + actor: str + agents: tuple[str, ...] + pull_request_base_sha: str = "" + + +class GitHubClient: + """Small token-bound wrapper around ``gh api`` for JSON requests.""" + + def __init__(self, token: str) -> None: + """Initialize a client with one non-empty GitHub credential.""" + + if not token: + raise ValueError("GitHub token is required") + self._token = token + + def request( + self, + args: Sequence[str], + *, + input_payload: dict[str, Any] | None = None, + ) -> Any: + """Execute ``gh api`` and decode its optional JSON response.""" + + command = ["gh", "api", *args] + if input_payload is not None: + command.extend(["--input", "-"]) + environment = os.environ.copy() + environment["GH_TOKEN"] = self._token + completed = subprocess.run( + command, + input=None if input_payload is None else json.dumps(input_payload), + text=True, + capture_output=True, + check=False, + env=environment, + ) + return_code = int(getattr(completed, "returncode", 0)) + if return_code: + diagnostic = " ".join( + str(getattr(completed, "stderr", "") or "").split() + ) + if not diagnostic: + diagnostic = "no stderr output" + raise RuntimeError( + f"gh api failed with exit code {return_code}: {diagnostic[:2000]}" + ) + output = completed.stdout.strip() + return None if not output else json.loads(output) + + +def exact_mentions(body: str) -> tuple[str, ...]: + """Return supported exact agent mentions in deterministic order.""" + + return tuple( + name for name, pattern in MENTION_PATTERNS.items() if pattern.search(body) + ) + + +def receipt_marker(comment_id: int) -> str: + """Return the hidden target-comment acknowledgement marker.""" + + if comment_id < 1: + raise ValueError("comment id must be positive") + return f"" + + +def processed_comment_ids(comments: Sequence[dict[str, Any]]) -> frozenset[int]: + """Extract local receipts authored by the trusted GitHub Actions bot only. + + These target-repository comments are a local optimization and user-facing + acknowledgement. Central exact-name Actions artifacts remain authoritative + for cross-repository dispatch idempotency because PAT and installation-token + identities can rotate and target-repository actors can be spoofed. + """ + + processed: set[int] = set() + for comment in comments: + user = comment.get("user") or {} + if ( + str(user.get("login") or "").casefold() + != "github-actions[bot]" + or str(user.get("type") or "").casefold() != "bot" + ): + continue + body = str(comment.get("body") or "") + processed.update(int(match) for match in RECEIPT_RE.findall(body)) + return frozenset(processed) + + +def parse_event(event: dict[str, Any]) -> MentionRequest | None: + """Return a validated mention request, or ``None`` for an ignored event.""" + + issue = event.get("issue") or {} + comment = event.get("comment") or {} + repository = event.get("repository") or {} + pull_request = event.get("pull_request") or {} + if not issue.get("pull_request"): + return None + if pull_request.get("state") != "open": + return None + if str(comment.get("user", {}).get("type", "")).casefold() == "bot": + return None + if str(comment.get("author_association", "")).upper() not in TRUSTED_ASSOCIATIONS: + return None + agents = exact_mentions(str(comment.get("body") or "")) + if not agents: + return None + + repository_name = str(repository.get("full_name") or "").strip() + actor = str(comment.get("user", {}).get("login") or "").strip() + head_sha = str(pull_request.get("head", {}).get("sha") or "").strip() + base = pull_request.get("base") or {} + base_branch = str(base.get("ref") or "").strip() + base_sha = str(base.get("sha") or "").strip() + number = issue.get("number") + comment_id = comment.get("id") + if not REPOSITORY_RE.fullmatch(repository_name): + raise ValueError( + "agent mentions are limited to ContextualWisdomLab repositories" + ) + if not isinstance(number, int) or number < 1: + raise ValueError("pull request number is missing or invalid") + if not isinstance(comment_id, int) or comment_id < 1: + raise ValueError("comment id is missing or invalid") + if comment_id in processed_comment_ids(event.get("conversation_comments") or ()): + return None + if not HEAD_SHA_RE.fullmatch(head_sha): + raise ValueError("pull request head SHA is missing or invalid") + if not BASE_BRANCH_RE.fullmatch(base_branch): + raise ValueError("pull request base branch is missing or invalid") + if not HEAD_SHA_RE.fullmatch(base_sha): + raise ValueError("pull request base SHA is missing or invalid") + if not ACTOR_RE.fullmatch(actor): + raise ValueError("comment actor is missing or invalid") + return MentionRequest( + repository_name, + number, + head_sha.lower(), + base_branch, + comment_id, + actor, + agents, + pull_request_base_sha=base_sha.lower(), + ) + + +def parse_repository_allowlist(raw_value: str) -> frozenset[str]: + """Parse and validate a comma-separated exact repository allowlist.""" + + repositories = frozenset( + part.strip() for part in raw_value.split(",") if part.strip() + ) + invalid = sorted( + repository + for repository in repositories + if not REPOSITORY_RE.fullmatch(repository) + ) + if invalid: + raise ValueError(f"invalid repository allowlist entries: {', '.join(invalid)}") + return repositories + + +def eligible_agents( + request: MentionRequest, + *, + opencode_allowlist: frozenset[str], +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Partition requested agents into dispatchable and rejected handles.""" + + dispatchable: list[str] = [] + rejected: list[str] = [] + if "cwl-noema-review" in request.agents: + dispatchable.append("cwl-noema-review") + if "opencode-agent" in request.agents: + normalized_allowlist = {entry.casefold() for entry in opencode_allowlist} + if request.repository.casefold() in normalized_allowlist: + dispatchable.append("opencode-agent") + else: + rejected.append("opencode-agent") + return tuple(dispatchable), tuple(rejected) + + +def agent_invocation_claim( + request: MentionRequest, + agent: str, +) -> dict[str, object]: + """Return the complete canonical security claim for one agent dispatch.""" + + if agent not in MENTION_PATTERNS: + raise ValueError(f"unsupported agent: {agent}") + 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 claim + + +def agent_invocation_key(request: MentionRequest, agent: str) -> str: + """Return a deterministic opaque key for one exact agent invocation. + + The key binds repository, pull request, exact head and base identities, + requested agent, source comment, requesting actor, and every downstream + behavior flag. It contains no credential or provider response and is safe + to place in workflow and artifact names. + """ + + canonical = json.dumps( + agent_invocation_claim(request, agent), + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def agent_invocation_marker(request: MentionRequest, agent: str) -> str: + """Return the exact human-readable workflow-run marker for one invocation.""" + + return f"[cwl-agent-invocation:{agent_invocation_key(request, agent)}]" + + +def agent_ledger_artifact_name(request: MentionRequest, agent: str) -> str: + """Return the exact-name durable artifact ledger key for one invocation.""" + + return f"{LEDGER_ARTIFACT_PREFIX}{agent_invocation_key(request, agent)}" + + +def _artifact_records( + value: Any, + *, + expected_name: str, +) -> tuple[dict[str, Any], ...]: + """Validate one exact-name repository artifact response and return live claims. + + The server-side ``name`` filter makes this response directly addressable by + invocation key. Any malformed, mismatched, truncated, or ambiguous response + fails closed rather than being interpreted as permission to redispatch. + """ + + if not isinstance(value, dict): + raise ValueError("artifact response must be an object") + total_count = value.get("total_count") + artifacts = value.get("artifacts") + if type(total_count) is not int or total_count < 0: + raise ValueError("artifact response has an invalid total_count") + if not isinstance(artifacts, list): + raise ValueError("artifact response has an invalid artifacts collection") + if total_count != len(artifacts): + raise ValueError("artifact response is truncated or internally inconsistent") + + live: list[dict[str, Any]] = [] + for artifact in artifacts: + if not isinstance(artifact, dict): + raise ValueError("artifact response contains a non-object record") + artifact_id = artifact.get("id") + name = artifact.get("name") + expired = artifact.get("expired") + if type(artifact_id) is not int or artifact_id < 1: + raise ValueError("artifact response contains an invalid artifact id") + if not isinstance(name, str) or name != expected_name: + raise ValueError("artifact response contains a mismatched artifact name") + if type(expired) is not bool: + raise ValueError("artifact response contains an invalid expired flag") + if not expired: + live.append(artifact) + return tuple(live) + + +def dispatched_agents( + request: MentionRequest, + dispatch_client: GitHubClient, + agents: Sequence[str] | None = None, + *, + ledger_artifact_cache: dict[str, bool] | None = None, +) -> frozenset[str]: + """Return agents with a durable exact-name artifact for this invocation. + + Each candidate uses the repository artifact endpoint's exact ``name`` filter, + avoiding workflow-run enumeration and its filtered-result cap. A caller-owned + cache bounds repeated API work during one local route or organization sweep. + """ + + candidates = tuple(request.agents if agents is None else agents) + observed: set[str] = set() + artifact_cache = ( + ledger_artifact_cache if ledger_artifact_cache is not None else {} + ) + for agent in candidates: + artifact_name = agent_ledger_artifact_name(request, agent) + if artifact_name not in artifact_cache: + response = dispatch_client.request( + [ + LEDGER_ARTIFACTS_ENDPOINT, + "-X", + "GET", + "-f", + f"name={artifact_name}", + "-f", + "per_page=100", + ] + ) + artifact_cache[artifact_name] = bool( + _artifact_records(response, expected_name=artifact_name) + ) + if artifact_cache[artifact_name]: + observed.add(agent) + return frozenset(observed) + + +def noema_payload(request: MentionRequest) -> dict[str, Any]: + """Return the durable Noema wrapper dispatch request body.""" + + agent = "cwl-noema-review" + return { + "event_type": "agent-mention-noema", + "client_payload": { + "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": agent, + "agent_invocation_key": agent_invocation_key(request, agent), + "requested_by": request.actor, + "source_comment_id": request.comment_id, + }, + } + + +def opencode_payload(request: MentionRequest) -> dict[str, Any]: + """Return the durable review-only OpenCode wrapper dispatch body.""" + + agent = "opencode-agent" + claim = agent_invocation_claim(request, agent) + return { + "event_type": "agent-mention-opencode", + "client_payload": { + "target_repository": request.repository, + "pr_number": request.pull_request_number, + "pr_head_sha": request.pull_request_head_sha, + "pr_base_sha": request.pull_request_base_sha, + "base_branch": request.pull_request_base_branch, + "trigger_reviews": claim["trigger_reviews"], + "review_dispatch_limit": claim["review_dispatch_limit"], + "enable_auto_merge": claim["enable_auto_merge"], + "update_branches": claim["update_branches"], + "merge_mode": claim["merge_mode"], + "requested_agent": agent, + "agent_invocation_key": agent_invocation_key(request, agent), + "requested_by": request.actor, + "source_comment_id": request.comment_id, + }, + } + + +def dispatch_request( + request: MentionRequest, + *, + target_client: GitHubClient, + dispatch_client: GitHubClient, + opencode_allowlist: frozenset[str], + dry_run: bool = False, + ledger_artifact_cache: dict[str, bool] | None = None, +) -> tuple[str, ...]: + """Dispatch missing agents and acknowledge only newly queued work.""" + + dispatchable, rejected = eligible_agents( + request, + opencode_allowlist=opencode_allowlist, + ) + if dry_run: + handles = tuple(f"@{agent}" for agent in dispatchable) + print( + "DRY-RUN agent mention " + f"repo={request.repository} pr={request.pull_request_number} " + f"head={request.pull_request_head_sha} " + f"dispatch={','.join(dispatchable) or 'none'} " + f"reject={','.join(rejected) or 'none'}" + ) + return handles + + existing = dispatched_agents( + request, + dispatch_client, + dispatchable, + ledger_artifact_cache=ledger_artifact_cache, + ) + missing = tuple(agent for agent in dispatchable if agent not in existing) + handles = tuple(f"@{agent}" for agent in missing) + if not missing: + if rejected: + print( + "Rejected agent mention without target mutation " + f"repo={request.repository} pr={request.pull_request_number} " + f"comment={request.comment_id} " + f"agents={','.join(rejected)}" + ) + return () + + dispatch_endpoint = f"repos/{CENTRAL_AUTOMATION_REPOSITORY}/dispatches" + if "cwl-noema-review" in missing: + agent = "cwl-noema-review" + dispatch_client.request( + [dispatch_endpoint, "-X", "POST"], + input_payload=noema_payload(request), + ) + if ledger_artifact_cache is not None: + ledger_artifact_cache[agent_ledger_artifact_name(request, agent)] = True + if "opencode-agent" in missing: + agent = "opencode-agent" + dispatch_client.request( + [dispatch_endpoint, "-X", "POST"], + input_payload=opencode_payload(request), + ) + if ledger_artifact_cache is not None: + 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"}, + ) + status_parts = [f"Queued {' and '.join(handles)}"] + existing_handles = tuple( + f"@{agent}" for agent in dispatchable if agent in existing + ) + if existing_handles: + status_parts.append( + f"Already queued {' and '.join(existing_handles)} on this exact request" + ) + if rejected: + rejected_handles = " and ".join(f"@{agent}" for agent in rejected) + status_parts.append( + f"Rejected {rejected_handles}: repository is absent from " + "OPENCODE_REPOSITORY_DISPATCH_TARGETS" + ) + acknowledgement = ( + f"{receipt_marker(request.comment_id)}\n" + f"{' ; '.join(status_parts)} for PR #{request.pull_request_number} at head " + f"`{request.pull_request_head_sha}`. Central exact-name Actions artifacts " + "are the durable dispatch ledger; existing review workflows remain " + "authoritative for the final verdict and failure evidence." + ) + target_client.request( + [ + f"{target_api}/issues/{request.pull_request_number}/comments", + "-X", + "POST", + ], + input_payload={"body": acknowledgement}, + ) + return handles + + +def load_event(path: str) -> dict[str, Any]: + """Load and validate a GitHub event JSON document.""" + + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ValueError("GitHub event payload must be a JSON object") + return value + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the mention router for one enriched GitHub issue-comment event.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--event-path", default=os.environ.get("GITHUB_EVENT_PATH", "")) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + if not args.event_path: + parser.error("--event-path or GITHUB_EVENT_PATH is required") + request = parse_event(load_event(args.event_path)) + if request is None: + print("No trusted pull-request agent mention found; nothing to dispatch.") + return 0 + target_token = os.environ.get("TARGET_REPOSITORY_TOKEN") or os.environ.get( + "GH_TOKEN", "" + ) + dispatch_token = os.environ.get("AGENT_DISPATCH_TOKEN") or os.environ.get( + "GH_TOKEN", "" + ) + allowlist = parse_repository_allowlist( + os.environ.get("OPENCODE_REPOSITORY_DISPATCH_TARGETS", "") + ) + dispatch_request( + request, + target_client=GitHubClient(target_token), + dispatch_client=GitHubClient(dispatch_token), + opencode_allowlist=allowlist, + dry_run=args.dry_run, + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/agent_mention_sweep.py b/scripts/ci/agent_mention_sweep.py new file mode 100644 index 000000000..9b64909a0 --- /dev/null +++ b/scripts/ci/agent_mention_sweep.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Sweep recent CWL pull-request comments for trusted review-agent mentions.""" + +from __future__ import annotations + +import argparse +import os +import re +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Callable, Iterator, Sequence + +from agent_mention_router import ( + GitHubClient, + MentionRequest, + dispatch_request, + parse_event, + parse_repository_allowlist, +) + +ORG_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +REPOSITORY_RE = re.compile(r"^ContextualWisdomLab/[A-Za-z0-9_.-]+$") +REPOSITORY_SOURCES = frozenset({"organization", "installation"}) + + +@dataclass +class SweepMetrics: + """Mutable operational counters returned to the CLI boundary.""" + + failures: int = 0 + + +def parse_timestamp(value: str) -> datetime: + """Parse one GitHub ISO-8601 timestamp into timezone-aware UTC.""" + + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (AttributeError, ValueError) as exc: + raise ValueError("invalid GitHub timestamp") from exc + if parsed.tzinfo is None: + raise ValueError("GitHub timestamp must be timezone-aware") + return parsed.astimezone(timezone.utc) + + +def cutoff_timestamp(lookback_hours: int, *, now: datetime | None = None) -> str: + """Return an ISO-8601 UTC cutoff for the bounded comment lookback window.""" + + if lookback_hours < 1 or lookback_hours > 24 * 30: + raise ValueError("lookback hours must be between 1 and 720") + current = now or datetime.now(timezone.utc) + if current.tzinfo is None: + raise ValueError("current time must be timezone-aware") + cutoff = current.astimezone(timezone.utc) - timedelta(hours=lookback_hours) + return cutoff.replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def flatten_pages( + value: Any, + *, + collection_key: str | None = None, +) -> list[dict[str, Any]]: + """Flatten ``gh api --paginate --slurp`` output into object records.""" + + if value is None: + raise ValueError("paginated GitHub response is empty") + if ( + collection_key is None + and isinstance(value, list) + and all(isinstance(record, dict) for record in value) + ): + return list(value) + pages = value if isinstance(value, list) else [value] + records: list[dict[str, Any]] = [] + for page in pages: + if collection_key and not isinstance(page, dict): + raise ValueError("paginated GitHub response page is not an object") + collection = page.get(collection_key, []) if collection_key else page + if not isinstance(collection, list): + raise ValueError("paginated GitHub response is not a list") + if not all(isinstance(record, dict) for record in collection): + raise ValueError( + "paginated GitHub response contains a non-object record" + ) + records.extend(collection) + return records + + +def list_accessible_repositories( + client: GitHubClient, + *, + organization: str, + repository_source: str, +) -> list[str]: + """List active organization repositories visible to the selected token type.""" + + if not ORG_NAME_RE.fullmatch(organization): + raise ValueError("invalid organization name") + if repository_source not in REPOSITORY_SOURCES: + raise ValueError("repository source must be organization or installation") + if repository_source == "installation": + response = client.request( + [ + "installation/repositories", + "-X", + "GET", + "-f", + "per_page=100", + "--paginate", + "--slurp", + ] + ) + repositories = flatten_pages(response, collection_key="repositories") + else: + response = client.request( + [ + f"orgs/{organization}/repos", + "-X", + "GET", + "-f", + "type=all", + "-f", + "per_page=100", + "--paginate", + "--slurp", + ] + ) + repositories = flatten_pages(response) + names: list[str] = [] + for repository in repositories: + full_name = str(repository.get("full_name") or "") + owner = str(repository.get("owner", {}).get("login") or "") + if owner.casefold() != organization.casefold(): + continue + if repository.get("archived") or repository.get("disabled"): + continue + if not REPOSITORY_RE.fullmatch(full_name): + raise ValueError("GitHub returned an invalid repository full_name") + names.append(full_name) + return sorted(set(names)) + + +def list_recent_pull_requests( + client: GitHubClient, + *, + organization: str, + repository_source: str, + since: str, + on_error: Callable[[str, Exception], None] | None = None, +) -> Iterator[dict[str, Any]]: + """Yield recent open pull requests with lazy cutoff-aware pagination.""" + + cutoff = parse_timestamp(since) + repositories = list_accessible_repositories( + client, + organization=organization, + repository_source=repository_source, + ) + for repository in repositories: + try: + page = 1 + while True: + response = client.request( + [ + f"repos/{repository}/pulls", + "-X", + "GET", + "-f", + "state=open", + "-f", + "sort=updated", + "-f", + "direction=desc", + "-f", + "per_page=100", + "-f", + f"page={page}", + ] + ) + pull_requests = flatten_pages(response) + if not pull_requests: + break + reached_cutoff = False + for pull_request in pull_requests: + if ( + parse_timestamp( + str(pull_request.get("updated_at") or "") + ) + < cutoff + ): + reached_cutoff = True + break + number = pull_request.get("number") + if not isinstance(number, int) or number < 1: + raise ValueError( + "GitHub returned an invalid pull request number" + ) + yield { + "number": number, + "repository": repository, + "pull_request": { + "url": ( + "https://api.github.com/repos/" + f"{repository}/pulls/{number}" + ) + }, + } + if reached_cutoff or len(pull_requests) < 100: + break + page += 1 + except Exception as exc: # noqa: BLE001 - repository isolation boundary + if on_error is None: + raise + on_error(repository, exc) + + +def list_recent_comments( + client: GitHubClient, + *, + repository: str, + pull_request_number: int, + since: str, +) -> list[dict[str, Any]]: + """List recent issue comments for one pull request.""" + + response = client.request( + [ + f"repos/{repository}/issues/{pull_request_number}/comments", + "-X", + "GET", + "-f", + f"since={since}", + "-f", + "per_page=100", + "--paginate", + "--slurp", + ] + ) + return flatten_pages(response) + + +def build_requests_for_pull_request( + client: GitHubClient, + *, + issue: dict[str, Any], + since: str, +) -> tuple[MentionRequest, ...]: + """Build trusted mention requests for one live pull request.""" + + repository = str(issue.get("repository") or "") + if not REPOSITORY_RE.fullmatch(repository): + raise ValueError("pull request candidate has an invalid repository") + number = issue.get("number") + if not isinstance(number, int) or number < 1: + raise ValueError("pull request candidate has an invalid number") + comments = list_recent_comments( + client, + repository=repository, + pull_request_number=number, + since=since, + ) + live_pull = client.request([f"repos/{repository}/pulls/{number}"]) + if not isinstance(live_pull, dict) or live_pull.get("state") != "open": + return () + requests: list[MentionRequest] = [] + for comment in comments: + event = { + "repository": {"full_name": repository}, + "issue": { + "number": number, + "pull_request": issue.get("pull_request"), + }, + "comment": comment, + "pull_request": live_pull, + } + request = parse_event(event) + if request is not None: + requests.append(request) + return tuple(requests) + + +def sweep( + *, + target_client: GitHubClient, + dispatch_client: GitHubClient, + organization: str, + repository_source: str, + lookback_hours: int, + max_dispatches: int, + opencode_allowlist: frozenset[str], + dry_run: bool = False, + now: datetime | None = None, + metrics: SweepMetrics | None = None, +) -> int: + """Queue bounded new work while isolating candidate-local failures.""" + + if max_dispatches < 1 or max_dispatches > 100: + raise ValueError("max dispatches must be between 1 and 100") + since = cutoff_timestamp(lookback_hours, now=now) + counters = metrics if metrics is not None else SweepMetrics() + ledger_artifact_cache: dict[str, bool] = {} + dispatched = 0 + + def record_failure(scope: str, error: Exception) -> None: + """Record one isolated error and preserve the remaining sweep.""" + + counters.failures += 1 + message = " ".join(str(error).split()) or error.__class__.__name__ + print( + f"::warning::Agent mention sweep skipped {scope}: {message[:1000]}" + ) + + for issue in list_recent_pull_requests( + target_client, + organization=organization, + repository_source=repository_source, + since=since, + on_error=record_failure, + ): + issue_scope = f"{issue.get('repository')}#{issue.get('number')}" + try: + requests = build_requests_for_pull_request( + target_client, + issue=issue, + since=since, + ) + except Exception as exc: # noqa: BLE001 - pull-request isolation boundary + record_failure(issue_scope, exc) + continue + for request in requests: + request_scope = f"{issue_scope}/comment-{request.comment_id}" + try: + queued_agents = dispatch_request( + request, + target_client=target_client, + dispatch_client=dispatch_client, + opencode_allowlist=opencode_allowlist, + dry_run=dry_run, + ledger_artifact_cache=ledger_artifact_cache, + ) + except Exception as exc: # noqa: BLE001 - request isolation boundary + record_failure(request_scope, exc) + continue + if not queued_agents: + continue + dispatched += 1 + if dispatched >= max_dispatches: + print( + "Agent mention sweep reached dispatch limit " + f"{max_dispatches}; isolated failures={counters.failures}." + ) + return dispatched + print( + "Agent mention sweep completed with " + f"{dispatched} dispatch(es) and {counters.failures} isolated failure(s)." + ) + return dispatched + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the scheduled organization mention sweep.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--organization", default="ContextualWisdomLab") + parser.add_argument( + "--repository-source", + choices=sorted(REPOSITORY_SOURCES), + default="organization", + ) + parser.add_argument("--lookback-hours", type=int, default=168) + parser.add_argument("--max-dispatches", type=int, default=20) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + allowlist = parse_repository_allowlist( + os.environ.get("OPENCODE_REPOSITORY_DISPATCH_TARGETS", "") + ) + metrics = SweepMetrics() + sweep( + target_client=GitHubClient( + os.environ.get("TARGET_REPOSITORY_TOKEN", "") + ), + dispatch_client=GitHubClient(os.environ.get("AGENT_DISPATCH_TOKEN", "")), + organization=args.organization, + repository_source=args.repository_source, + lookback_hours=args.lookback_hours, + max_dispatches=args.max_dispatches, + opencode_allowlist=allowlist, + dry_run=args.dry_run, + metrics=metrics, + ) + return 1 if metrics.failures else 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/tests/test_agent_mention_artifact_ledger.py b/tests/test_agent_mention_artifact_ledger.py new file mode 100644 index 000000000..c527f4a6e --- /dev/null +++ b/tests/test_agent_mention_artifact_ledger.py @@ -0,0 +1,199 @@ +"""Regression tests for the exact-name Actions artifact invocation ledger.""" + +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" +NOEMA_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-noema-dispatch.yml" +OPENCODE_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-opencode-dispatch.yml" +DOC = ROOT / "docs" / "automation" / "review-agent-comment-invocation.md" +UPLOAD_ARTIFACT_SHA = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + + +def load_module() -> ModuleType: + """Load the router module from its script path.""" + + module_name = "agent_mention_router_artifact_ledger" + 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 request(module: ModuleType): + """Build one exact request containing both supported agents.""" + + return module.MentionRequest( + "ContextualWisdomLab/example", + 17, + "a" * 40, + "main", + 91, + "maintainer", + ("cwl-noema-review", "opencode-agent"), + ) + + +def artifact(module: ModuleType, mention, agent: str, *, expired: bool = False) -> dict: + """Build one exact-name artifact record for an invocation.""" + + return { + "id": 7, + "name": module.agent_ledger_artifact_name(mention, agent), + "expired": expired, + "created_at": "2026-08-06T12:00:00Z", + "expires_at": "2026-09-05T12:00:00Z", + } + + +class ArtifactClient: + """Return artifact inventories while rejecting workflow-run scans.""" + + def __init__(self, responses=None) -> None: + """Initialize exact artifact-name responses and a request ledger.""" + + self.responses = responses or {} + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Return a name-filtered artifact response for one API call.""" + + args = list(args) + self.calls.append((args, input_payload)) + if args[0].endswith("/runs"): + raise AssertionError("workflow-run listings are not a durable ledger") + if args[0].endswith("/actions/artifacts"): + name = next( + value.split("=", 1)[1] + for value in args + if value.startswith("name=") + ) + return self.responses.get(name, {"total_count": 0, "artifacts": []}) + return None + + +def test_artifact_name_is_exact_key_addressable() -> None: + """The durable ledger name contains one complete invocation digest.""" + + module = load_module() + mention = request(module) + noema_name = module.agent_ledger_artifact_name(mention, "cwl-noema-review") + opencode_name = module.agent_ledger_artifact_name(mention, "opencode-agent") + + assert re.fullmatch(r"cwl-agent-invocation-[0-9a-f]{64}", noema_name) + assert noema_name != opencode_name + assert noema_name.endswith( + module.agent_invocation_key(mention, "cwl-noema-review") + ) + + +def test_exact_artifact_lookup_is_cached_without_workflow_run_pagination() -> None: + """Each exact artifact name is queried once and reused for the sweep run.""" + + module = load_module() + mention = request(module) + noema_name = module.agent_ledger_artifact_name(mention, "cwl-noema-review") + client = ArtifactClient( + { + noema_name: { + "total_count": 1, + "artifacts": [artifact(module, mention, "cwl-noema-review")], + } + } + ) + cache: dict[str, bool] = {} + + expected = frozenset({"cwl-noema-review"}) + assert module.dispatched_agents( + mention, + client, + ledger_artifact_cache=cache, + ) == expected + assert module.dispatched_agents( + mention, + client, + ledger_artifact_cache=cache, + ) == expected + + artifact_calls = [ + args for args, _ in client.calls if args[0].endswith("/actions/artifacts") + ] + assert len(artifact_calls) == 2 + assert all("per_page=100" in args for args in artifact_calls) + assert all(any(value.startswith("name=") for value in args) for args in artifact_calls) + assert all(not args[0].endswith("/runs") for args, _ in client.calls) + + +def test_artifact_inventory_validation_fails_closed() -> None: + """Malformed, mismatched, or expired artifact evidence is never trusted.""" + + module = load_module() + mention = request(module) + name = module.agent_ledger_artifact_name(mention, "cwl-noema-review") + + for malformed in ( + None, + [], + {"total_count": "1", "artifacts": []}, + {"total_count": 1, "artifacts": "bad"}, + {"total_count": 1, "artifacts": [{}]}, + { + "total_count": 1, + "artifacts": [{"id": 1, "name": "wrong", "expired": False}], + }, + ): + with pytest.raises(ValueError, match="artifact"): + module._artifact_records(malformed, expected_name=name) + + assert module._artifact_records( + {"total_count": 1, "artifacts": [artifact(module, mention, "cwl-noema-review", expired=True)]}, + expected_name=name, + ) == () + + +def test_thousand_workflow_runs_cannot_truncate_exact_ledger_lookup() -> None: + """The router uses an exact-name endpoint rather than the capped run search.""" + + module = load_module() + mention = request(module) + client = ArtifactClient() + + assert module.dispatched_agents(mention, client) == frozenset() + assert len(client.calls) == 2 + assert all(call[0][0].endswith("/actions/artifacts") for call in client.calls) + + +def test_wrappers_claim_the_artifact_before_forwarding() -> None: + """Both wrappers upload a 30-day immutable claim before repository dispatch.""" + + for path in (NOEMA_WORKFLOW, OPENCODE_WORKFLOW): + text = path.read_text(encoding="utf-8") + assert "actions/artifacts" in text + assert "name=${LEDGER_ARTIFACT_NAME}" in text + assert f"actions/upload-artifact@{UPLOAD_ARTIFACT_SHA}" in text + assert "name: cwl-agent-invocation-${{ env.INVOCATION_KEY }}" in text + assert "retention-days: 30" in text + assert text.index("actions/upload-artifact@") < text.index( + "Forward once to the authoritative" + ) + assert "workflow_runs" not in text + + +def test_doctoring_records_artifact_ledger_contract() -> None: + """Operator documentation cites the exact-name artifact API and retention.""" + + text = DOC.read_text(encoding="utf-8") + assert "exact-name Actions artifact ledger" in text + assert "30-day" in text + assert "REST API endpoints for GitHub Actions artifacts" in text + assert "Store and share data with workflow artifacts" in text diff --git a/tests/test_agent_mention_complete_payload_binding.py b/tests/test_agent_mention_complete_payload_binding.py new file mode 100644 index 000000000..04562e93f --- /dev/null +++ b/tests/test_agent_mention_complete_payload_binding.py @@ -0,0 +1,197 @@ +"""Contracts for complete review-agent invocation payload binding.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import json +import sys +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" +NOEMA_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-noema-dispatch.yml" +OPENCODE_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-opencode-dispatch.yml" + + +def _load_router() -> ModuleType: + """Load the router module from the pull-request source tree.""" + + module_name = "agent_mention_complete_payload_binding" + 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 _event() -> dict: + """Return one complete trusted issue-comment event.""" + + return { + "repository": {"full_name": "ContextualWisdomLab/example"}, + "issue": { + "number": 17, + "pull_request": {"url": "https://api.github.test/pr/17"}, + }, + "comment": { + "id": 91, + "body": "@cwl-noema-review @opencode-agent review", + "author_association": "MEMBER", + "user": {"login": "maintainer", "type": "User"}, + }, + "pull_request": { + "state": "open", + "head": {"sha": "a" * 40}, + "base": {"ref": "main", "sha": "b" * 40}, + }, + } + + +def _digest(claim: dict[str, object]) -> str: + """Return the canonical SHA-256 digest used by wrapper workflows.""" + + canonical = json.dumps( + claim, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def test_event_and_payloads_bind_exact_base_identity() -> None: + """The request and both wrapper payloads carry the immutable base SHA.""" + + router = _load_router() + request = router.parse_event(_event()) + assert request is not 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 + + malformed = _event() + malformed["pull_request"]["base"]["sha"] = "not-a-sha" + with pytest.raises(ValueError, match="base SHA"): + router.parse_event(malformed) + + +def test_invocation_claim_binds_all_security_relevant_fields() -> None: + """Every mutable dispatch field participates in the canonical digest.""" + + router = _load_router() + request = router.parse_event(_event()) + assert request is not None + + noema_claim = router.agent_invocation_claim(request, "cwl-noema-review") + assert noema_claim == { + "actor": "maintainer", + "agent": "cwl-noema-review", + "base_branch": "main", + "base_sha": "b" * 40, + "comment_id": 91, + "head_sha": "a" * 40, + "pr_number": 17, + "repository": "ContextualWisdomLab/example", + } + + opencode_claim = router.agent_invocation_claim(request, "opencode-agent") + assert opencode_claim == { + "actor": "maintainer", + "agent": "opencode-agent", + "base_branch": "main", + "base_sha": "b" * 40, + "comment_id": 91, + "enable_auto_merge": False, + "head_sha": "a" * 40, + "merge_mode": "disabled", + "pr_number": 17, + "repository": "ContextualWisdomLab/example", + "review_dispatch_limit": "1", + "trigger_reviews": True, + "update_branches": False, + } + + noema_key = router.agent_invocation_key(request, "cwl-noema-review") + opencode_key = router.agent_invocation_key(request, "opencode-agent") + assert noema_key == _digest(noema_claim) + assert opencode_key == _digest(opencode_claim) + + changed_base = replace(request, pull_request_base_sha="c" * 40) + assert router.agent_invocation_key( + changed_base, "cwl-noema-review" + ) != noema_key + + for field, replacement in ( + ("trigger_reviews", False), + ("review_dispatch_limit", "2"), + ("enable_auto_merge", True), + ("update_branches", True), + ("merge_mode", "direct_or_auto"), + ): + altered = dict(opencode_claim) + altered[field] = replacement + assert _digest(altered) != opencode_key + + +def test_wrappers_recompute_complete_claim_before_ledger_access() -> None: + """Both wrappers fail closed before reusing an exact-name artifact claim.""" + + noema = NOEMA_WORKFLOW.read_text(encoding="utf-8") + opencode = OPENCODE_WORKFLOW.read_text(encoding="utf-8") + 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"]', + ): + assert opencode.count(field) >= 2 + + +def test_no_pr_specific_writer_workflow_remains() -> None: + """Complete binding is implemented in canonical files, never a branch writer.""" + + forbidden = sorted( + str(path.relative_to(ROOT)) + for pattern in ( + "repair-pr787*.yml", + "*pr787*final*.yml", + "*agent-mention*repair*.yml", + ) + for path in (ROOT / ".github" / "workflows").glob(pattern) + ) + assert forbidden == [] diff --git a/tests/test_agent_mention_downstream_idempotency.py b/tests/test_agent_mention_downstream_idempotency.py new file mode 100644 index 000000000..4fc40a782 --- /dev/null +++ b/tests/test_agent_mention_downstream_idempotency.py @@ -0,0 +1,106 @@ +"""Static contracts for downstream review-agent invocation idempotency.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ROUTER_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router.yml" +QUALITY_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router-quality-ci.yml" +NOEMA_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-noema-dispatch.yml" +OPENCODE_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-opencode-dispatch.yml" +ROUTER_SCRIPT = ROOT / "scripts" / "ci" / "agent_mention_router.py" +UPLOAD_ARTIFACT_SHA = "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + + +def test_router_can_read_durable_central_artifacts() -> None: + """Both local routing and sibling sweeping receive actions read access.""" + + text = ROUTER_WORKFLOW.read_text(encoding="utf-8") + local, sweep = text.split("\n sweep-organization-agent-mentions:\n", 1) + assert "permissions:\n actions: read" in local + assert "permissions:\n actions: read" in sweep + assert "AGENT_DISPATCH_TOKEN: ${{ github.token }}" in local + assert "AGENT_DISPATCH_TOKEN: ${{ github.token }}" in sweep + + +def test_downstream_workflows_claim_artifacts_and_bind_exact_key() -> None: + """Exact-key concurrency serializes claims before authoritative forwarding.""" + + noema = NOEMA_WORKFLOW.read_text(encoding="utf-8") + opencode = OPENCODE_WORKFLOW.read_text(encoding="utf-8") + 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 + assert "retention-days: 30" in text + assert "overwrite: false" in text + assert text.index("actions/upload-artifact@") < text.index( + "Forward once to the authoritative" + ) + assert "workflow_runs" not in text + assert "repos/${GITHUB_REPOSITORY}/dispatches" in text + assert "types: [agent-mention-noema]" in noema + assert 'event_type: "noema-review"' in noema + assert 'REQUESTED_AGENT: "cwl-noema-review"' 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 + + +def test_wrappers_recompute_the_router_canonical_payload_digest() -> None: + """A syntactically valid key cannot authorize altered payload fields.""" + + router = ROUTER_SCRIPT.read_text(encoding="utf-8") + noema_function = router.split("def noema_payload", 1)[1].split( + "def opencode_payload", 1 + )[0] + assert '"base_branch": request.pull_request_base_branch' in noema_function + + canonical_fields = ( + '"actor"', + '"agent"', + '"base_branch"', + '"comment_id"', + '"head_sha"', + '"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 + assert "import hashlib" in text + assert "import hmac" in text + assert "json.dumps(" in text + assert 'separators=(",", ":")' in text + assert "sort_keys=True" in text + assert "hashlib.sha256" in text + assert "hmac.compare_digest" in text + assert "INVOCATION_KEY" in text + for field in canonical_fields: + assert field in text + + +def test_quality_gate_runs_full_suite_for_docs_and_exact_diff() -> None: + """Every changed contract executes while coverage stays source-bounded.""" + + text = QUALITY_WORKFLOW.read_text(encoding="utf-8") + assert ' - "docs/automation/review-agent-comment-invocation.md"' in text + assert ' - "tests/test_agent_mention_*.py"' in text + assert "python -m coverage run -m pytest -q\n" in text + assert "python -m compileall -q scripts/ci tests" in text + assert "CHANGE_DIFF_RANGE" in text + assert 'git diff --check "$CHANGE_DIFF_RANGE"' in text + 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 diff --git a/tests/test_agent_mention_idempotency.py b/tests/test_agent_mention_idempotency.py new file mode 100644 index 000000000..499730a22 --- /dev/null +++ b/tests/test_agent_mention_idempotency.py @@ -0,0 +1,351 @@ +"""Regression tests for durable per-agent mention dispatch idempotency.""" + +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" + + +def load_module() -> ModuleType: + """Load the router module from its script path for isolated tests.""" + + module_name = "agent_mention_router_idempotency" + 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 request(module: ModuleType): + """Build one request containing both supported review agents.""" + + return module.MentionRequest( + "ContextualWisdomLab/inkspan", + 65, + "a" * 40, + "main", + 12345, + "maintainer", + ("cwl-noema-review", "opencode-agent"), + ) + + +class ArtifactAwareClient: + """Fake GitHub client with exact artifact inventory and fault injection.""" + + def __init__( + self, + *, + artifacts=None, + fail_event=None, + fail_target_call=None, + ) -> None: + """Initialize bounded responses and optional deterministic failures.""" + + self.artifacts = artifacts or {} + self.fail_event = fail_event + self.fail_target_call = fail_target_call + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Return exact artifacts, record mutations, or raise at one boundary.""" + + call_number = len(self.calls) + 1 + args = list(args) + self.calls.append((args, input_payload)) + endpoint = args[0] + if endpoint.endswith("/actions/artifacts"): + name = next( + value.split("=", 1)[1] + for value in args + if value.startswith("name=") + ) + return self.artifacts.get( + name, + {"total_count": 0, "artifacts": []}, + ) + if endpoint.endswith("/dispatches"): + event_type = (input_payload or {}).get("event_type") + if event_type == self.fail_event: + raise RuntimeError(f"failed {event_type}") + if self.fail_target_call == call_number: + raise RuntimeError(f"failed target call {call_number}") + return None + + +def artifact(module: ModuleType, mention_request, agent: str, artifact_id: int) -> dict: + """Build one live exact-name artifact record for an agent request.""" + + return { + "id": artifact_id, + "name": module.agent_ledger_artifact_name(mention_request, agent), + "expired": False, + } + + +def artifact_inventory(module: ModuleType, mention_request, *agents: str) -> dict: + """Return artifact-name-keyed responses for selected agents.""" + + inventory = {} + for index, agent in enumerate(agents, start=1): + name = module.agent_ledger_artifact_name(mention_request, agent) + inventory[name] = { + "total_count": 1, + "artifacts": [artifact(module, mention_request, agent, index)], + } + return inventory + + +def dispatch_events(client: ArtifactAwareClient) -> list[str]: + """Return repository-dispatch event types recorded by one fake client.""" + + return [ + payload["event_type"] + for args, payload in client.calls + if args[0].endswith("/dispatches") and payload is not None + ] + + +def test_invocation_key_binds_complete_request_identity() -> None: + """The opaque key changes with agent, head, PR, repository, or comment.""" + + module = load_module() + original = request(module) + noema_key = module.agent_invocation_key(original, "cwl-noema-review") + opencode_key = module.agent_invocation_key(original, "opencode-agent") + assert re.fullmatch(r"[0-9a-f]{64}", noema_key) + assert noema_key != opencode_key + assert module.agent_invocation_marker(original, "cwl-noema-review") == ( + f"[cwl-agent-invocation:{noema_key}]" + ) + assert module.agent_ledger_artifact_name( + original, "cwl-noema-review" + ).endswith(noema_key) + + changed_values = ( + module.MentionRequest( + "ContextualWisdomLab/naruon", + original.pull_request_number, + original.pull_request_head_sha, + original.pull_request_base_branch, + original.comment_id, + original.actor, + original.agents, + ), + module.MentionRequest( + original.repository, + original.pull_request_number + 1, + original.pull_request_head_sha, + original.pull_request_base_branch, + original.comment_id, + original.actor, + original.agents, + ), + module.MentionRequest( + original.repository, + original.pull_request_number, + "b" * 40, + original.pull_request_base_branch, + original.comment_id, + original.actor, + original.agents, + ), + module.MentionRequest( + original.repository, + original.pull_request_number, + original.pull_request_head_sha, + original.pull_request_base_branch, + original.comment_id + 1, + original.actor, + original.agents, + ), + ) + assert all( + module.agent_invocation_key(changed, "cwl-noema-review") != noema_key + for changed in changed_values + ) + with pytest.raises(ValueError, match="unsupported agent"): + module.agent_invocation_key(original, "unknown-agent") + + +def test_payloads_carry_exact_agent_invocation_identity() -> None: + """Both durable wrappers receive the same deterministic request identity.""" + + module = load_module() + mention_request = request(module) + noema_body = module.noema_payload(mention_request) + opencode_body = module.opencode_payload(mention_request) + assert noema_body["event_type"] == "agent-mention-noema" + assert opencode_body["event_type"] == "agent-mention-opencode" + noema = noema_body["client_payload"] + opencode = opencode_body["client_payload"] + + assert noema["requested_agent"] == "cwl-noema-review" + assert noema["agent_invocation_key"] == module.agent_invocation_key( + mention_request, "cwl-noema-review" + ) + assert opencode["requested_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 + + +def test_existing_artifacts_are_per_agent_durable_evidence() -> None: + """A live exact-name artifact suppresses only its matching agent.""" + + module = load_module() + mention_request = request(module) + client = ArtifactAwareClient( + artifacts=artifact_inventory(module, mention_request, "cwl-noema-review") + ) + assert module.dispatched_agents(mention_request, client) == frozenset( + {"cwl-noema-review"} + ) + + with pytest.raises(ValueError, match="artifact"): + module.dispatched_agents( + mention_request, + ArtifactAwareClient( + artifacts={ + module.agent_ledger_artifact_name( + mention_request, "cwl-noema-review" + ): {"total_count": 1, "artifacts": "not-a-list"} + } + ), + ) + + +def test_artifact_inventory_edge_cases_fail_closed() -> None: + """Malformed, inconsistent, and unsupported evidence fails safely.""" + + module = load_module() + mention_request = request(module) + expected_name = module.agent_ledger_artifact_name( + mention_request, "cwl-noema-review" + ) + malformed = ( + None, + [], + {"total_count": True, "artifacts": []}, + {"total_count": -1, "artifacts": []}, + {"total_count": 0, "artifacts": "bad"}, + {"total_count": 1, "artifacts": []}, + {"total_count": 1, "artifacts": ["bad"]}, + {"total_count": 1, "artifacts": [{"id": True, "name": expected_name, "expired": False}]}, + {"total_count": 1, "artifacts": [{"id": 0, "name": expected_name, "expired": False}]}, + {"total_count": 1, "artifacts": [{"id": 1, "name": "wrong", "expired": False}]}, + {"total_count": 1, "artifacts": [{"id": 1, "name": expected_name, "expired": 0}]}, + ) + for value in malformed: + with pytest.raises(ValueError, match="artifact"): + module._artifact_records(value, expected_name=expected_name) + + assert module._artifact_records( + {"total_count": 0, "artifacts": []}, + expected_name=expected_name, + ) == () + assert module._artifact_records( + { + "total_count": 1, + "artifacts": [ + {"id": 1, "name": expected_name, "expired": True} + ], + }, + expected_name=expected_name, + ) == () + + with pytest.raises(ValueError, match="unsupported agent"): + module.dispatched_agents( + mention_request, + ArtifactAwareClient(), + agents=("unknown-agent",), + ) + + +def test_partial_failure_retries_only_the_missing_agent() -> None: + """A later dispatch failure never repeats an already claimed agent.""" + + module = load_module() + mention_request = request(module) + target = ArtifactAwareClient() + first = ArtifactAwareClient(fail_event="agent-mention-opencode") + + with pytest.raises(RuntimeError, match="agent-mention-opencode"): + module.dispatch_request( + mention_request, + target_client=target, + dispatch_client=first, + opencode_allowlist=frozenset({mention_request.repository}), + ) + assert dispatch_events(first) == [ + "agent-mention-noema", + "agent-mention-opencode", + ] + + retry = ArtifactAwareClient( + artifacts=artifact_inventory( + module, + mention_request, + "cwl-noema-review", + ) + ) + assert module.dispatch_request( + mention_request, + target_client=ArtifactAwareClient(), + dispatch_client=retry, + opencode_allowlist=frozenset({mention_request.repository}), + ) == ("@opencode-agent",) + 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.""" + + 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 dispatch_events(central) == [ + "agent-mention-noema", + "agent-mention-opencode", + ] + + retry = ArtifactAwareClient( + artifacts=artifact_inventory( + module, + mention_request, + "cwl-noema-review", + "opencode-agent", + ) + ) + retry_target = ArtifactAwareClient(fail_target_call=1) + assert module.dispatch_request( + mention_request, + target_client=retry_target, + dispatch_client=retry, + opencode_allowlist=frozenset({mention_request.repository}), + ) == () + assert dispatch_events(retry) == [] + assert retry_target.calls == [] diff --git a/tests/test_agent_mention_receipt_authority.py b/tests/test_agent_mention_receipt_authority.py new file mode 100644 index 000000000..9280167b9 --- /dev/null +++ b/tests/test_agent_mention_receipt_authority.py @@ -0,0 +1,29 @@ +"""Contracts that keep target-repository receipt comments non-authoritative.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" / "ci" +ROUTER_WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router.yml" + + +def test_local_router_does_not_load_target_receipts_as_dispatch_authority() -> None: + """The local path routes the source event without prior-comment receipts.""" + + workflow = ROUTER_WORKFLOW.read_text(encoding="utf-8") + local = workflow.split("\n sweep-organization-agent-mentions:\n", 1)[0] + + assert "conversation_comments" not in local + assert "/comments?per_page=100" not in local + + +def test_sweep_does_not_use_target_receipts_as_dispatch_authority() -> None: + """The organization sweep delegates suppression to the central run ledger.""" + + source = (SCRIPTS / "agent_mention_sweep.py").read_text(encoding="utf-8") + + assert "processed_comment_ids" not in source + assert "comment_id in processed" not in source + assert "/comments?per_page=100" not in source diff --git a/tests/test_agent_mention_rejection_idempotency.py b/tests/test_agent_mention_rejection_idempotency.py new file mode 100644 index 000000000..843454f3d --- /dev/null +++ b/tests/test_agent_mention_rejection_idempotency.py @@ -0,0 +1,66 @@ +"""Regression coverage for rejection-only agent mentions.""" + +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 router module from its script path.""" + + module_name = "agent_mention_router_rejection_idempotency" + 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 FakeClient: + """Capture bounded GitHub API calls for one router invocation.""" + + def __init__(self) -> None: + """Initialize an empty request ledger.""" + + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Record a request and return an empty workflow-run inventory.""" + + self.calls.append((list(args), input_payload)) + if args[0].endswith("/runs"): + return {"workflow_runs": []} + return None + + +def test_rejected_only_request_is_mutation_free() -> None: + """A disallowed OpenCode mention never creates repeatable target mutations.""" + + module = load_module() + request = module.MentionRequest( + "ContextualWisdomLab/example", + 17, + "a" * 40, + "main", + 91, + "maintainer", + ("opencode-agent",), + ) + target = FakeClient() + central = FakeClient() + + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + ) == () + assert target.calls == [] + assert central.calls == [] diff --git a/tests/test_agent_mention_review_regressions.py b/tests/test_agent_mention_review_regressions.py new file mode 100644 index 000000000..1615ddd2b --- /dev/null +++ b/tests/test_agent_mention_review_regressions.py @@ -0,0 +1,211 @@ +"""Review-driven runtime regressions for the agent mention control plane.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" + + +def load_module() -> ModuleType: + """Load the router under one isolated module name.""" + + module_name = "agent_mention_router_review_regressions" + 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 request(module: ModuleType, agents=("cwl-noema-review", "opencode-agent")): + """Build one exact invocation request.""" + + return module.MentionRequest( + "ContextualWisdomLab/Example", + 17, + "a" * 40, + "main", + 91, + "maintainer", + agents, + pull_request_base_sha="b" * 40, + ) + + +class FakeClient: + """Capture API requests and expose exact-name artifact inventories.""" + + def __init__(self, responses=None) -> None: + """Initialize responses and an empty call ledger.""" + + self.responses = responses or {} + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Record a call and return its registered artifact response.""" + + args = list(args) + self.calls.append((args, input_payload)) + if args[0].endswith("/actions/artifacts"): + name = next( + value.split("=", 1)[1] + for value in args + if value.startswith("name=") + ) + return self.responses.get( + name, + {"total_count": 0, "artifacts": []}, + ) + return None + + +def test_actor_and_allowlist_validation_are_wrapper_compatible() -> None: + """Router validation rejects actors wrappers cannot accept.""" + + module = load_module() + payload = { + "repository": {"full_name": "ContextualWisdomLab/Example"}, + "issue": {"number": 17, "pull_request": {"url": "x"}}, + "comment": { + "id": 91, + "body": "@opencode-agent", + "author_association": "MEMBER", + "user": {"login": "bad_actor", "type": "User"}, + }, + "pull_request": { + "state": "open", + "head": {"sha": "a" * 40}, + "base": {"ref": "main", "sha": "b" * 40}, + }, + } + with pytest.raises(ValueError, match="actor"): + module.parse_event(payload) + + mention = request(module, ("opencode-agent",)) + assert module.eligible_agents( + mention, + opencode_allowlist=frozenset({"contextualwisdomlab/example"}), + ) == (("opencode-agent",), ()) + + +@pytest.mark.parametrize( + ("stderr", "message"), + [("permission denied\n details", "permission denied details"), ("", "no stderr")], +) +def test_github_client_surfaces_bounded_api_diagnostics( + monkeypatch, + stderr: str, + message: str, +) -> None: + """A failed gh call identifies the real API boundary.""" + + module = load_module() + monkeypatch.setattr( + module.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + stdout="", + stderr=stderr, + returncode=1, + ), + ) + with pytest.raises(RuntimeError, match=message): + module.GitHubClient("token").request(["repos/x/y"]) + + +def test_exact_artifact_cache_bounds_api_cost() -> None: + """Each exact artifact name is queried once per router or sweep run.""" + + module = load_module() + mention = request(module) + name = module.agent_ledger_artifact_name(mention, "cwl-noema-review") + client = FakeClient( + { + name: { + "total_count": 1, + "artifacts": [ + {"id": 1, "name": name, "expired": False} + ], + } + } + ) + cache: dict[str, bool] = {} + expected = frozenset({"cwl-noema-review"}) + assert module.dispatched_agents( + mention, + client, + ledger_artifact_cache=cache, + ) == expected + assert module.dispatched_agents( + mention, + client, + ledger_artifact_cache=cache, + ) == expected + artifact_calls = [ + args for args, _ in client.calls if args[0].endswith("/actions/artifacts") + ] + assert len(artifact_calls) == 2 + assert all("per_page=100" in args for args in artifact_calls) + + +def test_dispatch_cache_suppresses_same_run_retries_and_rejection_noise() -> None: + """Accepted dispatches update the in-memory ledger before artifact visibility.""" + + module = load_module() + mention = request(module) + target = FakeClient() + central = FakeClient() + cache: dict[str, bool] = {} + allowlist = frozenset({"contextualwisdomlab/example"}) + + assert module.dispatch_request( + mention, + target_client=target, + dispatch_client=central, + opencode_allowlist=allowlist, + ledger_artifact_cache=cache, + ) == ("@cwl-noema-review", "@opencode-agent") + first_target_calls = len(target.calls) + assert module.dispatch_request( + mention, + target_client=target, + dispatch_client=central, + opencode_allowlist=allowlist, + ledger_artifact_cache=cache, + ) == () + assert len(target.calls) == first_target_calls + dispatches = [ + payload["event_type"] + for args, payload in central.calls + if args[0].endswith("/dispatches") and payload + ] + assert dispatches == ["agent-mention-noema", "agent-mention-opencode"] + + mixed = request(module) + mixed_target = FakeClient() + mixed_central = FakeClient() + mixed_cache: dict[str, bool] = {} + assert module.dispatch_request( + mixed, + target_client=mixed_target, + dispatch_client=mixed_central, + opencode_allowlist=frozenset(), + ledger_artifact_cache=mixed_cache, + ) == ("@cwl-noema-review",) + first_mixed_calls = len(mixed_target.calls) + assert module.dispatch_request( + mixed, + target_client=mixed_target, + dispatch_client=mixed_central, + opencode_allowlist=frozenset(), + ledger_artifact_cache=mixed_cache, + ) == () + assert len(mixed_target.calls) == first_mixed_calls diff --git a/tests/test_agent_mention_router.py b/tests/test_agent_mention_router.py new file mode 100644 index 000000000..4509d43f0 --- /dev/null +++ b/tests/test_agent_mention_router.py @@ -0,0 +1,369 @@ +"""Tests for trusted PR comment agent mention routing.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "ci" / "agent_mention_router.py" + + +def load_module() -> ModuleType: + """Load the router module from its script path.""" + + module_name = "agent_mention_router" + 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 receipt(comment_id: int, *, trusted: bool = True) -> dict: + """Build one trusted or attacker-controlled receipt-looking comment.""" + + return { + "body": f"", + "user": { + "login": "github-actions[bot]" if trusted else "attacker", + "type": "Bot" if trusted else "User", + }, + } + + +def event( + body: str, + *, + association: str = "MEMBER", + user_type: str = "User", +) -> dict: + """Build a representative enriched issue-comment event.""" + + return { + "repository": {"full_name": "ContextualWisdomLab/example"}, + "issue": { + "number": 17, + "pull_request": {"url": "https://api.github.test/pr/17"}, + }, + "comment": { + "id": 91, + "body": body, + "author_association": association, + "user": {"login": "maintainer", "type": user_type}, + }, + "pull_request": { + "state": "open", + "head": {"sha": "a" * 40}, + "base": {"ref": "develop", "sha": "b" * 40}, + }, + } + + +class FakeClient: + """Capture JSON API calls for deterministic dispatch assertions.""" + + def __init__(self) -> None: + """Initialize an empty call ledger.""" + + self.calls: list[tuple[list[str], dict | None]] = [] + + def request(self, args, *, input_payload=None): + """Record one request and return an empty artifact inventory for reads.""" + + self.calls.append((list(args), input_payload)) + if args[0].endswith("/actions/artifacts"): + return {"total_count": 0, "artifacts": []} + return None + + +def repository_dispatch_calls( + client: FakeClient, +) -> list[tuple[list[str], dict]]: + """Return only mutation calls that enqueue central repository dispatches.""" + + return [ + (args, payload) + for args, payload in client.calls + if args[0].endswith("/dispatches") and payload is not None + ] + + +def test_exact_mentions_and_parse_event() -> None: + """Both exact mentions are recognized with immutable PR metadata.""" + + module = load_module() + request = module.parse_event( + event("please @cwl-noema-review and @opencode-agent") + ) + assert request is not None + assert request.agents == ("cwl-noema-review", "opencode-agent") + assert request.pull_request_head_sha == "a" * 40 + assert request.pull_request_base_branch == "develop" + assert request.pull_request_base_sha == "b" * 40 + assert module.exact_mentions("@opencode-agent-evil @cwl-noema-review2") == () + + +@pytest.mark.parametrize( + "payload", + [ + event("no agent here"), + event("@opencode-agent", association="CONTRIBUTOR"), + event("@opencode-agent", user_type="Bot"), + {**event("@opencode-agent"), "issue": {"number": 17}}, + { + **event("@opencode-agent"), + "pull_request": { + **event("@opencode-agent")["pull_request"], + "state": "closed", + }, + }, + { + **event("@opencode-agent"), + "conversation_comments": [receipt(91)], + }, + ], +) +def test_parse_event_ignores_untrusted_irrelevant_or_processed_comments( + payload: dict, +) -> None: + """Untrusted, irrelevant, non-PR, and acknowledged comments are ignored.""" + + assert load_module().parse_event(payload) is None + + +def test_untrusted_receipt_marker_cannot_suppress_invocation() -> None: + """A user-authored marker does not acknowledge a trusted invocation.""" + + payload = event("@opencode-agent") + payload["conversation_comments"] = [receipt(91, trusted=False)] + assert load_module().parse_event(payload) is not None + + +@pytest.mark.parametrize( + ("path", "value", "message"), + [ + (("repository", "full_name"), "outside/example", "limited"), + (("issue", "number"), 0, "number"), + (("comment", "id"), 0, "comment id"), + (("pull_request", "head", "sha"), "bad", "head SHA"), + (("pull_request", "base", "ref"), "-bad", "base branch"), + (("pull_request", "base", "sha"), "bad", "base SHA"), + (("comment", "user", "login"), "", "actor"), + ], +) +def test_parse_event_rejects_malformed_trusted_requests( + path: tuple[str, ...], + value: object, + message: str, +) -> None: + """Malformed trusted invocation metadata fails closed.""" + + payload = event("@opencode-agent") + target = payload + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + with pytest.raises(ValueError, match=message): + load_module().parse_event(payload) + + +def test_receipt_and_allowlist_helpers() -> None: + """Receipt extraction and exact repository allowlists are deterministic.""" + + module = load_module() + assert module.receipt_marker(91) == "" + with pytest.raises(ValueError, match="positive"): + module.receipt_marker(0) + comments = [ + receipt(91), + { + "body": "x y", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + }, + receipt(93, trusted=False), + {"body": None, "user": {"login": "github-actions[bot]", "type": "Bot"}}, + ] + assert module.processed_comment_ids(comments) == frozenset({91, 92}) + assert module.parse_repository_allowlist( + "ContextualWisdomLab/example, ContextualWisdomLab/.github," + ) == frozenset( + {"ContextualWisdomLab/example", "ContextualWisdomLab/.github"} + ) + with pytest.raises(ValueError, match="invalid repository"): + module.parse_repository_allowlist("outside/example") + + +def test_eligible_agents_and_payloads() -> None: + """Eligibility and event bodies preserve the bounded review contract.""" + + module = load_module() + request = module.parse_event(event("@cwl-noema-review @opencode-agent")) + assert request is not None + assert module.eligible_agents( + request, + opencode_allowlist=frozenset({request.repository}), + ) == (("cwl-noema-review", "opencode-agent"), ()) + assert module.eligible_agents( + request, + opencode_allowlist=frozenset(), + ) == (("cwl-noema-review",), ("opencode-agent",)) + noema = module.noema_payload(request) + assert noema["event_type"] == "agent-mention-noema" + assert noema["client_payload"]["pr_head_sha"] == "a" * 40 + 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 + + +def test_dispatch_uses_central_events_and_acknowledges() -> None: + """Both agents dispatch centrally with bounded review-only OpenCode options.""" + + module = load_module() + request = module.parse_event(event("@cwl-noema-review @opencode-agent")) + assert request is not None + target = FakeClient() + central = FakeClient() + result = module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset({request.repository}), + ) + assert result == ("@cwl-noema-review", "@opencode-agent") + dispatches = repository_dispatch_calls(central) + assert [payload["event_type"] for _, payload in dispatches] == [ + "agent-mention-noema", + "agent-mention-opencode", + ] + assert all( + args[0] == "repos/ContextualWisdomLab/.github/dispatches" + for args, _ in dispatches + ) + assert target.calls[0][1] == {"content": "eyes"} + assert "cwl-agent-mention-receipt:91" in target.calls[1][1]["body"] + assert "exact-name Actions artifacts" in target.calls[1][1]["body"] + + +def test_dispatch_rejects_unallowlisted_opencode_and_supports_dry_run( + capsys, +) -> None: + """Rejected-only and dry-run requests remain mutation-free.""" + + module = load_module() + request = module.parse_event(event("@opencode-agent")) + assert request is not None + target = FakeClient() + central = FakeClient() + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + ) == () + assert target.calls == central.calls == [] + assert "Rejected agent mention without target mutation" in capsys.readouterr().out + + target = FakeClient() + central = FakeClient() + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + dry_run=True, + ) == () + assert target.calls == central.calls == [] + output = capsys.readouterr().out + assert "DRY-RUN agent mention" in output + assert "reject=opencode-agent" in output + + +def test_dispatch_noema_only_covers_non_opencode_path() -> None: + """A Noema-only request bypasses the OpenCode allowlist branch.""" + + module = load_module() + request = module.parse_event(event("@cwl-noema-review")) + assert request is not None + target = FakeClient() + central = FakeClient() + assert module.dispatch_request( + request, + target_client=target, + dispatch_client=central, + opencode_allowlist=frozenset(), + ) == ("@cwl-noema-review",) + dispatches = repository_dispatch_calls(central) + assert len(dispatches) == 1 + assert dispatches[0][1]["event_type"] == "agent-mention-noema" + + +def test_github_client_validates_token_and_decodes_json(monkeypatch) -> None: + """The token-bound client never places credentials in command arguments.""" + + module = load_module() + with pytest.raises(ValueError, match="token"): + module.GitHubClient("") + calls = [] + + def fake_run(command, **kwargs): + calls.append((command, kwargs)) + return SimpleNamespace(stdout='{"ok": true}\n') + + monkeypatch.setattr(module.subprocess, "run", fake_run) + client = module.GitHubClient("secret-token") + assert client.request(["repos/x/y"], input_payload={"a": 1}) == {"ok": True} + command, kwargs = calls[0] + assert command == ["gh", "api", "repos/x/y", "--input", "-"] + assert "secret-token" not in command + assert kwargs["env"]["GH_TOKEN"] == "secret-token" + assert kwargs["input"] == '{"a": 1}' + monkeypatch.setattr( + module.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(stdout=" "), + ) + assert client.request(["repos/x/y"]) is None + + +def test_load_event_and_main_paths(tmp_path: Path, monkeypatch, capsys) -> None: + """CLI rejects malformed JSON, ignores irrelevant events, and dispatches input.""" + + module = load_module() + array_path = tmp_path / "array.json" + array_path.write_text(json.dumps(["bad"]), encoding="utf-8") + with pytest.raises(ValueError, match="JSON object"): + module.load_event(str(array_path)) + ignored_path = tmp_path / "ignored.json" + ignored_path.write_text(json.dumps(event("nothing")), encoding="utf-8") + assert module.main(["--event-path", str(ignored_path)]) == 0 + assert "nothing to dispatch" in capsys.readouterr().out + monkeypatch.delenv("GITHUB_EVENT_PATH", raising=False) + with pytest.raises(SystemExit): + module.main([]) + valid_path = tmp_path / "valid.json" + valid_path.write_text(json.dumps(event("@opencode-agent")), encoding="utf-8") + captured = [] + monkeypatch.setenv("GH_TOKEN", "token") + monkeypatch.setenv( + "OPENCODE_REPOSITORY_DISPATCH_TARGETS", + "ContextualWisdomLab/example", + ) + monkeypatch.setattr( + module, + "dispatch_request", + lambda request, **kwargs: captured.append((request, kwargs)) or (), + ) + assert module.main(["--event-path", str(valid_path), "--dry-run"]) == 0 + assert captured[0][1]["dry_run"] is True diff --git a/tests/test_agent_mention_sweep.py b/tests/test_agent_mention_sweep.py new file mode 100644 index 000000000..0747bb02b --- /dev/null +++ b/tests/test_agent_mention_sweep.py @@ -0,0 +1,496 @@ +"""Tests for organization-wide pull-request comment mention sweeping.""" + +from __future__ import annotations + +import importlib +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" / "ci" +sys.path.insert(0, str(SCRIPTS)) + + +class FakeClient: + """Endpoint-keyed fake GitHub client for sweep tests.""" + + def __init__(self, responses=None) -> None: + """Initialize response mapping and request ledger.""" + + self.responses = responses or {} + self.calls = [] + + def request(self, args, *, input_payload=None): + """Return the response registered for the first API argument.""" + + self.calls.append((list(args), input_payload)) + return self.responses.get(args[0]) + + +def comment( + comment_id: int, + body: str, + *, + association: str = "MEMBER", + user_type: str = "User", + login: str = "maintainer", +) -> dict: + """Build one issue-comment API object.""" + + return { + "id": comment_id, + "body": body, + "author_association": association, + "user": {"login": login, "type": user_type}, + } + + +def repository( + name: str = "example", + *, + owner: str = "ContextualWisdomLab", + archived: bool = False, + disabled: bool = False, +) -> dict: + """Build one repository API object.""" + + return { + "full_name": f"{owner}/{name}", + "owner": {"login": owner}, + "archived": archived, + "disabled": disabled, + } + + +def candidate(number: int = 7) -> dict: + """Build one normalized pull-request candidate.""" + + return { + "number": number, + "repository": "ContextualWisdomLab/example", + "pull_request": { + "url": ( + "https://api.github.com/repos/ContextualWisdomLab/example/" + f"pulls/{number}" + ) + }, + } + + +def pull_list_item(number: int = 7, updated_at: str = "2026-08-05T11:00:00Z") -> dict: + """Build one pull-list API item.""" + + return {"number": number, "updated_at": updated_at} + + +def live_pull(state: str = "open") -> dict: + """Build live pull-request metadata consumed by the router.""" + + return { + "state": state, + "head": {"sha": "b" * 40}, + "base": {"ref": "main", "sha": "c" * 40}, + } + + +def module(): + """Reload the sweep module for isolated monkeypatching.""" + + return importlib.reload(importlib.import_module("agent_mention_sweep")) + + +def test_timestamp_cutoff_and_page_validation() -> None: + """Timestamps, lookback bounds, and pagination fail closed.""" + + sweep = module() + now = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + assert sweep.parse_timestamp("2026-08-05T11:00:00Z") == datetime( + 2026, + 8, + 5, + 11, + 0, + tzinfo=timezone.utc, + ) + for invalid in ("bad", "2026-08-05T11:00:00"): + with pytest.raises(ValueError, match="timestamp"): + sweep.parse_timestamp(invalid) + assert sweep.cutoff_timestamp(24, now=now) == "2026-08-04T12:00:00Z" + for hours in (0, 721): + with pytest.raises(ValueError, match="lookback"): + sweep.cutoff_timestamp(hours, now=now) + with pytest.raises(ValueError, match="timezone-aware"): + sweep.cutoff_timestamp(1, now=datetime(2026, 8, 5)) + assert sweep.flatten_pages([[{"a": 1}], [{"b": 2}]]) == [ + {"a": 1}, + {"b": 2}, + ] + assert sweep.flatten_pages( + [{"items": [{"a": 1}]}], collection_key="items" + ) == [{"a": 1}] + with pytest.raises(ValueError, match="empty"): + sweep.flatten_pages(None) + with pytest.raises(ValueError, match="page is not an object"): + sweep.flatten_pages([[]], collection_key="items") + with pytest.raises(ValueError, match="not a list"): + sweep.flatten_pages({"items": {}}, collection_key="items") + with pytest.raises(ValueError, match="non-object"): + sweep.flatten_pages([[1]]) + + +def test_accessible_repository_sources_filter_and_validate() -> None: + """PAT and installation-token repository inventories are both supported.""" + + sweep = module() + organization_response = [[ + repository(), + repository("archived", archived=True), + repository("disabled", disabled=True), + repository("outside", owner="outside"), + ]] + organization_client = FakeClient( + {"orgs/ContextualWisdomLab/repos": organization_response} + ) + assert sweep.list_accessible_repositories( + organization_client, + organization="ContextualWisdomLab", + repository_source="organization", + ) == ["ContextualWisdomLab/example"] + installation_client = FakeClient( + { + "installation/repositories": [ + {"repositories": [repository(), repository("second")]} + ] + } + ) + assert sweep.list_accessible_repositories( + installation_client, + organization="ContextualWisdomLab", + repository_source="installation", + ) == ["ContextualWisdomLab/example", "ContextualWisdomLab/second"] + with pytest.raises(ValueError, match="organization"): + sweep.list_accessible_repositories( + organization_client, + organization="bad/name", + repository_source="organization", + ) + with pytest.raises(ValueError, match="repository source"): + sweep.list_accessible_repositories( + organization_client, + organization="ContextualWisdomLab", + repository_source="bad", + ) + invalid_client = FakeClient( + { + "orgs/ContextualWisdomLab/repos": [[ + {**repository(), "full_name": "bad/name"} + ]] + } + ) + with pytest.raises(ValueError, match="full_name"): + sweep.list_accessible_repositories( + invalid_client, + organization="ContextualWisdomLab", + repository_source="organization", + ) + + +def test_recent_pull_request_filtering() -> None: + """Only open accessible PRs updated at or after the cutoff are yielded.""" + + sweep = module() + client = FakeClient( + { + "orgs/ContextualWisdomLab/repos": [[repository()]], + "repos/ContextualWisdomLab/example/pulls": [[ + pull_list_item(7, "2026-08-05T11:00:00Z"), + pull_list_item(8, "2026-08-04T11:59:59Z"), + ]], + } + ) + assert list( + sweep.list_recent_pull_requests( + client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-04T12:00:00Z", + ) + ) == [candidate()] + bad_number_client = FakeClient( + { + "orgs/ContextualWisdomLab/repos": [[repository()]], + "repos/ContextualWisdomLab/example/pulls": [[ + {"number": 0, "updated_at": "2026-08-05T11:00:00Z"} + ]], + } + ) + with pytest.raises(ValueError, match="pull request number"): + list( + sweep.list_recent_pull_requests( + bad_number_client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-04T12:00:00Z", + ) + ) + + +def test_build_requests_ignores_receipt_markers_and_skips_closed_pulls() -> None: + """Target comments are context only; trusted live mentions remain requests.""" + + sweep = module() + comments_endpoint = "repos/ContextualWisdomLab/example/issues/7/comments" + pull_endpoint = "repos/ContextualWisdomLab/example/pulls/7" + comments = [ + comment(10, "@opencode-agent"), + comment( + 11, + "", + user_type="Bot", + login="github-actions[bot]", + ), + comment(12, "@cwl-noema-review"), + comment(13, "@opencode-agent", association="CONTRIBUTOR"), + ] + client = FakeClient({comments_endpoint: [comments], pull_endpoint: live_pull()}) + requests = sweep.build_requests_for_pull_request( + client, issue=candidate(), since="2026-08-04T00:00:00Z" + ) + assert [request.comment_id for request in requests] == [10, 12] + assert [request.agents for request in requests] == [ + ("opencode-agent",), + ("cwl-noema-review",), + ] + assert {request.pull_request_base_sha for request in requests} == {"c" * 40} + closed = FakeClient( + {comments_endpoint: [comments], pull_endpoint: live_pull("closed")} + ) + assert ( + sweep.build_requests_for_pull_request( + closed, issue=candidate(), since="2026-08-04T00:00:00Z" + ) + == () + ) + with pytest.raises(ValueError, match="repository"): + sweep.build_requests_for_pull_request( + client, issue={**candidate(), "repository": "bad/name"}, since="x" + ) + with pytest.raises(ValueError, match="number"): + sweep.build_requests_for_pull_request( + client, issue={**candidate(), "number": 0}, since="x" + ) + + +def mention_request(number: int, comment_id: int, agent: str): + """Build one validated router request for orchestration tests.""" + + router = importlib.import_module("agent_mention_router") + return router.MentionRequest( + "ContextualWisdomLab/example", + number, + "a" * 40, + "main", + comment_id, + "maintainer", + (agent,), + pull_request_base_sha="b" * 40, + ) + + +def test_sweep_dispatches_with_limit_and_reports_empty(monkeypatch, capsys) -> None: + """The sweep bounds source requests that actually queue new agent work.""" + + sweep = module() + request_a = mention_request(7, 10, "opencode-agent") + request_b = mention_request(8, 11, "cwl-noema-review") + monkeypatch.setattr( + sweep, "list_recent_pull_requests", lambda *args, **kwargs: iter([candidate()]) + ) + monkeypatch.setattr( + sweep, + "build_requests_for_pull_request", + lambda *args, **kwargs: (request_a, request_b), + ) + dispatch_calls = [] + + def dispatch_new_work(request, **kwargs): + """Record one call and report its newly queued agent handle.""" + + dispatch_calls.append(request.comment_id) + return (f"@{request.agents[0]}",) + + monkeypatch.setattr(sweep, "dispatch_request", dispatch_new_work) + assert ( + sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=1, + opencode_allowlist=frozenset({"ContextualWisdomLab/example"}), + now=datetime(2026, 8, 5, tzinfo=timezone.utc), + ) + == 1 + ) + assert dispatch_calls == [10] + assert "reached dispatch limit" in capsys.readouterr().out + monkeypatch.setattr( + sweep, "list_recent_pull_requests", lambda *args, **kwargs: iter(()) + ) + assert ( + sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="installation", + lookback_hours=24, + max_dispatches=2, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 5, tzinfo=timezone.utc), + ) + == 0 + ) + assert "0 dispatch" in capsys.readouterr().out + for value in (0, 101): + with pytest.raises(ValueError, match="max dispatches"): + sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=value, + opencode_allowlist=frozenset(), + ) + + +def test_sweep_noops_do_not_starve_new_mentions_across_repeated_runs( + monkeypatch, +) -> None: + """Already-ledgered requests never consume the bounded new-work budget.""" + + sweep = module() + historical = tuple( + mention_request(7, comment_id, "opencode-agent") + for comment_id in range(100, 121) + ) + new_request = mention_request(7, 999, "opencode-agent") + requests = (*historical, new_request) + monkeypatch.setattr( + sweep, "list_recent_pull_requests", lambda *args, **kwargs: iter([candidate()]) + ) + monkeypatch.setattr( + sweep, + "build_requests_for_pull_request", + lambda *args, **kwargs: requests, + ) + ledgered_comment_ids = {request.comment_id for request in historical} + dispatch_calls = [] + + def dispatch_from_ledger(request, **kwargs): + """Return work only for a source request absent from the durable ledger.""" + + dispatch_calls.append(request.comment_id) + if request.comment_id in ledgered_comment_ids: + return () + ledgered_comment_ids.add(request.comment_id) + return ("@opencode-agent",) + + monkeypatch.setattr(sweep, "dispatch_request", dispatch_from_ledger) + sweep_kwargs = { + "target_client": FakeClient(), + "dispatch_client": FakeClient(), + "organization": "ContextualWisdomLab", + "repository_source": "organization", + "lookback_hours": 168, + "max_dispatches": 1, + "opencode_allowlist": frozenset({"ContextualWisdomLab/example"}), + "now": datetime(2026, 8, 5, tzinfo=timezone.utc), + } + + assert sweep.sweep(**sweep_kwargs) == 1 + assert dispatch_calls == [request.comment_id for request in requests] + dispatch_calls.clear() + + assert sweep.sweep(**sweep_kwargs) == 0 + assert dispatch_calls == [request.comment_id for request in requests] + + +def test_sweep_continues_across_empty_results_and_completes( + monkeypatch, capsys +) -> None: + """Empty candidate results do not stop later PR processing.""" + + sweep = module() + request = mention_request(8, 12, "cwl-noema-review") + monkeypatch.setattr( + sweep, + "list_recent_pull_requests", + lambda *args, **kwargs: iter([candidate(), candidate(8)]), + ) + monkeypatch.setattr( + sweep, + "build_requests_for_pull_request", + lambda *args, issue, **kwargs: () if issue["number"] == 7 else (request,), + ) + dispatch_calls = [] + + def dispatch_new_work(request, **kwargs): + """Record and report the one newly queued review agent.""" + + dispatch_calls.append(request.comment_id) + return ("@cwl-noema-review",) + + monkeypatch.setattr(sweep, "dispatch_request", dispatch_new_work) + assert ( + sweep.sweep( + target_client=FakeClient(), + dispatch_client=FakeClient(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=2, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 5, tzinfo=timezone.utc), + ) + == 1 + ) + assert dispatch_calls == [12] + assert "completed with 1 dispatch" in capsys.readouterr().out + + +def test_main_constructs_clients_and_forwards_options(monkeypatch) -> None: + """CLI reads credentials, parses allowlist, and forwards bounded options.""" + + sweep = module() + captured = [] + monkeypatch.setenv("TARGET_REPOSITORY_TOKEN", "target") + monkeypatch.setenv("AGENT_DISPATCH_TOKEN", "dispatch") + monkeypatch.setenv( + "OPENCODE_REPOSITORY_DISPATCH_TARGETS", "ContextualWisdomLab/example" + ) + monkeypatch.setattr(sweep, "sweep", lambda **kwargs: captured.append(kwargs) or 0) + assert ( + sweep.main( + [ + "--organization", + "ContextualWisdomLab", + "--repository-source", + "installation", + "--lookback-hours", + "48", + "--max-dispatches", + "3", + "--dry-run", + ] + ) + == 0 + ) + assert captured[0]["repository_source"] == "installation" + assert captured[0]["lookback_hours"] == 48 + assert captured[0]["max_dispatches"] == 3 + assert captured[0]["dry_run"] is True diff --git a/tests/test_agent_mention_sweep_regressions.py b/tests/test_agent_mention_sweep_regressions.py new file mode 100644 index 000000000..d9c0c4f2a --- /dev/null +++ b/tests/test_agent_mention_sweep_regressions.py @@ -0,0 +1,265 @@ +"""Review-driven pagination and failure-isolation regressions.""" + +from __future__ import annotations + +import importlib +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" / "ci" +sys.path.insert(0, str(SCRIPTS)) + + +def module(): + """Reload the sweep module for isolated monkeypatching.""" + + return importlib.reload(importlib.import_module("agent_mention_sweep")) + + +def repository(name: str) -> dict: + """Build one active organization repository record.""" + + return { + "full_name": f"ContextualWisdomLab/{name}", + "owner": {"login": "ContextualWisdomLab"}, + "archived": False, + "disabled": False, + } + + +class PagingClient: + """Serve page-aware endpoint responses and deterministic failures.""" + + def __init__(self, responses) -> None: + """Initialize an endpoint/page response map.""" + + self.responses = responses + self.calls: list[list[str]] = [] + + def request(self, args, *, input_payload=None): + """Return one endpoint/page response or raise its configured error.""" + + del input_payload + args = list(args) + self.calls.append(args) + endpoint = args[0] + page = 1 + for index, value in enumerate(args[:-1]): + if value == "-f" and args[index + 1].startswith("page="): + page = int(args[index + 1].split("=", 1)[1]) + response = self.responses[(endpoint, page)] + if isinstance(response, Exception): + raise response + return response + + +def pull(number: int, updated_at: str = "2026-08-06T11:00:00Z") -> dict: + """Build one pull-list response record.""" + + return {"number": number, "updated_at": updated_at} + + +def test_pull_pagination_stops_at_cutoff_without_loading_later_pages() -> None: + """Updated-descending pages stop immediately at the first old record.""" + + sweep = module() + recent = [pull(number) for number in range(1, 101)] + client = PagingClient( + { + ("orgs/ContextualWisdomLab/repos", 1): [[repository("example")]], + ("repos/ContextualWisdomLab/example/pulls", 1): recent, + ("repos/ContextualWisdomLab/example/pulls", 2): [ + pull(101, "2026-08-01T00:00:00Z") + ], + } + ) + results = list( + sweep.list_recent_pull_requests( + client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-05T00:00:00Z", + ) + ) + assert len(results) == 100 + pull_calls = [ + args for args in client.calls if args[0].endswith("/pulls") + ] + assert len(pull_calls) == 2 + assert not any("page=3" in args for args in pull_calls) + assert sweep.flatten_pages([{"number": 1}]) == [{"number": 1}] + + +def test_pull_pagination_stops_on_empty_followup_page() -> None: + """A full page followed by an empty page terminates without page three.""" + + sweep = module() + recent = [pull(number) for number in range(1, 101)] + client = PagingClient( + { + ("orgs/ContextualWisdomLab/repos", 1): [[repository("example")]], + ("repos/ContextualWisdomLab/example/pulls", 1): recent, + ("repos/ContextualWisdomLab/example/pulls", 2): [], + } + ) + results = list( + sweep.list_recent_pull_requests( + client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-05T00:00:00Z", + ) + ) + assert len(results) == 100 + pull_calls = [ + args for args in client.calls if args[0].endswith("/pulls") + ] + assert len(pull_calls) == 2 + assert any("page=2" in args for args in pull_calls) + assert not any("page=3" in args for args in pull_calls) + + +def test_invalid_pull_number_fails_closed_without_error_sink() -> None: + """Malformed pull metadata raises when no isolation sink is supplied.""" + + sweep = module() + client = PagingClient( + { + ("orgs/ContextualWisdomLab/repos", 1): [[repository("example")]], + ("repos/ContextualWisdomLab/example/pulls", 1): [pull(0)], + } + ) + with pytest.raises(ValueError, match="invalid pull request number"): + list( + sweep.list_recent_pull_requests( + client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-05T00:00:00Z", + ) + ) + + +def test_repository_failure_is_isolated_and_later_repository_runs() -> None: + """A repository-local API failure does not terminate organization traversal.""" + + sweep = module() + client = PagingClient( + { + ("orgs/ContextualWisdomLab/repos", 1): [[ + repository("broken"), + repository("healthy"), + ]], + ("repos/ContextualWisdomLab/broken/pulls", 1): RuntimeError( + "forbidden" + ), + ("repos/ContextualWisdomLab/healthy/pulls", 1): [pull(7)], + } + ) + failures = [] + results = list( + sweep.list_recent_pull_requests( + client, + organization="ContextualWisdomLab", + repository_source="organization", + since="2026-08-05T00:00:00Z", + on_error=lambda scope, error: failures.append( + (scope, str(error)) + ), + ) + ) + assert [result["repository"] for result in results] == [ + "ContextualWisdomLab/healthy" + ] + assert failures == [("ContextualWisdomLab/broken", "forbidden")] + + +def mention_request(comment_id: int): + """Build one Noema request for orchestration isolation tests.""" + + router = importlib.import_module("agent_mention_router") + return router.MentionRequest( + "ContextualWisdomLab/example", + 7, + "a" * 40, + "main", + comment_id, + "maintainer", + ("cwl-noema-review",), + ) + + +def test_sweep_continues_after_candidate_and_dispatch_failures( + monkeypatch, + capsys, +) -> None: + """Candidate-local failures are counted while later work is queued.""" + + sweep = module() + issues = [ + {"repository": "ContextualWisdomLab/example", "number": 7}, + {"repository": "ContextualWisdomLab/example", "number": 8}, + ] + monkeypatch.setattr( + sweep, + "list_recent_pull_requests", + lambda *args, **kwargs: iter(issues), + ) + + def build_requests(client, *, issue, since): + del client, since + if issue["number"] == 7: + raise RuntimeError("comment inventory failed") + return (mention_request(10), mention_request(11)) + + monkeypatch.setattr(sweep, "build_requests_for_pull_request", build_requests) + dispatch_kwargs = [] + + def dispatch(request, **kwargs): + dispatch_kwargs.append(kwargs) + if request.comment_id == 10: + raise RuntimeError("dispatch failed") + return ("@cwl-noema-review",) + + monkeypatch.setattr(sweep, "dispatch_request", dispatch) + metrics = sweep.SweepMetrics() + assert sweep.sweep( + target_client=object(), + dispatch_client=object(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=5, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 6, tzinfo=timezone.utc), + metrics=metrics, + ) == 1 + assert metrics.failures == 2 + assert dispatch_kwargs[0]["ledger_artifact_cache"] is dispatch_kwargs[1][ + "ledger_artifact_cache" + ] + assert dispatch_kwargs[0]["dry_run"] is False + output = capsys.readouterr().out + assert "comment inventory failed" in output + assert "dispatch failed" in output + + +def test_main_returns_failure_when_isolated_errors_were_observed( + monkeypatch, +) -> None: + """The scheduled workflow remains visibly failed after partial progress.""" + + sweep = module() + monkeypatch.setenv("TARGET_REPOSITORY_TOKEN", "target") + monkeypatch.setenv("AGENT_DISPATCH_TOKEN", "dispatch") + + def fail_partially(**kwargs): + kwargs["metrics"].failures = 1 + return 0 + + monkeypatch.setattr(sweep, "sweep", fail_partially) + assert sweep.main([]) == 1 diff --git a/tests/test_agent_mention_workflow_contract.py b/tests/test_agent_mention_workflow_contract.py new file mode 100644 index 000000000..c5fc4cae5 --- /dev/null +++ b/tests/test_agent_mention_workflow_contract.py @@ -0,0 +1,62 @@ +"""Static least-privilege and trigger contract for agent mention automation.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "agent-mention-router.yml" +QUALITY_WORKFLOW = ( + ROOT / ".github" / "workflows" / "agent-mention-router-quality-ci.yml" +) +CHECKOUT_PIN = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1" + + +def test_workflow_uses_local_event_and_central_sweep_with_job_scoped_writes() -> None: + """The router is central-only, scheduled, and least-privileged.""" + + text = WORKFLOW.read_text(encoding="utf-8") + header, jobs = text.split("\njobs:\n", 1) + assert "issue_comment:" in header + assert 'cron: "*/5 * * * *"' in header + assert "workflow_dispatch:" not in header + assert "permissions:\n contents: read" in header + assert "contents: write" not in header + assert text.count("runs-on: ubuntu-24.04") == 2 + assert text.count(CHECKOUT_PIN) == 2 + assert "ubuntu-latest" not in text + assert "actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8" not in text + + local, sweep = jobs.split("\n sweep-organization-agent-mentions:\n", 1) + assert "route-local-agent-mention:" in local + assert "github.repository == 'ContextualWisdomLab/.github'" in local + for permission in ( + "actions: read", + "contents: write", + "issues: write", + "pull-requests: read", + ): + assert f" {permission}" in local + assert "ref: ${{ github.event.repository.default_branch }}" in local + assert "TARGET_REPOSITORY_TOKEN: ${{ github.token }}" in local + assert "conversation_comments" not in local + + for permission in ("actions: read", "contents: write", "id-token: write"): + assert f" {permission}" in sweep + assert "github.repository == 'ContextualWisdomLab/.github'" in sweep + assert "github.event_name == 'schedule'" in sweep + assert "github.event_name == 'workflow_dispatch'" not in sweep + assert "secrets.PR_REVIEW_MERGE_TOKEN" in sweep + assert "secrets.OPENCODE_APPROVE_TOKEN" in sweep + assert "TARGET_REPOSITORY_SOURCE" in sweep + assert "AGENT_DISPATCH_TOKEN: ${{ github.token }}" in sweep + assert "agent_mention_sweep.py" in sweep + + +def test_quality_workflow_measures_exact_files_without_module_name_warnings() -> None: + """Coverage includes the two script paths instead of treating paths as modules.""" + + text = QUALITY_WORKFLOW.read_text(encoding="utf-8") + coverage_config = text.split("[run]\n", 1)[1].split("[report]\n", 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 diff --git a/tests/test_pr_review_fix_scheduler_coverage.py b/tests/test_pr_review_fix_scheduler_coverage.py index 11c5d48f9..d79956714 100644 --- a/tests/test_pr_review_fix_scheduler_coverage.py +++ b/tests/test_pr_review_fix_scheduler_coverage.py @@ -1,6 +1,48 @@ + +"""Coverage-only regressions for the review-fix scheduler.""" + +import builtins +import runpy + import scripts.ci.pr_review_fix_scheduler as fix + +def test_import_falls_back_to_package_module(monkeypatch): + """The scheduler remains importable when only the package path is available.""" + + real_import = builtins.__import__ + + def import_without_script_directory( + name, + globals_=None, + locals_=None, + fromlist=(), + level=0, + ): + """Reject the script-directory import and delegate every other import.""" + + if name == "pr_review_merge_scheduler": + raise ModuleNotFoundError(name) + return real_import(name, globals_, locals_, fromlist, level) + + monkeypatch.setattr( + builtins, + "__import__", + import_without_script_directory, + ) + namespace = runpy.run_path( + "scripts/ci/pr_review_fix_scheduler.py", + run_name="pr_review_fix_scheduler_package_fallback_test", + ) + + loaded = namespace["fetch_open_prs"] + assert loaded.__name__ == fix.fetch_open_prs.__name__ + assert loaded.__code__.co_filename == fix.fetch_open_prs.__code__.co_filename + + def test_coverage_process_queue_skips_draft_and_wrong_base_and_external_repo(monkeypatch): + """Draft, wrong-base, and external-head PRs are skipped.""" + def make_pr(number=1, **kwargs): pr = { "number": number, @@ -14,17 +56,25 @@ def make_pr(number=1, **kwargs): return pr args = fix.parse_args(["--repo", "owner/repo", "--base-branch", "main"]) - pr1 = make_pr(number=1, isDraft=True) pr2 = make_pr(number=2, baseRefName="other") pr3 = make_pr(number=3, headRepository={"nameWithOwner": "fork/repo"}) - - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr1, pr2, pr3]) - monkeypatch.setattr(fix, "inspect_pr", lambda repo, pr, args, **kwargs: ("skip", ("skip reason",))) - + monkeypatch.setattr( + fix, + "fetch_open_prs", + lambda repo, max_prs: [pr1, pr2, pr3], + ) + monkeypatch.setattr( + fix, + "inspect_pr", + lambda repo, pr, args, **kwargs: ("skip", ("skip reason",)), + ) assert fix.process_queue(args) == 0 + def test_coverage_process_queue_exception_handling(monkeypatch): + """One issue-comment lookup failure does not crash queue processing.""" + def make_pr(number=1, **kwargs): pr = { "number": number, @@ -38,10 +88,8 @@ def make_pr(number=1, **kwargs): return pr args = fix.parse_args(["--repo", "owner/repo", "--base-branch", "main"]) - pr1 = make_pr(number=1) pr2 = make_pr(number=2) - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr1, pr2]) monkeypatch.setattr(fix, "needs_autofix", lambda pr: (True, ("reason",))) @@ -49,6 +97,9 @@ def raise_error(repo, number): raise RuntimeError("boom") monkeypatch.setattr(fix, "issue_comments", raise_error) - monkeypatch.setattr(fix, "inspect_pr", lambda repo, pr, args, **kwargs: ("skip", ("skip reason",))) - + monkeypatch.setattr( + fix, + "inspect_pr", + lambda repo, pr, args, **kwargs: ("skip", ("skip reason",)), + ) assert fix.process_queue(args) == 0