diff --git a/.github/workflows/agent-mention-opencode-dispatch.yml b/.github/workflows/agent-mention-opencode-dispatch.yml new file mode 100644 index 000000000..c1292a5bc --- /dev/null +++ b/.github/workflows/agent-mention-opencode-dispatch.yml @@ -0,0 +1,213 @@ +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 || 'true' }} + REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.review_dispatch_limit || '1' }} + ENABLE_AUTO_MERGE: ${{ github.event.client_payload.enable_auto_merge || 'false' }} + UPDATE_BRANCHES: ${{ github.event.client_payload.update_branches || 'false' }} + MERGE_MODE: ${{ github.event.client_payload.merge_mode || 'disabled' }} + 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" \ + '{ + 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", + } + }' \ + | gh api "repos/${GITHUB_REPOSITORY}/dispatches" -X POST --input - 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/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py new file mode 100644 index 000000000..ae65d4cb7 --- /dev/null +++ b/scripts/ci/agent_mention_router.py @@ -0,0 +1,559 @@ +#!/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, + "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())