From 9e0eb23d7fb97850e1b4d8f7c7e28bece5fc4900 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:09:15 +0000 Subject: [PATCH 1/3] Initial plan From 821bf34646f13ddd3704958878a2135946881887 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:10:56 +0000 Subject: [PATCH 2/3] Initial plan for PR #31 review fixes --- deploy/coven-github/coven_github_adapter.py | 1469 +++++++++++++++++++ docs/headless-contract.md | 60 +- 2 files changed, 1516 insertions(+), 13 deletions(-) create mode 100644 deploy/coven-github/coven_github_adapter.py diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py new file mode 100644 index 0000000..1ed3f3c --- /dev/null +++ b/deploy/coven-github/coven_github_adapter.py @@ -0,0 +1,1469 @@ +import base64 +import hashlib +import hmac +import json +import os +import re +import subprocess +import tempfile +import time +import traceback +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +ROOT_DIR = Path(__file__).resolve().parent +STATE_DIR = Path(os.environ.get("COVEN_GITHUB_STATE_DIR", ROOT_DIR / "coven-github-state")) +DELIVERIES_DIR = STATE_DIR / "deliveries" +TASKS_DIR = STATE_DIR / "tasks" +WORKSPACES_DIR = STATE_DIR / "workspaces" +ATTEMPTS_DIR = STATE_DIR / "attempts" +POLICY_PATH = Path(os.environ.get("COVEN_GITHUB_POLICY_PATH", ROOT_DIR / "coven-github-policy.json")) +PRIVATE_KEY_PATH = Path( + os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH", ROOT_DIR / ".coven-github-private-key.pem") +) +APP_ID = os.environ.get("GITHUB_APP_ID", "").strip() +COVEN_CODE_BIN = os.environ.get("COVEN_CODE_BIN", "coven-code").strip() or "coven-code" +COVEN_CODE_MODEL = os.environ.get("COVEN_CODE_MODEL", "gpt-5.5").strip() +WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "").strip() + + +def env_int(name, default, minimum=0, maximum=10): + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + return default + return max(minimum, min(maximum, value)) + + +MAX_REVIEW_FIX_LOOPS = env_int("COVEN_REVIEW_FIX_LOOPS", 0, minimum=0, maximum=5) +TERMINAL_RESULT_EXIT_CODES = {0, 1, 3} +RESULT_STATUSES = {"success", "failure", "partial", "needs_input"} +REVIEW_MODES = {"none", "pull_request", "review_comment"} +REVIEW_EVIDENCE_STATUSES = {"not_applicable", "complete", "partial", "missing"} +RESULT_FIELDS = { + "contract_version", + "status", + "branch", + "commits", + "files_changed", + "summary", + "pr_body", + "review", + "exit_reason", +} +COMMIT_FIELDS = {"sha", "message"} +REVIEW_FIELDS = { + "mode", + "evidence_status", + "reviewed_files", + "supporting_files", + "findings", + "tests_run", + "no_findings_reason", + "limitations", +} +FINDING_FIELDS = {"severity", "file", "line", "title", "body", "recommendation"} +TEST_RUN_FIELDS = {"command", "status", "output_summary"} + + +def account_home(): + try: + import pwd + + return Path(pwd.getpwuid(os.getuid()).pw_dir) + except Exception: + return Path.home() + + +def configured_codex_tokens_path(): + configured = os.environ.get("COVEN_CODE_CODEX_TOKENS_PATH", "").strip() + if configured: + return Path(configured).expanduser() + return account_home() / ".coven-code" / "codex_tokens.json" + + +CODEX_TOKENS_PATH = configured_codex_tokens_path() + +for directory in (DELIVERIES_DIR, TASKS_DIR, WORKSPACES_DIR, ATTEMPTS_DIR): + directory.mkdir(parents=True, exist_ok=True) + + +DEFAULT_FAMILIAR = { + "id": "cody", + "display_name": "Cody", + "model": None, + "skills": ["code-review"], +} + + +DEFAULT_POLICY = { + "version": 1, + "installations": {}, +} + + +def utc_now(): + return datetime.now(timezone.utc).isoformat() + + +def read_json(path, default): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError: + return default + + +def write_json_atomic(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2) + handle.write("\n") + os.replace(tmp_name, str(path)) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def b64url(raw): + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def sign_rs256(message): + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + + key_data = PRIVATE_KEY_PATH.read_bytes() + private_key = serialization.load_pem_private_key(key_data, password=None) + return private_key.sign(message, padding.PKCS1v15(), hashes.SHA256()) + + +def github_app_jwt(): + if not APP_ID: + raise RuntimeError("GITHUB_APP_ID is required") + now = int(time.time()) + header = {"alg": "RS256", "typ": "JWT"} + payload = {"iat": now - 60, "exp": now + 540, "iss": APP_ID} + signing_input = ( + b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")) + + "." + + b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + ).encode("ascii") + return signing_input.decode("ascii") + "." + b64url(sign_rs256(signing_input)) + + +def github_request(method, url, token, body=None): + headers = { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer " + token, + "User-Agent": "coven-github-hosted-prototype", + "X-GitHub-Api-Version": "2022-11-28", + } + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + raise RuntimeError("GitHub API {} {} failed: {}".format(method, url, raw)) + + +def installation_token(installation_id): + app_token = github_app_jwt() + response = github_request( + "POST", + "https://api.github.com/app/installations/{}/access_tokens".format(installation_id), + app_token, + {}, + ) + token = response.get("token") + if not token: + raise RuntimeError("GitHub installation token response did not include token") + return token + + +def load_policy(): + if not POLICY_PATH.exists(): + write_json_atomic(POLICY_PATH, DEFAULT_POLICY) + return read_json(POLICY_PATH, DEFAULT_POLICY) + + +def repo_policy(payload): + policy = load_policy() + installation_id = str((payload.get("installation") or {}).get("id") or "") + repository = payload.get("repository") or {} + repo_id = str(repository.get("id") or "") + installation = (policy.get("installations") or {}).get(installation_id) or {} + repo = (installation.get("repositories") or {}).get(repo_id) + return installation_id, repo_id, repo + + +def delivery_path(delivery_id): + return DELIVERIES_DIR / (delivery_id + ".json") + + +def task_path(task_id): + return TASKS_DIR / (task_id + ".json") + + +def payload_hash(payload): + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def delivery_record(delivery_id, event_name, payload): + repository = payload.get("repository") or {} + installation = payload.get("installation") or {} + return { + "delivery_id": delivery_id, + "event": event_name, + "action": payload.get("action"), + "installation_id": installation.get("id"), + "repository_id": repository.get("id"), + "repository": repository.get("full_name"), + "payload_hash": payload_hash(payload), + "received_at": utc_now(), + "state": "received", + "issue_refs": ["OpenCoven/coven-github#2"], + } + + +def mentioned(text, policy): + normalized = text or "" + for username in policy.get("bot_usernames") or []: + login = str(username).strip() + if not login: + continue + pattern = r"(?= 1 or null".format( + index + ) + ) + require_string(finding.get("title"), "result.review.findings[{}].title".format(index)) + require_string(finding.get("body"), "result.review.findings[{}].body".format(index)) + require_optional_string( + finding.get("recommendation"), + "result.review.findings[{}].recommendation".format(index), + ) + + +def validate_tests_run(tests_run): + if not isinstance(tests_run, list): + raise ValueError("result.review.tests_run must be an array") + statuses = {"passed", "failed", "not_run", "unknown"} + for index, test in enumerate(tests_run): + if not isinstance(test, dict): + raise ValueError("result.review.tests_run[{}] must be an object".format(index)) + validate_object_fields(test, TEST_RUN_FIELDS, "result.review.tests_run[{}]".format(index)) + for field in TEST_RUN_FIELDS: + if field not in test: + raise ValueError( + "result.review.tests_run[{}] missing required field {}".format(index, field) + ) + require_string(test.get("command"), "result.review.tests_run[{}].command".format(index)) + status = test.get("status") + if status not in statuses: + raise ValueError("unsupported review test status {}".format(status)) + require_optional_string( + test.get("output_summary"), + "result.review.tests_run[{}].output_summary".format(index), + ) + + +def validate_result_contract(result): + if not isinstance(result, dict): + raise ValueError("result.json must be a JSON object") + + validate_object_fields(result, RESULT_FIELDS, "result.json") + for field in ("contract_version", "status", "commits", "files_changed", "summary", "pr_body", "review"): + if field not in result: + raise ValueError("result.json missing required field {}".format(field)) + + if result.get("contract_version") != "2": + raise ValueError("unsupported result contract_version {}".format(result.get("contract_version"))) + if result.get("status") not in RESULT_STATUSES: + raise ValueError("unsupported result status {}".format(result.get("status"))) + status = result.get("status") + validate_commits(result.get("commits")) + validate_string_array(result.get("files_changed"), "result.files_changed") + if not isinstance(result.get("summary"), str): + raise ValueError("result.summary must be a string") + if not isinstance(result.get("pr_body"), str): + raise ValueError("result.pr_body must be a string") + if result.get("branch") is not None and not isinstance(result.get("branch"), str): + raise ValueError("result.branch must be a string or null") + if result.get("exit_reason") not in ( + "test_failure", + "ambiguous_spec", + "git_conflict", + "infra_error", + None, + ): + raise ValueError("unsupported result exit_reason {}".format(result.get("exit_reason"))) + if status == "success" and result.get("exit_reason") is not None: + raise ValueError("result.exit_reason must be null when status is success") + if status != "success" and result.get("exit_reason") is None: + raise ValueError("result.exit_reason is required when status is {}".format(status)) + + review = result.get("review") + if not isinstance(review, dict): + raise ValueError("result.review must be an object") + validate_object_fields(review, REVIEW_FIELDS, "result.review") + for field in REVIEW_FIELDS: + if field not in review: + raise ValueError("result.review missing required field {}".format(field)) + + mode = review.get("mode") + evidence_status = review.get("evidence_status") + if mode not in REVIEW_MODES: + raise ValueError("unsupported review mode {}".format(mode)) + if evidence_status not in REVIEW_EVIDENCE_STATUSES: + raise ValueError("unsupported review evidence_status {}".format(evidence_status)) + + validate_string_array(review.get("reviewed_files"), "result.review.reviewed_files") + validate_string_array(review.get("supporting_files"), "result.review.supporting_files") + validate_findings(review.get("findings")) + validate_tests_run(review.get("tests_run")) + require_optional_string(review.get("no_findings_reason"), "result.review.no_findings_reason") + validate_string_array(review.get("limitations"), "result.review.limitations") + + if mode in ("pull_request", "review_comment"): + if evidence_status == "not_applicable": + raise ValueError("review evidence_status not_applicable is invalid for {}".format(mode)) + if evidence_status != "missing" and not review.get("reviewed_files"): + raise ValueError("reviewed_files is required for review mode {}".format(mode)) + no_findings_reason = review.get("no_findings_reason") + if not review.get("findings") and not ( + isinstance(no_findings_reason, str) and no_findings_reason.strip() + ): + raise ValueError("no_findings_reason is required when review findings are empty") + if mode == "none" and evidence_status != "not_applicable": + raise ValueError("review evidence_status {} is invalid for none mode".format(evidence_status)) + + +def session_brief(task, workspace, review_context=None, extra_audit_instruction=None): + owner, name = (task["repository"] or "/").split("/", 1) + brief = { + "contract_version": "2", + "trigger": task["trigger"], + "repo": { + "owner": owner, + "name": name, + "clone_url": task["clone_url"], + "default_branch": task["default_branch"], + }, + "task": task["task"], + "familiar": task["familiar"], + "workspace": {"root": str(workspace)}, + } + if review_context: + brief["review_context"] = review_context + instruction = ( + "This run is evidence-backed. Review the supplied PR metadata and " + "changed-file patches in review_context before responding. Cite the " + "specific changed files you inspected in the result summary." + ) + if extra_audit_instruction: + instruction = instruction + "\n\n" + extra_audit_instruction + brief["audit_instruction"] = instruction + return brief + + +def run_coven_code_cycle( + task, + workspace, + review_context, + attempt_dir, + env, + cycle, + extra_audit_instruction=None, +): + suffix = "" if cycle == 0 else "-repair-{}".format(cycle) + brief_path = attempt_dir / "session-brief{}.json".format(suffix) + result_path = attempt_dir / "result{}.json".format(suffix) + run_path = attempt_dir / "run{}.json".format(suffix) + + write_json_atomic( + brief_path, + session_brief(task, workspace, review_context, extra_audit_instruction), + ) + run = run_command( + [ + COVEN_CODE_BIN, + "--headless", + "--hosted-review", + "--provider", + "codex", + "--model", + COVEN_CODE_MODEL, + "--context", + str(brief_path), + "--output", + str(result_path), + ], + cwd=str(workspace), + env=env, + timeout=1800, + ) + write_json_atomic(run_path, redacted_command_result(run)) + result = None + result_error = None + if result_path.exists(): + try: + result = read_json(result_path, None) + validate_result_contract(result) + except Exception as exc: + result_error = str(exc) + result = None + return { + "cycle": cycle, + "brief_path": brief_path, + "result_path": result_path, + "run_path": run_path, + "run": run, + "result": result, + "result_error": result_error, + } + + +def review_findings(result): + if not result: + return [] + review = result.get("review") or {} + mode = review.get("mode") + if mode not in ("pull_request", "review_comment"): + return [] + return review.get("findings") or [] + + +def review_fix_instruction(findings, iteration, max_iterations): + lines = [ + "Autofix review loop iteration {}/{}.".format(iteration, max_iterations), + "The previous hosted review returned structured findings. Fix the findings below, run the relevant checks you can run safely, then perform another bounded review of the updated code using the required review sections.", + "If a finding cannot be fixed safely, leave a clear limitation and explain the remaining blocker. Do not merely restate the findings.", + "", + "Findings to fix:", + ] + for index, finding in enumerate(findings[:10], start=1): + location = finding.get("file") or "unknown file" + if finding.get("line") is not None: + location = "{}:{}".format(location, finding.get("line")) + lines.append( + "{}. [{}] `{}` {}".format( + index, + finding.get("severity") or "unknown", + location, + finding.get("title") or "Untitled finding", + ) + ) + body = (finding.get("body") or "").strip() + if body: + lines.append(" Body: {}".format(body[:1200])) + recommendation = (finding.get("recommendation") or "").strip() + if recommendation: + lines.append(" Recommendation: {}".format(recommendation[:1200])) + if len(findings) > 10: + lines.append("Only the first 10 findings are listed; inspect the prior result for the full set.") + return "\n".join(lines) + + +def task_with_repair_request(task, instruction): + copy = json.loads(json.dumps(task)) + task_data = copy.get("task") or {} + explicit_request = ( + "\n\nPlease fix the review findings from the previous hosted review cycle. " + "After fixing them, rerun relevant checks and produce another structured review.\n\n" + + instruction + ) + if "comment_body" in task_data: + task_data["comment_body"] = (task_data.get("comment_body") or "") + explicit_request + elif "issue_body" in task_data: + task_data["issue_body"] = (task_data.get("issue_body") or "") + explicit_request + copy["task"] = task_data + return copy + + +def run_task(task_id, debug): + path = task_path(task_id) + task = read_json(path, {}) + if task.get("state") != "queued": + return task + + task["state"] = "running" + task["attempts"] = int(task.get("attempts") or 0) + 1 + task["updated_at"] = utc_now() + write_json_atomic(path, task) + + attempt_dir = ATTEMPTS_DIR / task_id / str(task["attempts"]) + attempt_dir.mkdir(parents=True, exist_ok=True) + workspace = WORKSPACES_DIR / task_id / "repo" + + try: + token = installation_token(task["installation_id"]) + askpass = write_askpass(attempt_dir) + env = os.environ.copy() + env["GIT_ASKPASS"] = str(askpass) + env["GIT_TERMINAL_PROMPT"] = "0" + env["COVEN_GIT_TOKEN"] = token + env["COVEN_CODE_PROVIDER"] = "codex" + env["COVEN_CODE_HOSTED_REVIEW"] = "1" + env["HOME"] = str(account_home()) + codex_access_token = load_codex_access_token() + if not codex_access_token: + return fail_task( + path, + task, + "codex_auth_missing", + "Missing Codex access token at {}".format(CODEX_TOKENS_PATH), + ) + env["OPENAI_API_KEY"] = codex_access_token + + if not workspace.exists(): + clone = run_command( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + task["default_branch"], + task["clone_url"], + str(workspace), + ], + env=env, + timeout=180, + ) + write_json_atomic(attempt_dir / "clone.json", redacted_command_result(clone)) + if clone["returncode"] != 0: + return fail_task(path, task, "clone_failed", clone["stderr"]) + + review_context = prepare_review_context(task, workspace, token, env, attempt_dir) + if review_context: + review_context_path = attempt_dir / "review-context.json" + write_json_atomic(review_context_path, review_context) + task["review_context_path"] = str(review_context_path) + task["review_context_sha256"] = file_sha256(review_context_path) + task["review_evidence"] = review_evidence(review_context, review_context_path, task) + write_json_atomic(path, task) + + if not command_exists(COVEN_CODE_BIN): + return fail_task( + path, + task, + "runtime_missing", + "COVEN_CODE_BIN is not available on the host: {}".format(COVEN_CODE_BIN), + ) + + cycle_result = run_coven_code_cycle(task, workspace, review_context, attempt_dir, env, 0) + brief_path = cycle_result["brief_path"] + result_path = cycle_result["result_path"] + run = cycle_result["run"] + task["session_brief_path"] = str(brief_path) + task["session_brief_sha256"] = file_sha256(brief_path) + task["runtime_exit_code"] = run["returncode"] + task["result_path"] = str(result_path) + write_json_atomic(path, task) + + if run["returncode"] not in TERMINAL_RESULT_EXIT_CODES: + return fail_task( + path, + task, + "runtime_non_terminal", + "coven-code exited {} before a terminal result could be used: {}".format( + run["returncode"], run["stderr"] + ), + ) + if cycle_result["result"] is None: + reason = "result_invalid" if cycle_result.get("result_error") else "result_missing" + detail = cycle_result.get("result_error") or "coven-code exited {} without writing result.json: {}".format( + run["returncode"], run["stderr"] + ) + return fail_task( + path, + task, + reason, + detail, + ) + + final_cycle = cycle_result + loop_records = [] + for iteration in range(1, MAX_REVIEW_FIX_LOOPS + 1): + findings = review_findings(final_cycle["result"]) + if not findings: + break + instruction = review_fix_instruction(findings, iteration, MAX_REVIEW_FIX_LOOPS) + repair_task = task_with_repair_request(task, instruction) + repair_cycle = run_coven_code_cycle( + repair_task, + workspace, + review_context, + attempt_dir, + env, + iteration, + instruction, + ) + if repair_cycle["run"]["returncode"] not in TERMINAL_RESULT_EXIT_CODES: + return fail_task( + path, + task, + "runtime_non_terminal", + "review repair loop {} exited {} before a terminal result could be used: {}".format( + iteration, + repair_cycle["run"]["returncode"], + repair_cycle["run"]["stderr"], + ), + ) + if repair_cycle["result"] is None: + reason = "result_invalid" if repair_cycle.get("result_error") else "result_missing" + detail = repair_cycle.get("result_error") or ( + "review repair loop {} exited {} without writing result.json: {}".format( + iteration, + repair_cycle["run"]["returncode"], + repair_cycle["run"]["stderr"], + ) + ) + return fail_task( + path, + task, + reason, + detail, + ) + remaining = review_findings(repair_cycle["result"]) + loop_records.append( + { + "iteration": iteration, + "input_findings": len(findings), + "runtime_exit_code": repair_cycle["run"]["returncode"], + "result_path": str(repair_cycle["result_path"]), + "result_status": (repair_cycle["result"] or {}).get("status"), + "remaining_findings": len(remaining), + } + ) + task["review_fix_loops"] = loop_records + task["runtime_exit_code"] = repair_cycle["run"]["returncode"] + task["result_path"] = str(repair_cycle["result_path"]) + task["updated_at"] = utc_now() + write_json_atomic(path, task) + final_cycle = repair_cycle + + result_path = final_cycle["result_path"] + run = final_cycle["run"] + task["runtime_exit_code"] = run["returncode"] + task["result_path"] = str(result_path) + task["state"] = "completed" if run["returncode"] in (0, 1, 3) else "failed" + task["updated_at"] = utc_now() + publish_result_if_configured(task, result_path, token) + write_json_atomic(path, task) + return task + except Exception as exc: + return fail_task(path, task, "infra_error", repr(exc)) + + +def command_exists(command): + probe = run_command(["/bin/sh", "-lc", "command -v {}".format(shell_quote(command))], timeout=10) + return probe["returncode"] == 0 + + +def shell_quote(value): + return "'" + str(value).replace("'", "'\"'\"'") + "'" + + +def pr_number_for_task(task): + task_data = task.get("task") or {} + target = task.get("target") or {} + if target.get("kind") == "pull_request" and target.get("pr_number"): + return int(target.get("pr_number")) + value = task_data.get("pr_number") + if value: + return int(value) + return None + + +def prepare_review_context(task, workspace, token, env, attempt_dir): + pr_number = pr_number_for_task(task) + if not pr_number: + return None + + repo = task.get("repository") + pr = github_request( + "GET", + "https://api.github.com/repos/{}/pulls/{}".format(repo, pr_number), + token, + ) + files = paginated_pr_files(repo, pr_number, token) + + fetch = run_command( + ["git", "fetch", "--depth", "1", "origin", "pull/{}/head".format(pr_number)], + cwd=str(workspace), + env=env, + timeout=180, + ) + write_json_atomic(attempt_dir / "fetch-pr.json", redacted_command_result(fetch)) + if fetch["returncode"] != 0: + raise RuntimeError("failed to fetch PR #{} head: {}".format(pr_number, fetch["stderr"])) + + checkout = run_command(["git", "checkout", "--detach", "FETCH_HEAD"], cwd=str(workspace), env=env) + write_json_atomic(attempt_dir / "checkout-pr.json", redacted_command_result(checkout)) + if checkout["returncode"] != 0: + raise RuntimeError("failed to checkout PR #{} head: {}".format(pr_number, checkout["stderr"])) + + head = run_command(["git", "rev-parse", "HEAD"], cwd=str(workspace), env=env) + status = run_command(["git", "status", "--short", "--branch"], cwd=str(workspace), env=env) + write_json_atomic(attempt_dir / "workspace-git.json", redacted_command_result({ + "args": ["git evidence"], + "returncode": 0 if head["returncode"] == 0 and status["returncode"] == 0 else 1, + "stdout": "HEAD={}\n{}".format(head["stdout"].strip(), status["stdout"].strip()), + "stderr": head["stderr"] + status["stderr"], + })) + if head["returncode"] != 0: + raise RuntimeError( + "failed to read checked-out PR #{} HEAD: {}".format(pr_number, head["stderr"]) + ) + + workspace_head_sha = head["stdout"].strip() + metadata_head_sha = str(((pr.get("head") or {}).get("sha")) or "").strip() + if not workspace_head_sha: + raise RuntimeError("checked-out PR #{} HEAD was empty".format(pr_number)) + if metadata_head_sha and workspace_head_sha != metadata_head_sha: + raise RuntimeError( + "checked-out PR #{} HEAD {} does not match GitHub metadata head {}".format( + pr_number, + workspace_head_sha, + metadata_head_sha, + ) + ) + + return { + "kind": "pull_request", + "pr_number": pr_number, + "metadata": summarize_pr(pr), + "files": summarize_pr_files(files), + "checkout": { + "fetch_returncode": fetch["returncode"], + "checkout_returncode": checkout["returncode"], + "workspace_head_sha": workspace_head_sha, + "workspace_status": status["stdout"].strip(), + }, + } + + +def paginated_pr_files(repo, pr_number, token): + files = [] + page = 1 + while True: + page_items = github_request( + "GET", + "https://api.github.com/repos/{}/pulls/{}/files?per_page=100&page={}".format( + repo, pr_number, page + ), + token, + ) + files.extend(page_items or []) + if len(page_items or []) < 100: + break + page += 1 + return files + + +def summarize_pr(pr): + return { + "number": pr.get("number"), + "title": pr.get("title"), + "state": pr.get("state"), + "html_url": pr.get("html_url"), + "base_ref": (pr.get("base") or {}).get("ref"), + "base_sha": (pr.get("base") or {}).get("sha"), + "head_ref": (pr.get("head") or {}).get("ref"), + "head_sha": (pr.get("head") or {}).get("sha"), + "merge_commit_sha": pr.get("merge_commit_sha"), + } + + +def summarize_pr_files(files): + summarized = [] + for item in files or []: + patch = item.get("patch") or "" + summarized.append( + { + "filename": item.get("filename"), + "status": item.get("status"), + "additions": item.get("additions"), + "deletions": item.get("deletions"), + "changes": item.get("changes"), + "sha": item.get("sha"), + "patch": patch[:12000], + "patch_truncated": len(patch) > 12000, + } + ) + return summarized + + +def review_evidence(review_context, review_context_path, task): + metadata = review_context.get("metadata") or {} + files = review_context.get("files") or [] + checkout = review_context.get("checkout") or {} + return { + "pr_number": review_context.get("pr_number"), + "base_ref": metadata.get("base_ref"), + "base_sha": metadata.get("base_sha"), + "head_ref": metadata.get("head_ref"), + "head_sha": metadata.get("head_sha"), + "workspace_head_sha": checkout.get("workspace_head_sha"), + "changed_file_count": len(files), + "changed_files": [f.get("filename") for f in files], + "review_context_path": str(review_context_path), + "review_context_sha256": file_sha256(review_context_path), + } + + +def file_sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def publish_result_if_configured(task, result_path, token): + publication = task.get("publication") or {} + mode = publication.get("mode") or "record_only" + if mode != "comment": + task["publication_state"] = "held_for_issue_11_publication_gates" + return + + result = read_json(result_path, {}) + number = ( + (task.get("task") or {}).get("issue_number") + or (task.get("task") or {}).get("pr_number") + ) + if not number: + task["publication_state"] = "publication_skipped_no_issue_or_pr_number" + return + + body = publication_comment_body(task, result) + repo = task.get("repository") + url = "https://api.github.com/repos/{}/issues/{}/comments".format(repo, int(number)) + try: + response = github_request("POST", url, token, {"body": body}) + task["publication_state"] = "published_comment" + task["publication_url"] = response.get("html_url") + task["publication_comment_id"] = response.get("id") + except Exception as exc: + task["publication_state"] = "publication_failed" + task["publication_error"] = redact_tokenish(repr(exc)) + + +def publication_comment_body(task, result): + status = result.get("status") or "unknown" + summary = result.get("summary") or "No summary returned." + pr_body = result.get("pr_body") or "" + files_changed = result.get("files_changed") or [] + commits = result.get("commits") or [] + task_id = task.get("task_id") or "" + evidence = task.get("review_evidence") or {} + review = result.get("review") or {} + + parts = [ + "## Cody dogfood result", + "", + "**Status:** {}".format(status), + "", + summary.strip(), + ] + if pr_body.strip() and pr_body.strip() != summary.strip(): + parts.extend(["", pr_body.strip()]) + parts.extend(review_fix_loop_lines(task)) + parts.extend(["", "### Evidence"]) + if evidence: + changed_files = evidence.get("changed_files") or [] + parts.extend( + [ + "- PR: #{}".format(evidence.get("pr_number")), + "- Base: `{}` @ `{}`".format(evidence.get("base_ref"), evidence.get("base_sha")), + "- Head: `{}` @ `{}`".format(evidence.get("head_ref"), evidence.get("head_sha")), + "- Checked-out workspace HEAD: `{}`".format(evidence.get("workspace_head_sha")), + "- Changed files supplied to agent: {}".format(evidence.get("changed_file_count")), + "- Review context SHA-256: `{}`".format(evidence.get("review_context_sha256")), + ] + ) + if changed_files: + parts.append("- Files: {}".format(", ".join("`{}`".format(f) for f in changed_files[:20]))) + else: + parts.append("- No PR review evidence was captured for this run.") + parts.extend(structured_review_lines(review)) + parts.extend( + [ + "", + "**Files changed:** {}".format(len(files_changed)), + "**Commits:** {}".format(len(commits)), + "", + "_Task `{}`. Publication is enabled on the hosted test adapter only._".format(task_id), + ] + ) + return "\n".join(parts) + + +def review_fix_loop_lines(task): + loops = task.get("review_fix_loops") or [] + if not loops: + return [] + + lines = ["", "### Review fix loop"] + for loop in loops: + lines.append( + "- Iteration {iteration}: input findings {input_findings}, result `{result_status}`, remaining findings {remaining_findings}.".format( + iteration=loop.get("iteration"), + input_findings=loop.get("input_findings"), + result_status=loop.get("result_status") or "unknown", + remaining_findings=loop.get("remaining_findings"), + ) + ) + if loops and loops[-1].get("remaining_findings", 0): + lines.append( + "- Loop stopped after {} configured iteration(s); unresolved findings remain.".format( + len(loops) + ) + ) + return lines + + +def structured_review_lines(review): + if not review: + return ["", "### Structured review", "- No structured review result was emitted."] + + lines = [ + "", + "### Structured review", + "- Mode: `{}`".format(review.get("mode") or "unknown"), + "- Evidence status: `{}`".format(review.get("evidence_status") or "unknown"), + ] + + reviewed_files = review.get("reviewed_files") or [] + lines.append("- Reviewed files: {}".format(len(reviewed_files))) + if reviewed_files: + lines.append( + "- Reviewed file list: {}".format( + ", ".join("`{}`".format(path) for path in reviewed_files[:20]) + ) + ) + if len(reviewed_files) > 20: + lines.append("- Reviewed file list truncated after 20 entries.") + + supporting_files = review.get("supporting_files") or [] + lines.append("- Supporting files inspected: {}".format(len(supporting_files))) + if supporting_files: + lines.append( + "- Supporting file list: {}".format( + ", ".join("`{}`".format(path) for path in supporting_files[:20]) + ) + ) + if len(supporting_files) > 20: + lines.append("- Supporting file list truncated after 20 entries.") + + findings = review.get("findings") or [] + lines.append("- Findings: {}".format(len(findings))) + for index, finding in enumerate(findings[:10], start=1): + location = finding.get("file") or "unknown file" + if finding.get("line") is not None: + location = "{}:{}".format(location, finding.get("line")) + lines.append( + " {}. `{}` {} - {}".format( + index, + finding.get("severity") or "unknown", + location, + finding.get("title") or "Untitled finding", + ) + ) + if len(findings) > 10: + lines.append("- Findings truncated after 10 entries.") + + no_findings_reason = review.get("no_findings_reason") + if no_findings_reason: + lines.append("- No-findings reason: {}".format(no_findings_reason)) + + tests_run = review.get("tests_run") or [] + lines.append("- Tests reported by runtime: {}".format(len(tests_run))) + for test in tests_run[:10]: + summary = test.get("output_summary") + suffix = " - {}".format(summary) if summary else "" + lines.append( + " - `{}`: `{}`{}".format( + test.get("command") or "unknown command", + test.get("status") or "unknown", + suffix, + ) + ) + if len(tests_run) > 10: + lines.append("- Test list truncated after 10 entries.") + + limitations = review.get("limitations") or [] + lines.append("- Limitations: {}".format(len(limitations))) + for limitation in limitations[:10]: + lines.append(" - {}".format(limitation)) + if len(limitations) > 10: + lines.append("- Limitation list truncated after 10 entries.") + + return lines + + +def load_codex_access_token(): + for path in codex_token_candidates(): + try: + data = read_json(path, {}) + token = str(data.get("access_token") or "").strip() + if token: + return token + except Exception: + continue + return None + + +def codex_token_candidates(): + yield CODEX_TOKENS_PATH + + coven_home = CODEX_TOKENS_PATH.parent + registry = read_json(coven_home / "accounts.json", {}) + active = ( + registry.get("providers", {}) + .get("codex", {}) + .get("active") + ) + if active: + yield coven_home / "accounts" / "codex" / str(active) / "codex_tokens.json" + + accounts_root = coven_home / "accounts" / "codex" + if accounts_root.exists(): + for path in accounts_root.glob("*/codex_tokens.json"): + yield path + + +def redacted_command_result(result): + redacted = dict(result) + for key in ("stdout", "stderr"): + redacted[key] = redact_tokenish(redacted.get(key) or "") + return redacted + + +def redact_tokenish(text): + if not text: + return text + markers = ["ghs_", "ghu_", "github_pat_", "x-access-token:"] + redacted = text + for marker in markers: + while marker in redacted: + index = redacted.find(marker) + end = index + len(marker) + while end < len(redacted) and redacted[end] not in " \n\r\t'\"": + end += 1 + redacted = redacted[:index] + marker + "[redacted]" + redacted[end:] + return redacted + + +def fail_task(path, task, reason, detail): + task["state"] = "failed" + task["failure_category"] = reason + task["failure_detail"] = redact_tokenish(str(detail))[-4000:] + task["updated_at"] = utc_now() + write_json_atomic(path, task) + return task diff --git a/docs/headless-contract.md b/docs/headless-contract.md index f71eb54..86b6b87 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -1,6 +1,6 @@ # coven-code Headless Execution Contract -**Contract version: `1`** · Status: **Locked** (V1 / M1) +**Contract version: `2`** - Status: **Locked** (V2 / structured review output) This document is the single source of truth for the interface between `coven-github` (the GitHub ingress adapter) and `coven-code` (the execution @@ -64,7 +64,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is ```json { - "contract_version": "1", + "contract_version": "2", "trigger": "issue_assigned", "repo": { "owner": "OpenCoven", @@ -94,7 +94,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"1"`. Consumers MUST reject a brief whose major version they do not implement. | +| `contract_version` | string | MUST be `"2"`. Consumers MUST reject a brief whose major version they do not implement. | | `trigger` | string enum | `issue_assigned` \| `pr_review_comment` \| `issue_mention`. | | `repo.owner` | string | | | `repo.name` | string | | @@ -106,6 +106,8 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is | `familiar.model` | string \| null | BYOM model id; `null`/absent means runtime default. | | `familiar.skills` | string[] | Skill ids to load for the session. MAY be empty. | | `workspace.root` | string | Absolute path to the pre-cloned, isolated workspace. The runtime operates **inside** this directory and MUST NOT write outside it. | +| `review_context` | object | Optional tokenless hosted-review evidence supplied by the adapter. When present, it contains PR metadata and changed-file context the runtime MUST inspect before producing review output. | +| `audit_instruction` | string | Optional hosted-review instruction paired with `review_context`. Consumers that do not implement hosted review MAY ignore it. | ### 2.2 Task kinds @@ -128,7 +130,7 @@ the file MAY be absent. ```json { - "contract_version": "1", + "contract_version": "2", "status": "success", "branch": "cody/fix-issue-42", "commits": [ @@ -136,7 +138,17 @@ the file MAY be absent. ], "files_changed": ["src/auth/refresh.rs"], "summary": "Fixed OAuth token refresh by adding a 60-second clock skew buffer.", - "pr_body": "## Hey, I'm Cody 🦄\n\nI looked at issue #42…", + "pr_body": "## Hey, I'm Cody\n\nI looked at issue #42...", + "review": { + "mode": "pull_request", + "evidence_status": "complete", + "reviewed_files": ["src/auth/refresh.rs"], + "supporting_files": ["src/auth/mod.rs", "tests/auth_refresh.rs"], + "findings": [], + "tests_run": [], + "no_findings_reason": "Reviewed the supplied PR file and found no blocking issues.", + "limitations": [] + }, "exit_reason": null } ``` @@ -145,22 +157,44 @@ the file MAY be absent. | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"1"`. If absent, the consumer assumes `"1"` for backward compatibility, but producers MUST emit it. | -| `status` | string enum | `success` \| `failure` \| `partial` \| `needs_input`. See [3.2](#32-status). | +| `contract_version` | string | MUST be `"2"`. Producers MUST emit it. | +| `status` | string enum | `success` \| `failure` \| `partial` \| `needs_input`. See [3.3](#33-status). | | `branch` | string \| null | Branch the runtime pushed. `null` when no branch was created. The adapter only opens a PR when `branch` is set **and** `commits` is non-empty. | | `commits` | array | `{ "sha": string, "message": string }`. MAY be empty. | | `files_changed` | string[] | Workspace-relative paths. MAY be empty. | | `summary` | string | One-line familiar-voice summary. Used in the Check Run and PR title. | | `pr_body` | string | Full PR body, **authored by the familiar** in its own voice — not a template. | -| `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.3](#33-exit_reason). | +| `review` | object | Structured review evidence and findings. Required even when `mode` is `none`. See [3.2](#32-review). | +| `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.4](#34-exit_reason). | > **Drift note (supersedes `COVEN-GITHUB.md`):** the prose result envelope listed -> an `events` array. Progress/event streaming is **not** part of the v1 result +> an `events` array. Progress/event streaming is **not** part of the v2 result > envelope — it is deferred to M2 and will travel over a separate channel. The -> v1 envelope carries terminal task state only. Producers MUST NOT rely on +> v2 envelope carries terminal task state only. Producers MUST NOT rely on > `events` being read. -### 3.2 `status` +### 3.2 `review` + +`review` is the machine-readable proof that a hosted review actually examined +the intended code. It is required on every result. Non-review tasks MUST set +`mode: "none"` and `evidence_status: "not_applicable"`. + +| Field | Type | Notes | +|---|---|---| +| `mode` | string enum | `none`, `pull_request`, or `review_comment`. | +| `evidence_status` | string enum | `not_applicable`, `complete`, `partial`, or `missing`. PR review modes MUST NOT use `not_applicable`. | +| `reviewed_files` | string[] | Workspace-relative files supplied to or inspected by the runtime. PR review modes MUST include at least one file unless `evidence_status` is `missing`. | +| `supporting_files` | string[] | Workspace-relative files beyond the changed-file list that the runtime can prove were inspected for context. Empty means no broader-codebase inspection was proven, not that none was needed. | +| `findings` | array | Structured findings. Empty is allowed only when `no_findings_reason` is a non-empty string. | +| `tests_run` | array | Commands run while reviewing, with `passed`, `failed`, `not_run`, or `unknown` status. | +| `no_findings_reason` | string \| null | Required when `mode` is a review mode and `findings` is empty. | +| `limitations` | string[] | Evidence gaps, skipped checks, or other caveats. | + +Each finding carries `severity`, `file`, optional `line`, `title`, `body`, and +optional `recommendation`. Valid severities are `info`, `low`, `medium`, `high`, +and `critical`. + +### 3.3 `status` | Value | Meaning | |---|---| @@ -172,7 +206,7 @@ the file MAY be absent. The adapter treats `success` and `partial` as PR-opening outcomes; `failure` and `needs_input` do not open a PR by themselves. -### 3.3 `exit_reason` +### 3.4 `exit_reason` `null` on success. Otherwise one of: @@ -211,7 +245,7 @@ A process **killed by signal**, or one that **times out** (the adapter enforces ## 5. Security invariants -These are non-negotiable for v1: +These are non-negotiable for v2: 1. The session brief is **tokenless**. Serializing a brief MUST NOT produce an `auth` field, a `"token"` field, or a credential-bearing `clone_url` From a25a7c5e586caa9fd0f46c728db95393daba99e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:11:32 +0000 Subject: [PATCH 3/3] fix: apply review fixes from PR #31 review 4635264195 - docs/headless-contract.md: clarify that `line` and `recommendation` on findings are required (present in every finding) but may be null, matching the v2 JSON schema which lists them in `required` with nullable types. - deploy/coven-github/coven_github_adapter.py: catch RuntimeError from verify_webhook_signature() in route_signed_delivery() and return a structured 500 response instead of propagating an unhandled exception when the webhook secret is not configured. --- .../coven_github_adapter.cpython-312.pyc | Bin 0 -> 69257 bytes .../test_coven_github_adapter.cpython-312.pyc | Bin 0 -> 4049 bytes deploy/coven-github/coven_github_adapter.py | 6 +- .../coven-github/test_coven_github_adapter.py | 72 ++++++++++++++++++ docs/headless-contract.md | 4 +- 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 deploy/coven-github/__pycache__/coven_github_adapter.cpython-312.pyc create mode 100644 deploy/coven-github/__pycache__/test_coven_github_adapter.cpython-312.pyc create mode 100644 deploy/coven-github/test_coven_github_adapter.py diff --git a/deploy/coven-github/__pycache__/coven_github_adapter.cpython-312.pyc b/deploy/coven-github/__pycache__/coven_github_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3ee1389a5828826b69b3f9eae59d56cc4a3a49c GIT binary patch literal 69257 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 G_WuEHY?&7T literal 0 HcmV?d00001 diff --git a/deploy/coven-github/__pycache__/test_coven_github_adapter.cpython-312.pyc b/deploy/coven-github/__pycache__/test_coven_github_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40cd0634df0120222f96a09cd9c1ba5f53cf5afa GIT binary patch literal 4049 zcmcInO>7j&6|S1@p8xUu*v1Brt%hO2!{V`kB_u3yz!+mK!6w)-!6e&iPnYdB^S9MK z*fSYR-iYiPB@#$P!cz8N?THmRASIDFvU`YD+8kzVRvz^d2`Ra7GZ+PlQ(kpXdyMf4 zMM~P9s#ov5dR5)^y`TCf5bz>+l7BomeH*BMvVr{wHDdh}AnqUqDL943u-QAt9A?jr zanL(bd|DV2FjBbdXw2zAXOY5RM~XnW=?2?ey}!p<^q8A?6zA1WglNQpK)0!`V^jBP z1V#m1z;V}8HXe*)gTEl_Q%PHm^*sET<@J9dQXD08ueQz{e#`uTJ*(cA>Z?0Sc)PDQ9j6niw86oy$@^ zrH&hXURP5_iWfu;vGU#@ zulRcA`yTt6ZuZ;&=Anx3Q2Fq{*LzodL%+_Gmi)l%FE;GvZZi4l1rky%iZNSfA1uKq8tl~L`!u6qF?L}ygE1_vNYs&+G*#^)h z4&YIPWQnsWjDEk52lw1$k?m#6^WR%6ptvw-1je(vl-CGl_|XhLnNm=u`YGPA|xOK%EVO+mL#*5EDl5t;`#NRtdhIZ+Is#qO6=8i7;h zHIkYzTs0>%oVjdDO%@DZC$qZ7;t{}Fq|9**(FAwRk|xQN!2a_YMWzKwo`GTU$eWN! zylqklOlul6-~y43+ld8NHL`oDf9Z$i@WBP=lcu5P2zyTBg}$mkbaUv&(Bj$EXjdiL zbyvCHzY=}_!N|A%-ZhS?tT~;{-si~W=>zSx1{4Ud`lA(pbn((^d!o{wxU1hEU1>k@ zK>5~x`dN^v(MDMJHBhs-Xo7kGpuRZ*ZvwRkT!lf}3(kT8&=BpU4aWe;Vre5<>l?t_ zE(+T=kD@tV;S^rQil8_@bIb|gBy1;J9^G#<6qoG`M-jwLJRP(p^}ZzN8}YWp+fi0= zt7tp^y_)+$1~mU{`)`g5D25Ip4bM0*x(us>J4R-?%V-8~-f87!t8bd)enu1o$Al;)K@q88^Hs9oAFLZ*bf_SPXUN@~M>8edzF! zZk7YIqPNa@`fFIl;It{Z=uZf+V!~~%b zb{WFFwh1sV>v^r{>H;F>({e^BPX0f^wP~RQ>`%K54MSGrt=?NdTbNxuvvjQ7_U>}<-Kx0v{;7wNuW6;_ zVmWkiLu0w^_;T?0_e+m02ai38iq$=_YDagqLwx2D7#M|cWX+F4P1Z8YZA{HF(pLV( zSf%AkIdtW5L|kfGiR>%;_nBBVJm4voJ5%Nc#T`RAk~z&v%5JkV>#_>VxC64BtX*Zt zpUY~ho~4CiOvw^W%s{?Q%YYQ|tf~*>$06xjaYHo20|$N|$AeI+4yp$MX;6W(V$Nu& zRjf6CH+-8uo8i_;I>&;O5g7q0OCfFcsT3R>)-}RbR=y~!8qqG2Niu8rFXkaPQ zj2+v@2($E!J82Zwr|qm^=R<$Ow!`V^9Az;;V|M|T$L&mh+)T>bTYO8@DW3nI;!I@Q zV|j2R`>*pJ&%X5?4W9jLBHD9w!S%@9QSE@T;E}uSUxDV;KxZY;xe_?AhB(hCzMHJJ zcixSaqlt3QXr=KIlnLSH)$smGc>kaL5|t2C0e=~J5c~7x zhr&bp_xH*dhQImIqtMS^a!l!^z-(HUU-FEzW=?*!S@23@sQPP1Z!7m@V`r~l`0{-> z&|mq5-e%8NVHcxYh2Gt+!MMOKC9IBwONm#iT_J$>OVUSqIb}&G`v&x<(0qYR0Grho zZDw57u+*-xJLq-v+|eNP;dODZFlyB6L@BnUSI}TxHq@;%6-DR^7PP4z++)nI|B_= zU>JXgn!iIq`2PoLeeM)+t5Tt2+-=b{=?v*k~<>5$|6pE^|Ape0YA~)AJvn O|LxEkhxlFQGXDYOKqzql literal 0 HcmV?d00001 diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 1ed3f3c..1b05c50 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -473,7 +473,11 @@ def route_signed_delivery(headers, body, debug, webhook_secret=None): raw_body = b"" signature = header_value(headers, "x-hub-signature-256") - if not verify_webhook_signature(webhook_secret or WEBHOOK_SECRET, raw_body, signature): + try: + verified = verify_webhook_signature(webhook_secret or WEBHOOK_SECRET, raw_body, signature) + except RuntimeError: + return {"ok": False, "status": 500, "error": "webhook secret not configured"} + if not verified: return {"ok": False, "status": 401, "error": "invalid signature"} event_name = header_value(headers, "x-github-event") diff --git a/deploy/coven-github/test_coven_github_adapter.py b/deploy/coven-github/test_coven_github_adapter.py new file mode 100644 index 0000000..bde9f03 --- /dev/null +++ b/deploy/coven-github/test_coven_github_adapter.py @@ -0,0 +1,72 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +def load_adapter(): + path = Path(__file__).with_name("coven_github_adapter.py") + spec = importlib.util.spec_from_file_location("coven_github_adapter", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class HostedAdapterTests(unittest.TestCase): + def test_mentions_are_boundary_aware(self): + adapter = load_adapter() + policy = {"bot_usernames": ["cody"]} + + for text in ("@cody please review", "Please review, @Cody.", "(@cody)"): + self.assertTrue(adapter.mentioned(text, policy), text) + + for text in ( + "@codybot please review", + "@cody_bot please review", + "@cody/team please review", + "email me at x@cody.example", + "prefix@cody", + ): + self.assertFalse(adapter.mentioned(text, policy), text) + + def test_prepare_review_context_rejects_stale_pr_head_evidence(self): + adapter = load_adapter() + + def fake_github_request(method, url, token, body=None): + if "/pulls/123/files" in url: + return [] + if "/pulls/123" in url: + return { + "number": 123, + "head": {"sha": "metadata-sha"}, + "base": {"sha": "base-sha"}, + } + raise AssertionError(url) + + def fake_run_command(args, cwd=None, env=None, timeout=300): + if args[:2] == ["git", "fetch"]: + return {"args": args, "returncode": 0, "stdout": "", "stderr": ""} + if args[:3] == ["git", "checkout", "--detach"]: + return {"args": args, "returncode": 0, "stdout": "", "stderr": ""} + if args[:3] == ["git", "rev-parse", "HEAD"]: + return { + "args": args, + "returncode": 0, + "stdout": "different-sha\n", + "stderr": "", + } + if args[:3] == ["git", "status", "--short"]: + return {"args": args, "returncode": 0, "stdout": "## HEAD\n", "stderr": ""} + raise AssertionError(args) + + adapter.github_request = fake_github_request + adapter.run_command = fake_run_command + + with tempfile.TemporaryDirectory() as tmp: + task = {"task": {"pr_number": 123}, "repository": "OpenCoven/coven-github"} + with self.assertRaisesRegex(RuntimeError, "does not match GitHub metadata head"): + adapter.prepare_review_context(task, Path(tmp), "tok", {}, Path(tmp)) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/headless-contract.md b/docs/headless-contract.md index 86b6b87..4ea3daf 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -190,8 +190,8 @@ the intended code. It is required on every result. Non-review tasks MUST set | `no_findings_reason` | string \| null | Required when `mode` is a review mode and `findings` is empty. | | `limitations` | string[] | Evidence gaps, skipped checks, or other caveats. | -Each finding carries `severity`, `file`, optional `line`, `title`, `body`, and -optional `recommendation`. Valid severities are `info`, `low`, `medium`, `high`, +Each finding carries `severity`, `file`, `line` (required, may be null), `title`, `body`, and +`recommendation` (required, may be null). Valid severities are `info`, `low`, `medium`, `high`, and `critical`. ### 3.3 `status`