diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df2820b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: TypeScript test + build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index adafed1..3b0ef64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ __pycache__/ *.pyc +node_modules/ +dist/ .env *.pem *.key diff --git a/AGENTS.md b/AGENTS.md index c37b4b3..166fd6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,9 @@ pull requests against this repo. This is the agent-specific layer; read ## Before opening the PR -- If you touched Python, keep it runnable on the target host: no new heavy deps - unless the deployment target supports them, and don't break the entrypoint. +- If you touched TypeScript, keep it runnable on the target host: avoid heavy + runtime dependencies unless the deployment target supports them, and don't + break the Node entrypoint. - Smoke-test the webhook handler locally where possible (a signed sample payload) before relying on the hosted deploy. diff --git a/README.md b/README.md index d2b0665..9899653 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Hosted webhook deployment bundle for the Coven GitHub integration. -This repository tracks the Python webhook adapter used to receive GitHub App +This repository tracks the TypeScript webhook adapter used to receive GitHub App events, capture review context, run `coven-code`, and publish structured review results back to GitHub. @@ -12,8 +12,11 @@ reproduced, and changed through PRs instead of server-only edits. ## Files -- `coven_github_adapter.py` - webhook handler, task runner, PR evidence capture, - Codex-backed headless runtime invocation, and comment publisher. +- `src/adapter.ts` - webhook handler, task router, task runner, PR evidence + capture, Codex-backed headless runtime invocation, and comment publisher. +- `src/server.ts` - Node HTTP entrypoint for `/`, `/healthz`, and `/webhook`. +- `tests/webhook-adapter.test.ts` - parity coverage for signature handling, + request body edge cases, and task routing guards. - `scripts/smoke-webhook.sh` - local HMAC signature smoke test for a running webhook endpoint. @@ -34,10 +37,27 @@ The deployment expects secrets and mutable state to be supplied outside git: Do not commit private keys, webhook secrets, OAuth tokens, generated task state, workspaces, or attempt artifacts. +## Local runtime + +Install dependencies, build TypeScript, and start the webhook service: + +```bash +npm ci +npm run build +WEBHOOK_SECRET="replace-with-local-secret" npm start +``` + +For local development without a build step: + +```bash +WEBHOOK_SECRET="replace-with-local-secret" npm run dev +``` + +The server listens on `PORT` or `3000` by default. + ## Local smoke test -Run the adapter behind any WSGI server that points at -`coven_github_adapter:application`, then verify signature handling: +Run the adapter, then verify signature handling: ```bash WEBHOOK_SECRET="replace-with-local-secret" \ @@ -48,6 +68,13 @@ The smoke test proves that unsigned requests and bad signatures are rejected, while a correctly HMAC-signed GitHub `ping` delivery is accepted without needing `coven-code` or a GitHub installation token. +## Verification + +```bash +npm test +npm run build +``` + ## Policy The default checked-in policy is empty: diff --git a/coven_github_adapter.py b/coven_github_adapter.py deleted file mode 100644 index 2980ef1..0000000 --- a/coven_github_adapter.py +++ /dev/null @@ -1,1208 +0,0 @@ -import base64 -import hashlib -import hmac -import json -import os -import subprocess -import tempfile -import time -import traceback -import uuid -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() -WEBHOOK_SECRET = ( - os.environ.get("GITHUB_WEBHOOK_SECRET") or os.environ.get("WEBHOOK_SECRET", "") -).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() -MAX_WEBHOOK_BODY_BYTES = 10 * 1024 * 1024 - - -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) - - -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_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 header(environ, name, default=""): - key = "HTTP_" + name.upper().replace("-", "_") - return environ.get(key, default) - - -def json_response(start_response, status, body): - payload = json.dumps(body, sort_keys=True).encode("utf-8") - start_response( - status, - [ - ("Content-Type", "application/json"), - ("Content-Length", str(len(payload))), - ], - ) - return [payload] - - -class PayloadTooLarge(Exception): - pass - - -def read_request_body(environ): - raw_length = (environ.get("CONTENT_LENGTH") or "").strip() - try: - length = int(raw_length) if raw_length else -1 - except ValueError: - length = -1 - if length > MAX_WEBHOOK_BODY_BYTES: - raise PayloadTooLarge - if length >= 0: - return environ["wsgi.input"].read(length) - body = environ["wsgi.input"].read(MAX_WEBHOOK_BODY_BYTES + 1) - if len(body) > MAX_WEBHOOK_BODY_BYTES: - raise PayloadTooLarge - return body - - -def verify_webhook_signature(secret, body, signature_header): - if not secret: - return False, "webhook secret not configured" - if not signature_header: - return False, "missing signature" - signature_header = signature_header.strip() - prefix = "sha256=" - if not signature_header.startswith(prefix): - return False, "invalid signature" - expected = prefix + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() - if not hmac.compare_digest(expected, signature_header): - return False, "invalid signature" - return True, None - - -def application(environ, start_response): - method = environ.get("REQUEST_METHOD", "GET").upper() - path = environ.get("PATH_INFO", "/") - if method == "GET" and path in ("/", "/healthz"): - return json_response(start_response, "200 OK", {"ok": True}) - if method != "POST" or path not in ("/", "/webhook"): - return json_response(start_response, "404 Not Found", {"error": "not found"}) - - try: - body = read_request_body(environ) - except PayloadTooLarge: - return json_response(start_response, "413 Payload Too Large", {"error": "payload too large"}) - ok, error = verify_webhook_signature( - WEBHOOK_SECRET, - body, - header(environ, "X-Hub-Signature-256"), - ) - if not ok: - status = "500 Internal Server Error" if error == "webhook secret not configured" else "401 Unauthorized" - return json_response(start_response, status, {"error": error}) - - event_name = header(environ, "X-GitHub-Event") - if not event_name: - return json_response(start_response, "400 Bad Request", {"error": "missing event"}) - - try: - payload = json.loads(body.decode("utf-8")) - except json.JSONDecodeError: - return json_response(start_response, "400 Bad Request", {"error": "invalid json"}) - - delivery_id = header(environ, "X-GitHub-Delivery") or str(uuid.uuid4()) - result = route_delivery(event_name, delivery_id, payload, lambda message: print(message, flush=True)) - return json_response(start_response, "200 OK", result) - - -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 "").lower() - for username in policy.get("bot_usernames") or []: - if "@" + username.lower() in normalized: - return True - return False - - -def labels_include_trigger(labels, policy): - wanted = set((policy.get("trigger_labels") or [])) - for label in labels or []: - name = (label.get("name") if isinstance(label, dict) else str(label)).strip() - if name in wanted: - return True - return False - - -def build_task_from_event(event_name, delivery_id, payload, policy): - repository = payload.get("repository") or {} - installation = payload.get("installation") or {} - familiar = policy.get("familiar") - base = { - "task_id": delivery_id, - "delivery_id": delivery_id, - "created_at": utc_now(), - "updated_at": utc_now(), - "state": "queued", - "attempts": 0, - "installation_id": installation.get("id"), - "repository_id": repository.get("id"), - "repository": repository.get("full_name"), - "clone_url": repository.get("clone_url") - or "https://github.com/{}.git".format(repository.get("full_name")), - "default_branch": repository.get("default_branch") or policy.get("default_branch") or "main", - "familiar": familiar, - "publication": policy.get("publication") or {"mode": "record_only"}, - "issue_refs": ["OpenCoven/coven-github#2", "OpenCoven/coven-github#7"], - } - if not familiar: - return ignored(base, "missing_familiar_policy") - - if event_name == "issue_comment": - issue = payload.get("issue") or {} - comment = payload.get("comment") or {} - if not mentioned(comment.get("body"), policy): - return ignored(base, "issue_comment_without_mention") - if issue.get("pull_request"): - base.update( - { - "trigger": "pr_mention", - "target": { - "kind": "pull_request", - "pr_number": int(issue.get("number") or 0), - }, - "task": { - "kind": "respond_to_mention", - "issue_number": int(issue.get("number") or 0), - "comment_body": comment.get("body") or "", - }, - "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], - } - ) - return base - base.update( - { - "trigger": "issue_mention", - "task": { - "kind": "respond_to_mention", - "issue_number": int(issue.get("number") or 0), - "comment_body": comment.get("body") or "", - }, - "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], - } - ) - return base - - if event_name == "pull_request_review_comment": - comment = payload.get("comment") or {} - pull_request = payload.get("pull_request") or {} - if not mentioned(comment.get("body"), policy): - return ignored(base, "pr_review_comment_without_mention") - base.update( - { - "trigger": "pr_review_comment", - "task": { - "kind": "address_review_comment", - "pr_number": int(pull_request.get("number") or 0), - "comment_body": comment.get("body") or "", - "diff_hunk": comment.get("diff_hunk"), - "path": comment.get("path"), - "line": comment.get("line"), - "side": comment.get("side"), - "commit_id": comment.get("commit_id"), - "html_url": comment.get("html_url"), - }, - "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], - } - ) - return base - - if event_name == "issues": - issue = payload.get("issue") or {} - action = payload.get("action") - if action not in ("assigned", "labeled", "opened"): - return ignored(base, "unsupported_issue_action") - if action == "labeled" and not labels_include_trigger(issue.get("labels"), policy): - return ignored(base, "issue_label_not_enabled") - base.update( - { - "trigger": "issue_assigned" if action == "assigned" else "issue_mention", - "task": { - "kind": "fix_issue", - "issue_number": int(issue.get("number") or 0), - "issue_title": issue.get("title") or "", - "issue_body": issue.get("body") or "", - }, - "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], - } - ) - return base - - if event_name == "pull_request": - pull_request = payload.get("pull_request") or {} - base.update( - { - "state": "ignored", - "ignored_reason": "pull_request_review_task_not_in_headless_contract_v1", - "trigger": "pull_request", - "target": { - "action": payload.get("action"), - "number": pull_request.get("number"), - "head_sha": (pull_request.get("head") or {}).get("sha"), - "head_ref": (pull_request.get("head") or {}).get("ref"), - "base_ref": (pull_request.get("base") or {}).get("ref"), - }, - "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#10"], - } - ) - return base - - if event_name == "push": - base.update( - { - "state": "ignored", - "ignored_reason": "push_review_task_not_in_headless_contract_v1", - "trigger": "push", - "target": { - "ref": payload.get("ref"), - "before": payload.get("before"), - "after": payload.get("after"), - "commit_count": len(payload.get("commits") or []), - }, - "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#10"], - } - ) - return base - - return ignored(base, "unsupported_event") - - -def ignored(base, reason): - base["state"] = "ignored" - base["ignored_reason"] = reason - return base - - -def route_delivery(event_name, delivery_id, payload, debug): - delivery_file = delivery_path(delivery_id) - if delivery_file.exists(): - existing = read_json(delivery_file, {}) - return { - "ok": True, - "action": "duplicate_ignored", - "delivery_id": delivery_id, - "task_id": existing.get("task_id"), - "state": existing.get("state"), - } - - delivery = delivery_record(delivery_id, event_name, payload) - installation_id, repo_id, policy = repo_policy(payload) - if not policy: - delivery["state"] = "ignored" - delivery["routing_result"] = "no_policy_for_installation_repo" - delivery["installation_id"] = installation_id or delivery.get("installation_id") - delivery["repository_id"] = repo_id or delivery.get("repository_id") - write_json_atomic(delivery_file, delivery) - return { - "ok": True, - "action": "ignored", - "delivery_id": delivery_id, - "reason": "no_policy_for_installation_repo", - } - - task = build_task_from_event(event_name, delivery_id, payload, policy) - task["policy_snapshot"] = { - "enabled_triggers": policy.get("enabled_triggers") or [], - "publication": policy.get("publication") or {"mode": "record_only"}, - } - write_json_atomic(task_path(task["task_id"]), task) - - delivery["task_id"] = task["task_id"] - delivery["state"] = task["state"] - delivery["routing_result"] = task.get("ignored_reason") or "queued" - write_json_atomic(delivery_file, delivery) - - if task["state"] == "queued": - try: - run_task(task["task_id"], debug) - except Exception: - debug("COVEN GITHUB TASK RUN FAIL task_id={} {}".format(task["task_id"], traceback.format_exc())) - - return { - "ok": True, - "action": "accepted" if task["state"] != "ignored" else "ignored", - "delivery_id": delivery_id, - "task_id": task["task_id"], - "state": read_json(task_path(task["task_id"]), task).get("state"), - "reason": task.get("ignored_reason"), - "queued": task["state"] == "queued", - } - - -def run_command(args, cwd=None, env=None, timeout=300): - proc = subprocess.run( - args, - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=timeout, - text=True, - ) - return { - "args": args, - "returncode": proc.returncode, - "stdout": proc.stdout[-8000:], - "stderr": proc.stderr[-8000:], - } - - -def write_askpass(work_dir): - script = work_dir / "git-askpass.sh" - script.write_text("#!/bin/sh\nprintf '%s\\n' \"$COVEN_GIT_TOKEN\"\n", encoding="utf-8") - script.chmod(0o700) - return script - - -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 = read_json(result_path, None) if result_path.exists() else None - return { - "cycle": cycle, - "brief_path": brief_path, - "result_path": result_path, - "run_path": run_path, - "run": run, - "result": result, - } - - -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(CODEX_TOKENS_PATH.parent.parent) - 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 cycle_result["result"] is None: - return fail_task( - path, - task, - "result_missing", - "coven-code exited {} without writing result.json: {}".format( - run["returncode"], run["stderr"] - ), - ) - - 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, - ) - 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) - - if repair_cycle["result"] is None: - return fail_task( - path, - task, - "result_missing", - "review repair loop {} exited {} without writing result.json: {}".format( - iteration, - repair_cycle["run"]["returncode"], - repair_cycle["run"]["stderr"], - ), - ) - 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 = github_request( - "GET", - "https://api.github.com/repos/{}/pulls/{}/files?per_page=100".format(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: - return { - "kind": "pull_request", - "pr_number": pr_number, - "fetch_error": fetch["stderr"], - "metadata": summarize_pr(pr), - "files": summarize_pr_files(files), - } - - 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)) - - 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"], - })) - - 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": head["stdout"].strip(), - "workspace_status": status["stdout"].strip(), - }, - } - - -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/package-lock.json b/package-lock.json new file mode 100644 index 0000000..87fb0f1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,569 @@ +{ + "name": "@opencoven/coven-github-webhook", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@opencoven/coven-github-webhook", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aba7bab --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "@opencoven/coven-github-webhook", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/src/server.js", + "dev": "tsx src/server.ts", + "test": "node --import tsx --test tests/*.test.ts" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.20.0", + "typescript": "^5.9.0" + } +} diff --git a/src/adapter.ts b/src/adapter.ts new file mode 100644 index 0000000..1452cd1 --- /dev/null +++ b/src/adapter.ts @@ -0,0 +1,1229 @@ +import {createHash, createHmac, createSign, randomUUID} from "node:crypto"; +import {existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync} from "node:fs"; +import {homedir} from "node:os"; +import {basename, dirname, join, resolve} from "node:path"; +import {spawnSync} from "node:child_process"; + +export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject; +export type JsonObject = {[key: string]: JsonValue | undefined}; + +export interface AdapterConfig { + rootDir: string; + stateDir: string; + deliveriesDir: string; + tasksDir: string; + workspacesDir: string; + attemptsDir: string; + policyPath: string; + privateKeyPath: string; + appId: string; + webhookSecret: string; + covenCodeBin: string; + covenCodeModel: string; + maxReviewFixLoops: number; + codexTokensPath: string; + maxWebhookBodyBytes: number; +} + +export interface AdapterRequest { + method: string; + path: string; + headers: Map; + rawBody: Buffer; +} + +export interface AdapterResponse { + status: number; + body: JsonObject; +} + +interface CommandResult extends JsonObject { + args: string[]; + returncode: number; + stdout: string; + stderr: string; +} + +interface CycleResult { + cycle: number; + brief_path: string; + result_path: string; + run_path: string; + run: CommandResult; + result: JsonObject | null; +} + +const DEFAULT_POLICY: JsonObject = { + version: 1, + installations: {}, +}; + +const MAX_WEBHOOK_BODY_BYTES = 10 * 1024 * 1024; + +export function createConfig(env: NodeJS.ProcessEnv = process.env, rootDir = process.cwd()): AdapterConfig { + const stateDir = resolve(env.COVEN_GITHUB_STATE_DIR || join(rootDir, "coven-github-state")); + const config: AdapterConfig = { + rootDir, + stateDir, + deliveriesDir: join(stateDir, "deliveries"), + tasksDir: join(stateDir, "tasks"), + workspacesDir: join(stateDir, "workspaces"), + attemptsDir: join(stateDir, "attempts"), + policyPath: resolve(env.COVEN_GITHUB_POLICY_PATH || join(rootDir, "coven-github-policy.json")), + privateKeyPath: resolve(env.GITHUB_APP_PRIVATE_KEY_PATH || join(rootDir, ".coven-github-private-key.pem")), + appId: (env.GITHUB_APP_ID || "").trim(), + webhookSecret: (env.GITHUB_WEBHOOK_SECRET || env.WEBHOOK_SECRET || "").trim(), + covenCodeBin: (env.COVEN_CODE_BIN || "coven-code").trim() || "coven-code", + covenCodeModel: (env.COVEN_CODE_MODEL || "gpt-5.5").trim(), + maxReviewFixLoops: envInt(env.COVEN_REVIEW_FIX_LOOPS, 0, 0, 5), + codexTokensPath: configuredCodexTokensPath(env), + maxWebhookBodyBytes: MAX_WEBHOOK_BODY_BYTES, + }; + + for (const directory of [config.deliveriesDir, config.tasksDir, config.workspacesDir, config.attemptsDir]) { + mkdirSync(directory, {recursive: true}); + } + return config; +} + +function envInt(raw: string | undefined, fallback: number, minimum: number, maximum: number): number { + const parsed = Number.parseInt((raw || "").trim(), 10); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.max(minimum, Math.min(maximum, parsed)); +} + +function configuredCodexTokensPath(env: NodeJS.ProcessEnv): string { + const configured = (env.COVEN_CODE_CODEX_TOKENS_PATH || "").trim(); + if (configured) { + return resolve(configured.replace(/^~/, homedir())); + } + return join(homedir(), ".coven-code", "codex_tokens.json"); +} + +function utcNow(): string { + return new Date().toISOString(); +} + +function readJson(path: string, fallback: T): T { + try { + return JSON.parse(readFileSync(path, "utf8")) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return fallback; + } + throw error; + } +} + +function writeJsonAtomic(path: string, value: JsonValue): void { + mkdirSync(dirname(path), {recursive: true}); + const tmpName = join(dirname(path), `${basename(path)}.${randomUUID()}.tmp`); + try { + writeFileSync(tmpName, `${stableStringify(value)}\n`, "utf8"); + renameSync(tmpName, path); + } finally { + if (existsSync(tmpName)) { + rmSync(tmpName); + } + } +} + +function stableStringify(value: JsonValue, indent = 0): string { + const space = " ".repeat(indent); + const nextSpace = " ".repeat(indent + 1); + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + if (value.length === 0) { + return "[]"; + } + return `[\n${value.map((item) => `${nextSpace}${stableStringify(item, indent + 1)}`).join(",\n")}\n${space}]`; + } + const entries = Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + if (entries.length === 0) { + return "{}"; + } + return `{\n${entries + .map(([key, item]) => `${nextSpace}${JSON.stringify(key)}: ${stableStringify(item as JsonValue, indent + 1)}`) + .join(",\n")}\n${space}}`; +} + +function stableCompactStringify(value: JsonValue): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableCompactStringify(item)).join(",")}]`; + } + return `{${Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableCompactStringify(item as JsonValue)}`) + .join(",")}}`; +} + +function b64url(raw: Buffer | string): string { + return Buffer.from(raw).toString("base64url"); +} + +function githubAppJwt(config: AdapterConfig): string { + if (!config.appId) { + throw new Error("GITHUB_APP_ID is required"); + } + const now = Math.floor(Date.now() / 1000); + const header = {alg: "RS256", typ: "JWT"}; + const payload = {iat: now - 60, exp: now + 540, iss: config.appId}; + const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`; + const privateKey = readFileSync(config.privateKeyPath, "utf8"); + const signature = createSign("RSA-SHA256").update(signingInput).sign(privateKey); + return `${signingInput}.${b64url(signature)}`; +} + +async function githubRequest( + method: string, + url: string, + token: string, + body?: JsonObject, +): Promise { + const response = await fetch(url, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": "coven-github-hosted-prototype", + "X-GitHub-Api-Version": "2022-11-28", + ...(body === undefined ? {} : {"Content-Type": "application/json"}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const raw = await response.text(); + if (!response.ok) { + throw new Error(`GitHub API ${method} ${url} failed: ${raw}`); + } + return raw ? (JSON.parse(raw) as JsonValue) : {}; +} + +async function installationToken(config: AdapterConfig, installationId: JsonValue | undefined): Promise { + const response = (await githubRequest( + "POST", + `https://api.github.com/app/installations/${installationId}/access_tokens`, + githubAppJwt(config), + {}, + )) as JsonObject; + const token = response.token; + if (typeof token !== "string" || !token) { + throw new Error("GitHub installation token response did not include token"); + } + return token; +} + +function loadPolicy(config: AdapterConfig): JsonObject { + if (!existsSync(config.policyPath)) { + writeJsonAtomic(config.policyPath, DEFAULT_POLICY); + } + return readJson(config.policyPath, DEFAULT_POLICY); +} + +function repoPolicy(config: AdapterConfig, payload: JsonObject): [string, string, JsonObject | undefined] { + const policy = loadPolicy(config); + const installationId = String(((payload.installation as JsonObject | undefined)?.id as JsonValue) || ""); + const repository = (payload.repository as JsonObject | undefined) || {}; + const repoId = String((repository.id as JsonValue) || ""); + const installation = (((policy.installations as JsonObject | undefined) || {})[installationId] as JsonObject | undefined) || {}; + const repo = ((installation.repositories as JsonObject | undefined) || {})[repoId] as JsonObject | undefined; + return [installationId, repoId, repo]; +} + +function deliveryPath(config: AdapterConfig, deliveryId: string): string { + return join(config.deliveriesDir, `${deliveryId}.json`); +} + +function taskPath(config: AdapterConfig, taskId: string): string { + return join(config.tasksDir, `${taskId}.json`); +} + +function header(headers: Map, name: string): string { + return headers.get(name.toLowerCase()) || ""; +} + +function bodyFromContentLength(request: AdapterRequest, maxBytes: number): Buffer | "too_large" { + const rawLength = (header(request.headers, "Content-Length") || "").trim(); + const parsed = rawLength && /^-?\d+$/.test(rawLength) ? Number.parseInt(rawLength, 10) : -1; + const length = Number.isFinite(parsed) ? parsed : -1; + if (length > maxBytes) { + return "too_large"; + } + if (length >= 0) { + return request.rawBody.subarray(0, length); + } + if (request.rawBody.length > maxBytes) { + return "too_large"; + } + return request.rawBody; +} + +export function verifyWebhookSignature( + secret: string, + body: Buffer, + signatureHeader: string, +): [boolean, string | undefined] { + if (!secret) { + return [false, "webhook secret not configured"]; + } + if (!signatureHeader) { + return [false, "missing signature"]; + } + const signature = signatureHeader.trim(); + const prefix = "sha256="; + if (!signature.startsWith(prefix)) { + return [false, "invalid signature"]; + } + const expected = `${prefix}${createHmac("sha256", secret).update(body).digest("hex")}`; + if (!constantTimeEqual(expected, signature)) { + return [false, "invalid signature"]; + } + return [true, undefined]; +} + +function constantTimeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return timingSafeCompare(leftBuffer, rightBuffer); +} + +function timingSafeCompare(left: Buffer, right: Buffer): boolean { + let result = 0; + for (let index = 0; index < left.length; index += 1) { + result |= left[index] ^ right[index]; + } + return result === 0; +} + +export async function handleRequest( + config: AdapterConfig, + request: AdapterRequest, + debug: (message: string) => void = (message) => console.log(message), +): Promise { + const method = request.method.toUpperCase(); + if (method === "GET" && (request.path === "/" || request.path === "/healthz")) { + return {status: 200, body: {ok: true}}; + } + if (method !== "POST" || (request.path !== "/" && request.path !== "/webhook")) { + return {status: 404, body: {error: "not found"}}; + } + + const body = bodyFromContentLength(request, config.maxWebhookBodyBytes); + if (body === "too_large") { + return {status: 413, body: {error: "payload too large"}}; + } + + const [ok, error] = verifyWebhookSignature(config.webhookSecret, body, header(request.headers, "X-Hub-Signature-256")); + if (!ok) { + return { + status: error === "webhook secret not configured" ? 500 : 401, + body: {error}, + }; + } + + const eventName = header(request.headers, "X-GitHub-Event"); + if (!eventName) { + return {status: 400, body: {error: "missing event"}}; + } + + let payload: JsonObject; + try { + payload = JSON.parse(body.toString("utf8")) as JsonObject; + } catch { + return {status: 400, body: {error: "invalid json"}}; + } + + const deliveryId = header(request.headers, "X-GitHub-Delivery") || randomUUID(); + return { + status: 200, + body: await routeDelivery(config, eventName, deliveryId, payload, debug), + }; +} + +function payloadHash(payload: JsonObject): string { + return sha256(stableCompactStringify(payload)); +} + +function sha256(raw: string | Buffer): string { + return createHash("sha256").update(raw).digest("hex"); +} + +function deliveryRecord(deliveryId: string, eventName: string, payload: JsonObject): JsonObject { + const repository = (payload.repository as JsonObject | undefined) || {}; + const installation = (payload.installation as JsonObject | undefined) || {}; + return { + delivery_id: deliveryId, + event: eventName, + action: payload.action, + installation_id: installation.id, + repository_id: repository.id, + repository: repository.full_name, + payload_hash: payloadHash(payload), + received_at: utcNow(), + state: "received", + issue_refs: ["OpenCoven/coven-github#2"], + }; +} + +function mentioned(text: JsonValue | undefined, policy: JsonObject): boolean { + const normalized = String(text || "").toLowerCase(); + for (const username of ((policy.bot_usernames as JsonValue[]) || [])) { + if (normalized.includes(`@${String(username).toLowerCase()}`)) { + return true; + } + } + return false; +} + +function labelsIncludeTrigger(labels: JsonValue | undefined, policy: JsonObject): boolean { + const wanted = new Set(((policy.trigger_labels as JsonValue[]) || []).map((label) => String(label))); + for (const label of (Array.isArray(labels) ? labels : [])) { + const name = typeof label === "object" && label !== null && !Array.isArray(label) + ? String((label as JsonObject).name || "").trim() + : String(label).trim(); + if (wanted.has(name)) { + return true; + } + } + return false; +} + +export function buildTaskFromEvent( + eventName: string, + deliveryId: string, + payload: JsonObject, + policy: JsonObject, +): JsonObject { + const repository = (payload.repository as JsonObject | undefined) || {}; + const installation = (payload.installation as JsonObject | undefined) || {}; + const familiar = policy.familiar; + const fullName = String(repository.full_name || ""); + const base: JsonObject = { + task_id: deliveryId, + delivery_id: deliveryId, + created_at: utcNow(), + updated_at: utcNow(), + state: "queued", + attempts: 0, + installation_id: installation.id, + repository_id: repository.id, + repository: repository.full_name, + clone_url: repository.clone_url || `https://github.com/${fullName}.git`, + default_branch: repository.default_branch || policy.default_branch || "main", + familiar, + publication: policy.publication || {mode: "record_only"}, + issue_refs: ["OpenCoven/coven-github#2", "OpenCoven/coven-github#7"], + }; + if (!familiar) { + return ignored(base, "missing_familiar_policy"); + } + + if (eventName === "issue_comment") { + const issue = (payload.issue as JsonObject | undefined) || {}; + const comment = (payload.comment as JsonObject | undefined) || {}; + if (!mentioned(comment.body, policy)) { + return ignored(base, "issue_comment_without_mention"); + } + if (issue.pull_request) { + Object.assign(base, { + trigger: "pr_mention", + target: {kind: "pull_request", pr_number: Number(issue.number || 0)}, + task: { + kind: "respond_to_mention", + issue_number: Number(issue.number || 0), + comment_body: comment.body || "", + }, + issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#4"], + }); + return base; + } + Object.assign(base, { + trigger: "issue_mention", + task: { + kind: "respond_to_mention", + issue_number: Number(issue.number || 0), + comment_body: comment.body || "", + }, + issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#4"], + }); + return base; + } + + if (eventName === "pull_request_review_comment") { + const comment = (payload.comment as JsonObject | undefined) || {}; + const pullRequest = (payload.pull_request as JsonObject | undefined) || {}; + if (!mentioned(comment.body, policy)) { + return ignored(base, "pr_review_comment_without_mention"); + } + Object.assign(base, { + trigger: "pr_review_comment", + task: { + kind: "address_review_comment", + pr_number: Number(pullRequest.number || 0), + comment_body: comment.body || "", + diff_hunk: comment.diff_hunk, + path: comment.path, + line: comment.line, + side: comment.side, + commit_id: comment.commit_id, + html_url: comment.html_url, + }, + issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#4"], + }); + return base; + } + + if (eventName === "issues") { + const issue = (payload.issue as JsonObject | undefined) || {}; + const action = payload.action; + if (action !== "assigned" && action !== "labeled" && action !== "opened") { + return ignored(base, "unsupported_issue_action"); + } + if (action === "labeled" && !labelsIncludeTrigger(issue.labels, policy)) { + return ignored(base, "issue_label_not_enabled"); + } + Object.assign(base, { + trigger: action === "assigned" ? "issue_assigned" : "issue_mention", + task: { + kind: "fix_issue", + issue_number: Number(issue.number || 0), + issue_title: issue.title || "", + issue_body: issue.body || "", + }, + issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#4"], + }); + return base; + } + + if (eventName === "pull_request") { + const pullRequest = (payload.pull_request as JsonObject | undefined) || {}; + const head = (pullRequest.head as JsonObject | undefined) || {}; + const baseRef = (pullRequest.base as JsonObject | undefined) || {}; + Object.assign(base, { + state: "ignored", + ignored_reason: "pull_request_review_task_not_in_headless_contract_v1", + trigger: "pull_request", + target: { + action: payload.action, + number: pullRequest.number, + head_sha: head.sha, + head_ref: head.ref, + base_ref: baseRef.ref, + }, + issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#10"], + }); + return base; + } + + if (eventName === "push") { + Object.assign(base, { + state: "ignored", + ignored_reason: "push_review_task_not_in_headless_contract_v1", + trigger: "push", + target: { + ref: payload.ref, + before: payload.before, + after: payload.after, + commit_count: Array.isArray(payload.commits) ? payload.commits.length : 0, + }, + issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#10"], + }); + return base; + } + + return ignored(base, "unsupported_event"); +} + +function ignored(base: JsonObject, reason: string): JsonObject { + base.state = "ignored"; + base.ignored_reason = reason; + return base; +} + +async function routeDelivery( + config: AdapterConfig, + eventName: string, + deliveryId: string, + payload: JsonObject, + debug: (message: string) => void, +): Promise { + const deliveryFile = deliveryPath(config, deliveryId); + if (existsSync(deliveryFile)) { + const existing = readJson(deliveryFile, {}); + return { + ok: true, + action: "duplicate_ignored", + delivery_id: deliveryId, + task_id: existing.task_id, + state: existing.state, + }; + } + + const delivery = deliveryRecord(deliveryId, eventName, payload); + const [installationId, repoId, policy] = repoPolicy(config, payload); + if (!policy) { + delivery.state = "ignored"; + delivery.routing_result = "no_policy_for_installation_repo"; + delivery.installation_id = installationId || delivery.installation_id; + delivery.repository_id = repoId || delivery.repository_id; + writeJsonAtomic(deliveryFile, delivery); + return { + ok: true, + action: "ignored", + delivery_id: deliveryId, + reason: "no_policy_for_installation_repo", + }; + } + + const task = buildTaskFromEvent(eventName, deliveryId, payload, policy); + task.policy_snapshot = { + enabled_triggers: policy.enabled_triggers || [], + publication: policy.publication || {mode: "record_only"}, + }; + writeJsonAtomic(taskPath(config, String(task.task_id)), task); + + delivery.task_id = task.task_id; + delivery.state = task.state; + delivery.routing_result = task.ignored_reason || "queued"; + writeJsonAtomic(deliveryFile, delivery); + + if (task.state === "queued") { + try { + await runTask(config, String(task.task_id)); + } catch (error) { + debug(`COVEN GITHUB TASK RUN FAIL task_id=${task.task_id} ${String((error as Error).stack || error)}`); + } + } + + return { + ok: true, + action: task.state !== "ignored" ? "accepted" : "ignored", + delivery_id: deliveryId, + task_id: task.task_id, + state: readJson(taskPath(config, String(task.task_id)), task).state, + reason: task.ignored_reason, + queued: task.state === "queued", + }; +} + +function runCommand(args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutSeconds = 300): CommandResult { + const proc = spawnSync(args[0], args.slice(1), { + cwd, + env, + encoding: "utf8", + timeout: timeoutSeconds * 1000, + maxBuffer: 20 * 1024 * 1024, + }); + return { + args, + returncode: proc.status ?? 1, + stdout: String(proc.stdout || "").slice(-8000), + stderr: `${String(proc.stderr || "")}${proc.error ? String(proc.error.message || proc.error) : ""}`.slice(-8000), + }; +} + +function writeAskpass(workDir: string): string { + const script = join(workDir, "git-askpass.sh"); + writeFileSync(script, "#!/bin/sh\nprintf '%s\\n' \"$COVEN_GIT_TOKEN\"\n", {encoding: "utf8", mode: 0o700}); + return script; +} + +function sessionBrief( + task: JsonObject, + workspace: string, + reviewContext?: JsonObject | null, + extraAuditInstruction?: string, +): JsonObject { + const [owner, name] = String(task.repository || "/").split("/", 2); + const brief: JsonObject = { + contract_version: "2", + trigger: task.trigger, + repo: { + owner, + name, + clone_url: task.clone_url, + default_branch: task.default_branch, + }, + task: task.task, + familiar: task.familiar, + workspace: {root: workspace}, + }; + if (reviewContext) { + brief.review_context = reviewContext; + let 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 (extraAuditInstruction) { + instruction = `${instruction}\n\n${extraAuditInstruction}`; + } + brief.audit_instruction = instruction; + } + return brief; +} + +function runCovenCodeCycle( + config: AdapterConfig, + task: JsonObject, + workspace: string, + reviewContext: JsonObject | null | undefined, + attemptDir: string, + env: NodeJS.ProcessEnv, + cycle: number, + extraAuditInstruction?: string, +): CycleResult { + const suffix = cycle === 0 ? "" : `-repair-${cycle}`; + const briefPath = join(attemptDir, `session-brief${suffix}.json`); + const resultPath = join(attemptDir, `result${suffix}.json`); + const runPath = join(attemptDir, `run${suffix}.json`); + + writeJsonAtomic(briefPath, sessionBrief(task, workspace, reviewContext, extraAuditInstruction)); + const run = runCommand( + [ + config.covenCodeBin, + "--headless", + "--hosted-review", + "--provider", + "codex", + "--model", + config.covenCodeModel, + "--context", + briefPath, + "--output", + resultPath, + ], + workspace, + env, + 1800, + ); + writeJsonAtomic(runPath, redactedCommandResult(run)); + return { + cycle, + brief_path: briefPath, + result_path: resultPath, + run_path: runPath, + run, + result: existsSync(resultPath) ? readJson(resultPath, null) : null, + }; +} + +function reviewFindings(result: JsonObject | null | undefined): JsonObject[] { + if (!result) { + return []; + } + const review = (result.review as JsonObject | undefined) || {}; + const mode = review.mode; + if (mode !== "pull_request" && mode !== "review_comment") { + return []; + } + return Array.isArray(review.findings) ? (review.findings as JsonObject[]) : []; +} + +function reviewFixInstruction(findings: JsonObject[], iteration: number, maxIterations: number): string { + const lines = [ + `Autofix review loop iteration ${iteration}/${maxIterations}.`, + "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:", + ]; + findings.slice(0, 10).forEach((finding, index) => { + let location = String(finding.file || "unknown file"); + if (finding.line !== undefined && finding.line !== null) { + location = `${location}:${finding.line}`; + } + lines.push(`${index + 1}. [${finding.severity || "unknown"}] \`${location}\` ${finding.title || "Untitled finding"}`); + const body = String(finding.body || "").trim(); + if (body) { + lines.push(` Body: ${body.slice(0, 1200)}`); + } + const recommendation = String(finding.recommendation || "").trim(); + if (recommendation) { + lines.push(` Recommendation: ${recommendation.slice(0, 1200)}`); + } + }); + if (findings.length > 10) { + lines.push("Only the first 10 findings are listed; inspect the prior result for the full set."); + } + return lines.join("\n"); +} + +function taskWithRepairRequest(task: JsonObject, instruction: string): JsonObject { + const copy = JSON.parse(JSON.stringify(task)) as JsonObject; + const taskData = ((copy.task as JsonObject | undefined) || {}) as JsonObject; + const explicitRequest = `\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 taskData) { + taskData.comment_body = String(taskData.comment_body || "") + explicitRequest; + } else if ("issue_body" in taskData) { + taskData.issue_body = String(taskData.issue_body || "") + explicitRequest; + } + copy.task = taskData; + return copy; +} + +async function runTask(config: AdapterConfig, taskId: string): Promise { + const path = taskPath(config, taskId); + const task = readJson(path, {}); + if (task.state !== "queued") { + return task; + } + + task.state = "running"; + task.attempts = Number(task.attempts || 0) + 1; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + + const attemptDir = join(config.attemptsDir, taskId, String(task.attempts)); + mkdirSync(attemptDir, {recursive: true}); + const workspace = join(config.workspacesDir, taskId, "repo"); + + try { + const token = await installationToken(config, task.installation_id); + const askpass = writeAskpass(attemptDir); + const env: NodeJS.ProcessEnv = { + ...process.env, + GIT_ASKPASS: askpass, + GIT_TERMINAL_PROMPT: "0", + COVEN_GIT_TOKEN: token, + COVEN_CODE_PROVIDER: "codex", + COVEN_CODE_HOSTED_REVIEW: "1", + HOME: dirname(dirname(config.codexTokensPath)), + }; + const codexAccessToken = loadCodexAccessToken(config); + if (!codexAccessToken) { + return failTask(path, task, "codex_auth_missing", `Missing Codex access token at ${config.codexTokensPath}`); + } + env.OPENAI_API_KEY = codexAccessToken; + + if (!existsSync(workspace)) { + const clone = runCommand( + ["git", "clone", "--depth", "1", "--branch", String(task.default_branch), String(task.clone_url), workspace], + undefined, + env, + 180, + ); + writeJsonAtomic(join(attemptDir, "clone.json"), redactedCommandResult(clone)); + if (clone.returncode !== 0) { + return failTask(path, task, "clone_failed", clone.stderr); + } + } + + const reviewContext = await prepareReviewContext(config, task, workspace, token, env, attemptDir); + if (reviewContext) { + const reviewContextPath = join(attemptDir, "review-context.json"); + writeJsonAtomic(reviewContextPath, reviewContext); + task.review_context_path = reviewContextPath; + task.review_context_sha256 = fileSha256(reviewContextPath); + task.review_evidence = reviewEvidence(reviewContext, reviewContextPath, task); + writeJsonAtomic(path, task); + } + + if (!commandExists(config.covenCodeBin)) { + return failTask(path, task, "runtime_missing", `COVEN_CODE_BIN is not available on the host: ${config.covenCodeBin}`); + } + + const firstCycle = runCovenCodeCycle(config, task, workspace, reviewContext, attemptDir, env, 0); + task.session_brief_path = firstCycle.brief_path; + task.session_brief_sha256 = fileSha256(String(firstCycle.brief_path)); + task.runtime_exit_code = firstCycle.run.returncode; + task.result_path = firstCycle.result_path; + writeJsonAtomic(path, task); + + if (!firstCycle.result) { + return failTask(path, task, "result_missing", `coven-code exited ${firstCycle.run.returncode} without writing result.json: ${firstCycle.run.stderr}`); + } + + let finalCycle = firstCycle; + const loopRecords: JsonObject[] = []; + for (let iteration = 1; iteration <= config.maxReviewFixLoops; iteration += 1) { + const findings = reviewFindings(finalCycle.result as JsonObject); + if (!findings.length) { + break; + } + const instruction = reviewFixInstruction(findings, iteration, config.maxReviewFixLoops); + const repairTask = taskWithRepairRequest(task, instruction); + const repairCycle = runCovenCodeCycle(config, repairTask, workspace, reviewContext, attemptDir, env, iteration, instruction); + const remaining = reviewFindings(repairCycle.result as JsonObject); + loopRecords.push({ + iteration, + input_findings: findings.length, + runtime_exit_code: repairCycle.run.returncode, + result_path: repairCycle.result_path, + result_status: ((repairCycle.result as JsonObject | null)?.status as JsonValue) || undefined, + remaining_findings: remaining.length, + }); + task.review_fix_loops = loopRecords; + task.runtime_exit_code = repairCycle.run.returncode; + task.result_path = repairCycle.result_path; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + + if (!repairCycle.result) { + return failTask(path, task, "result_missing", `review repair loop ${iteration} exited ${repairCycle.run.returncode} without writing result.json: ${repairCycle.run.stderr}`); + } + finalCycle = repairCycle; + } + + task.runtime_exit_code = finalCycle.run.returncode; + task.result_path = finalCycle.result_path; + task.state = [0, 1, 3].includes(finalCycle.run.returncode) ? "completed" : "failed"; + task.updated_at = utcNow(); + await publishResultIfConfigured(task, String(finalCycle.result_path), token); + writeJsonAtomic(path, task); + return task; + } catch (error) { + return failTask(path, task, "infra_error", String((error as Error).stack || error)); + } +} + +function commandExists(command: string): boolean { + return runCommand(["/bin/sh", "-lc", `command -v ${shellQuote(command)}`], undefined, undefined, 10).returncode === 0; +} + +function shellQuote(value: string): string { + return `'${String(value).replaceAll("'", "'\"'\"'")}'`; +} + +function prNumberForTask(task: JsonObject): number | null { + const taskData = (task.task as JsonObject | undefined) || {}; + const target = (task.target as JsonObject | undefined) || {}; + if (target.kind === "pull_request" && target.pr_number) { + return Number(target.pr_number); + } + if (taskData.pr_number) { + return Number(taskData.pr_number); + } + return null; +} + +async function prepareReviewContext( + config: AdapterConfig, + task: JsonObject, + workspace: string, + token: string, + env: NodeJS.ProcessEnv, + attemptDir: string, +): Promise { + const prNumber = prNumberForTask(task); + if (!prNumber) { + return null; + } + + const repo = String(task.repository); + const pr = (await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}`, token)) as JsonObject; + const files = (await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}/files?per_page=100`, token)) as JsonObject[]; + + const fetch = runCommand(["git", "fetch", "--depth", "1", "origin", `pull/${prNumber}/head`], workspace, env, 180); + writeJsonAtomic(join(attemptDir, "fetch-pr.json"), redactedCommandResult(fetch)); + if (fetch.returncode !== 0) { + return { + kind: "pull_request", + pr_number: prNumber, + fetch_error: fetch.stderr, + metadata: summarizePr(pr), + files: summarizePrFiles(files), + }; + } + + const checkout = runCommand(["git", "checkout", "--detach", "FETCH_HEAD"], workspace, env); + writeJsonAtomic(join(attemptDir, "checkout-pr.json"), redactedCommandResult(checkout)); + const head = runCommand(["git", "rev-parse", "HEAD"], workspace, env); + const status = runCommand(["git", "status", "--short", "--branch"], workspace, env); + writeJsonAtomic(join(attemptDir, "workspace-git.json"), redactedCommandResult({ + args: ["git evidence"], + returncode: head.returncode === 0 && status.returncode === 0 ? 0 : 1, + stdout: `HEAD=${head.stdout.trim()}\n${status.stdout.trim()}`, + stderr: head.stderr + status.stderr, + })); + + return { + kind: "pull_request", + pr_number: prNumber, + metadata: summarizePr(pr), + files: summarizePrFiles(files), + checkout: { + fetch_returncode: fetch.returncode, + checkout_returncode: checkout.returncode, + workspace_head_sha: head.stdout.trim(), + workspace_status: status.stdout.trim(), + }, + }; +} + +function summarizePr(pr: JsonObject): JsonObject { + const base = (pr.base as JsonObject | undefined) || {}; + const head = (pr.head as JsonObject | undefined) || {}; + return { + number: pr.number, + title: pr.title, + state: pr.state, + html_url: pr.html_url, + base_ref: base.ref, + base_sha: base.sha, + head_ref: head.ref, + head_sha: head.sha, + merge_commit_sha: pr.merge_commit_sha, + }; +} + +function summarizePrFiles(files: JsonObject[]): JsonObject[] { + return (files || []).map((item) => { + const patch = String(item.patch || ""); + return { + filename: item.filename, + status: item.status, + additions: item.additions, + deletions: item.deletions, + changes: item.changes, + sha: item.sha, + patch: patch.slice(0, 12000), + patch_truncated: patch.length > 12000, + }; + }); +} + +function reviewEvidence(reviewContext: JsonObject, reviewContextPath: string, task: JsonObject): JsonObject { + const metadata = (reviewContext.metadata as JsonObject | undefined) || {}; + const files = Array.isArray(reviewContext.files) ? (reviewContext.files as JsonObject[]) : []; + const checkout = (reviewContext.checkout as JsonObject | undefined) || {}; + return { + pr_number: reviewContext.pr_number, + base_ref: metadata.base_ref, + base_sha: metadata.base_sha, + head_ref: metadata.head_ref, + head_sha: metadata.head_sha, + workspace_head_sha: checkout.workspace_head_sha, + changed_file_count: files.length, + changed_files: files.map((file) => file.filename).filter((file): file is JsonValue => file !== undefined), + review_context_path: reviewContextPath, + review_context_sha256: fileSha256(reviewContextPath), + }; +} + +function fileSha256(path: string): string { + return sha256(readFileSync(path)); +} + +async function publishResultIfConfigured(task: JsonObject, resultPath: string, token: string): Promise { + const publication = (task.publication as JsonObject | undefined) || {}; + const mode = publication.mode || "record_only"; + if (mode !== "comment") { + task.publication_state = "held_for_issue_11_publication_gates"; + return; + } + + const result = readJson(resultPath, {}); + const taskData = (task.task as JsonObject | undefined) || {}; + const number = taskData.issue_number || taskData.pr_number; + if (!number) { + task.publication_state = "publication_skipped_no_issue_or_pr_number"; + return; + } + + const body = publicationCommentBody(task, result); + const repo = String(task.repository); + try { + const response = (await githubRequest("POST", `https://api.github.com/repos/${repo}/issues/${Number(number)}/comments`, token, {body})) as JsonObject; + task.publication_state = "published_comment"; + task.publication_url = response.html_url; + task.publication_comment_id = response.id; + } catch (error) { + task.publication_state = "publication_failed"; + task.publication_error = redactTokenish(String((error as Error).stack || error)); + } +} + +function publicationCommentBody(task: JsonObject, result: JsonObject): string { + const status = result.status || "unknown"; + const summary = String(result.summary || "No summary returned."); + const prBody = String(result.pr_body || ""); + const filesChanged = Array.isArray(result.files_changed) ? result.files_changed : []; + const commits = Array.isArray(result.commits) ? result.commits : []; + const taskId = String(task.task_id || ""); + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + const review = (result.review as JsonObject | undefined) || {}; + const parts = ["## Cody dogfood result", "", `**Status:** ${status}`, "", summary.trim()]; + if (prBody.trim() && prBody.trim() !== summary.trim()) { + parts.push("", prBody.trim()); + } + parts.push(...reviewFixLoopLines(task), "", "### Evidence"); + if (Object.keys(evidence).length) { + const changedFiles = Array.isArray(evidence.changed_files) ? evidence.changed_files : []; + parts.push( + `- PR: #${evidence.pr_number}`, + `- Base: \`${evidence.base_ref}\` @ \`${evidence.base_sha}\``, + `- Head: \`${evidence.head_ref}\` @ \`${evidence.head_sha}\``, + `- Checked-out workspace HEAD: \`${evidence.workspace_head_sha}\``, + `- Changed files supplied to agent: ${evidence.changed_file_count}`, + `- Review context SHA-256: \`${evidence.review_context_sha256}\``, + ); + if (changedFiles.length) { + parts.push(`- Files: ${changedFiles.slice(0, 20).map((file) => `\`${file}\``).join(", ")}`); + } + } else { + parts.push("- No PR review evidence was captured for this run."); + } + parts.push(...structuredReviewLines(review)); + parts.push( + "", + `**Files changed:** ${filesChanged.length}`, + `**Commits:** ${commits.length}`, + "", + `_Task \`${taskId}\`. Publication is enabled on the hosted test adapter only._`, + ); + return parts.join("\n"); +} + +function reviewFixLoopLines(task: JsonObject): string[] { + const loops = Array.isArray(task.review_fix_loops) ? (task.review_fix_loops as JsonObject[]) : []; + if (!loops.length) { + return []; + } + const lines = ["", "### Review fix loop"]; + for (const loop of loops) { + lines.push(`- Iteration ${loop.iteration}: input findings ${loop.input_findings}, result \`${loop.result_status || "unknown"}\`, remaining findings ${loop.remaining_findings}.`); + } + if (loops.at(-1)?.remaining_findings) { + lines.push(`- Loop stopped after ${loops.length} configured iteration(s); unresolved findings remain.`); + } + return lines; +} + +function structuredReviewLines(review: JsonObject): string[] { + if (!Object.keys(review).length) { + return ["", "### Structured review", "- No structured review result was emitted."]; + } + + const lines = [ + "", + "### Structured review", + `- Mode: \`${review.mode || "unknown"}\``, + `- Evidence status: \`${review.evidence_status || "unknown"}\``, + ]; + const reviewedFiles = Array.isArray(review.reviewed_files) ? review.reviewed_files : []; + lines.push(`- Reviewed files: ${reviewedFiles.length}`); + if (reviewedFiles.length) { + lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`); + if (reviewedFiles.length > 20) { + lines.push("- Reviewed file list truncated after 20 entries."); + } + } + const supportingFiles = Array.isArray(review.supporting_files) ? review.supporting_files : []; + lines.push(`- Supporting files inspected: ${supportingFiles.length}`); + if (supportingFiles.length) { + lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`); + if (supportingFiles.length > 20) { + lines.push("- Supporting file list truncated after 20 entries."); + } + } + const findings = Array.isArray(review.findings) ? (review.findings as JsonObject[]) : []; + lines.push(`- Findings: ${findings.length}`); + findings.slice(0, 10).forEach((finding, index) => { + let location = String(finding.file || "unknown file"); + if (finding.line !== undefined && finding.line !== null) { + location = `${location}:${finding.line}`; + } + lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${location} - ${finding.title || "Untitled finding"}`); + }); + if (findings.length > 10) { + lines.push("- Findings truncated after 10 entries."); + } + if (review.no_findings_reason) { + lines.push(`- No-findings reason: ${review.no_findings_reason}`); + } + const testsRun = Array.isArray(review.tests_run) ? (review.tests_run as JsonObject[]) : []; + lines.push(`- Tests reported by runtime: ${testsRun.length}`); + testsRun.slice(0, 10).forEach((item) => { + const summary = item.output_summary ? ` - ${item.output_summary}` : ""; + lines.push(` - \`${item.command || "unknown command"}\`: \`${item.status || "unknown"}\`${summary}`); + }); + if (testsRun.length > 10) { + lines.push("- Test list truncated after 10 entries."); + } + const limitations = Array.isArray(review.limitations) ? review.limitations : []; + lines.push(`- Limitations: ${limitations.length}`); + limitations.slice(0, 10).forEach((limitation) => lines.push(` - ${limitation}`)); + if (limitations.length > 10) { + lines.push("- Limitation list truncated after 10 entries."); + } + return lines; +} + +function loadCodexAccessToken(config: AdapterConfig): string | null { + for (const path of codexTokenCandidates(config)) { + try { + const data = readJson(path, {}); + const token = String(data.access_token || "").trim(); + if (token) { + return token; + } + } catch { + continue; + } + } + return null; +} + +function codexTokenCandidates(config: AdapterConfig): string[] { + const candidates = [config.codexTokensPath]; + const covenHome = dirname(config.codexTokensPath); + const registry = readJson(join(covenHome, "accounts.json"), {}); + const active = (((registry.providers as JsonObject | undefined)?.codex as JsonObject | undefined)?.active as JsonValue) || ""; + if (active) { + candidates.push(join(covenHome, "accounts", "codex", String(active), "codex_tokens.json")); + } + const accountsRoot = join(covenHome, "accounts", "codex"); + if (existsSync(accountsRoot)) { + for (const entry of readdirSync(accountsRoot, {withFileTypes: true})) { + if (entry.isDirectory()) { + candidates.push(join(accountsRoot, entry.name, "codex_tokens.json")); + } + } + } + return candidates; +} + +function redactedCommandResult(result: CommandResult): JsonObject { + return { + ...result, + stdout: redactTokenish(result.stdout), + stderr: redactTokenish(result.stderr), + }; +} + +function redactTokenish(text: string): string { + if (!text) { + return text; + } + const markers = ["ghs_", "ghu_", "github_pat_", "x-access-token:"]; + let redacted = text; + for (const marker of markers) { + while (redacted.includes(marker)) { + const index = redacted.indexOf(marker); + let end = index + marker.length; + while (end < redacted.length && !" \n\r\t'\"".includes(redacted[end])) { + end += 1; + } + redacted = `${redacted.slice(0, index)}${marker}[redacted]${redacted.slice(end)}`; + } + } + return redacted; +} + +function failTask(path: string, task: JsonObject, reason: string, detail: string): JsonObject { + task.state = "failed"; + task.failure_category = reason; + task.failure_detail = redactTokenish(String(detail)).slice(-4000); + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..a6da34b --- /dev/null +++ b/src/server.ts @@ -0,0 +1,55 @@ +import {createServer, type IncomingHttpHeaders} from "node:http"; +import {pathToFileURL} from "node:url"; + +import {createConfig, handleRequest, type AdapterConfig} from "./adapter.js"; + +function headersToMap(headers: IncomingHttpHeaders): Map { + const map = new Map(); + for (const [name, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + map.set(name.toLowerCase(), value.join(", ")); + } else if (value !== undefined) { + map.set(name.toLowerCase(), value); + } + } + return map; +} + +async function readBody(req: NodeJS.ReadableStream, limit: number): Promise { + const chunks: Buffer[] = []; + let total = 0; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += buffer.length; + if (total > limit) { + return Buffer.concat([...chunks, buffer], total); + } + chunks.push(buffer); + } + return Buffer.concat(chunks, total); +} + +export function createWebhookServer(config: AdapterConfig = createConfig()) { + return createServer(async (req, res) => { + const rawBody = await readBody(req, config.maxWebhookBodyBytes + 1); + const response = await handleRequest(config, { + method: req.method || "GET", + path: req.url?.split("?")[0] || "/", + headers: headersToMap(req.headers), + rawBody, + }); + const body = Buffer.from(JSON.stringify(response.body)); + res.statusCode = response.status; + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Length", String(body.length)); + res.end(body); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const port = Number.parseInt(process.env.PORT || "3000", 10); + const server = createWebhookServer(); + server.listen(port, () => { + console.log(`coven-github webhook listening on :${port}`); + }); +} diff --git a/tests/test_webhook_adapter.py b/tests/test_webhook_adapter.py deleted file mode 100644 index 574e868..0000000 --- a/tests/test_webhook_adapter.py +++ /dev/null @@ -1,285 +0,0 @@ -import hashlib -import hmac -import importlib -import io -import json -import os -import sys -import tempfile -import unittest - - -def import_adapter(state_dir, webhook_secret="test-webhook-secret"): - os.environ["COVEN_GITHUB_STATE_DIR"] = str(state_dir) - os.environ["COVEN_GITHUB_POLICY_PATH"] = str(state_dir / "policy.json") - os.environ.pop("WEBHOOK_SECRET", None) - os.environ["GITHUB_WEBHOOK_SECRET"] = webhook_secret - sys.modules.pop("coven_github_adapter", None) - return importlib.import_module("coven_github_adapter") - - -def import_adapter_with_legacy_secret(state_dir, webhook_secret="legacy-webhook-secret"): - os.environ["COVEN_GITHUB_STATE_DIR"] = str(state_dir) - os.environ["COVEN_GITHUB_POLICY_PATH"] = str(state_dir / "policy.json") - os.environ.pop("GITHUB_WEBHOOK_SECRET", None) - os.environ["WEBHOOK_SECRET"] = webhook_secret - sys.modules.pop("coven_github_adapter", None) - return importlib.import_module("coven_github_adapter") - - -def signature(secret, body): - digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() - return "sha256=" + digest - - -class WebhookAdapterTests(unittest.TestCase): - def call_app(self, adapter, body, headers=None, content_length="auto"): - headers = headers or {} - status_headers = [] - environ = { - "REQUEST_METHOD": "POST", - "PATH_INFO": "/webhook", - "wsgi.input": io.BytesIO(body), - } - if content_length == "auto": - environ["CONTENT_LENGTH"] = str(len(body)) - elif content_length is not None: - environ["CONTENT_LENGTH"] = str(content_length) - for name, value in headers.items(): - environ["HTTP_" + name.upper().replace("-", "_")] = value - - def start_response(status, response_headers): - status_headers.append((status, response_headers)) - - response = b"".join(adapter.application(environ, start_response)) - status = status_headers[0][0] - return status, json.loads(response.decode("utf-8")) - - def test_webhook_rejects_missing_and_invalid_signatures(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - adapter = import_adapter(Path(tmp)) - body = b'{"zen":"Keep it logically awesome."}' - - missing_status, missing_payload = self.call_app( - adapter, - body, - {"X-GitHub-Event": "ping", "X-GitHub-Delivery": "delivery-1"}, - ) - self.assertEqual(missing_status, "401 Unauthorized") - self.assertEqual(missing_payload["error"], "missing signature") - - invalid_status, invalid_payload = self.call_app( - adapter, - body, - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-2", - "X-Hub-Signature-256": "sha256=deadbeef", - }, - ) - self.assertEqual(invalid_status, "401 Unauthorized") - self.assertEqual(invalid_payload["error"], "invalid signature") - - def test_webhook_accepts_valid_signed_ping_without_runtime(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - secret = "valid-webhook-secret" - adapter = import_adapter(Path(tmp), secret) - body = b'{"zen":"Keep it logically awesome."}' - - status, payload = self.call_app( - adapter, - body, - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-3", - "X-Hub-Signature-256": signature(secret, body), - }, - ) - - self.assertEqual(status, "200 OK") - self.assertTrue(payload["ok"]) - self.assertEqual(payload["action"], "ignored") - self.assertEqual(payload["reason"], "no_policy_for_installation_repo") - - def test_webhook_reads_body_when_content_length_is_missing(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - secret = "missing-length-secret" - adapter = import_adapter(Path(tmp), secret) - body = b'{"zen":"Keep it logically awesome."}' - - status, payload = self.call_app( - adapter, - body, - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-missing-length", - "X-Hub-Signature-256": signature(secret, body), - }, - content_length=None, - ) - - self.assertEqual(status, "200 OK") - self.assertTrue(payload["ok"]) - - def test_webhook_reads_body_when_content_length_is_unparsable(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - secret = "bad-length-secret" - adapter = import_adapter(Path(tmp), secret) - body = b'{"zen":"Keep it logically awesome."}' - - status, payload = self.call_app( - adapter, - body, - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-bad-length", - "X-Hub-Signature-256": signature(secret, body), - }, - content_length="not-a-number", - ) - - self.assertEqual(status, "200 OK") - self.assertTrue(payload["ok"]) - - def test_webhook_treats_zero_content_length_as_empty_body(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - secret = "zero-length-secret" - adapter = import_adapter(Path(tmp), secret) - - status, payload = self.call_app( - adapter, - b'{"zen":"Keep it logically awesome."}', - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-zero-length", - "X-Hub-Signature-256": signature(secret, b""), - }, - content_length=0, - ) - - self.assertEqual(status, "400 Bad Request") - self.assertEqual(payload["error"], "invalid json") - - def test_webhook_rejects_oversized_content_length_before_signature_check(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - adapter = import_adapter(Path(tmp)) - status, payload = self.call_app( - adapter, - b"", - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-large-body", - "X-Hub-Signature-256": "sha256=deadbeef", - }, - content_length=10 * 1024 * 1024 + 1, - ) - - self.assertEqual(status, "413 Payload Too Large") - self.assertEqual(payload["error"], "payload too large") - - def test_webhook_signature_allows_surrounding_whitespace(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - secret = "whitespace-secret" - adapter = import_adapter(Path(tmp), secret) - body = b'{"zen":"Keep it logically awesome."}' - - status, payload = self.call_app( - adapter, - body, - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-whitespace-signature", - "X-Hub-Signature-256": " " + signature(secret, body) + " ", - }, - ) - - self.assertEqual(status, "200 OK") - self.assertTrue(payload["ok"]) - - def test_webhook_reports_missing_secret_as_server_misconfiguration(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - adapter = import_adapter(Path(tmp), "") - body = b'{"zen":"Keep it logically awesome."}' - - status, payload = self.call_app( - adapter, - body, - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-missing-secret", - "X-Hub-Signature-256": signature("ignored", body), - }, - ) - - self.assertEqual(status, "500 Internal Server Error") - self.assertEqual(payload["error"], "webhook secret not configured") - - def test_webhook_secret_supports_smoke_script_environment_name(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - secret = "legacy-webhook-secret" - adapter = import_adapter_with_legacy_secret(Path(tmp), secret) - body = b'{"zen":"Keep it logically awesome."}' - - status, payload = self.call_app( - adapter, - body, - { - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "delivery-legacy", - "X-Hub-Signature-256": signature(secret, body), - }, - ) - - self.assertEqual(status, "200 OK") - self.assertTrue(payload["ok"]) - - def test_missing_familiar_policy_does_not_fall_back_to_hardcoded_installation(self): - with tempfile.TemporaryDirectory() as tmp: - from pathlib import Path - - adapter = import_adapter(Path(tmp)) - task = adapter.build_task_from_event( - "issues", - "delivery-4", - { - "action": "opened", - "installation": {"id": 111}, - "repository": { - "id": 222, - "full_name": "OpenCoven/example", - "clone_url": "https://github.com/OpenCoven/example.git", - "default_branch": "main", - }, - "issue": {"number": 7, "title": "Fix it", "body": "Please fix it."}, - }, - { - "trigger_labels": ["coven:fix"], - "bot_usernames": ["coven-github[bot]"], - "publication": {"mode": "record_only"}, - }, - ) - - self.assertEqual(task["state"], "ignored") - self.assertEqual(task["ignored_reason"], "missing_familiar_policy") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/webhook-adapter.test.ts b/tests/webhook-adapter.test.ts new file mode 100644 index 0000000..b1b396f --- /dev/null +++ b/tests/webhook-adapter.test.ts @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + buildTaskFromEvent, + createConfig, + handleRequest, + type JsonObject, +} from "../src/adapter.js"; + +function tempStateDir(): string { + return mkdtempSync(join(tmpdir(), "coven-github-webhook-")); +} + +function testConfig(stateDir: string, webhookSecret = "test-webhook-secret") { + return createConfig( + { + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: webhookSecret, + }, + process.cwd(), + ); +} + +function legacySecretConfig(stateDir: string, webhookSecret = "legacy-webhook-secret") { + return createConfig( + { + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + WEBHOOK_SECRET: webhookSecret, + }, + process.cwd(), + ); +} + +function signature(secret: string, body: Buffer): string { + return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; +} + +async function callWebhook( + body: Buffer, + headers: Record = {}, + contentLength: string | null | "auto" = "auto", + config = testConfig(tempStateDir()), +) { + const requestHeaders = new Map(); + if (contentLength === "auto") { + requestHeaders.set("content-length", String(body.length)); + } else if (contentLength !== null) { + requestHeaders.set("content-length", contentLength); + } + for (const [name, value] of Object.entries(headers)) { + requestHeaders.set(name.toLowerCase(), value); + } + + return handleRequest(config, { + method: "POST", + path: "/webhook", + headers: requestHeaders, + rawBody: body, + }); +} + +test("webhook rejects missing and invalid signatures", async () => { + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + const config = testConfig(tempStateDir()); + + const missing = await callWebhook( + body, + {"X-GitHub-Event": "ping", "X-GitHub-Delivery": "delivery-1"}, + "auto", + config, + ); + assert.equal(missing.status, 401); + assert.equal(missing.body.error, "missing signature"); + + const invalid = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-2", + "X-Hub-Signature-256": "sha256=deadbeef", + }, + "auto", + config, + ); + assert.equal(invalid.status, 401); + assert.equal(invalid.body.error, "invalid signature"); +}); + +test("webhook accepts valid signed ping without runtime", async () => { + const secret = "valid-webhook-secret"; + const config = testConfig(tempStateDir(), secret); + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + + const response = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-3", + "X-Hub-Signature-256": signature(secret, body), + }, + "auto", + config, + ); + + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); + assert.equal(response.body.action, "ignored"); + assert.equal(response.body.reason, "no_policy_for_installation_repo"); +}); + +test("webhook reads body when content length is missing", async () => { + const secret = "missing-length-secret"; + const config = testConfig(tempStateDir(), secret); + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + + const response = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-missing-length", + "X-Hub-Signature-256": signature(secret, body), + }, + null, + config, + ); + + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); +}); + +test("webhook reads body when content length is unparsable", async () => { + const secret = "bad-length-secret"; + const config = testConfig(tempStateDir(), secret); + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + + const response = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-bad-length", + "X-Hub-Signature-256": signature(secret, body), + }, + "not-a-number", + config, + ); + + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); +}); + +test("webhook treats partially numeric content length as unparsable", async () => { + const secret = "partial-length-secret"; + const config = testConfig(tempStateDir(), secret); + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + + const response = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-partial-length", + "X-Hub-Signature-256": signature(secret, body), + }, + "12oops", + config, + ); + + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); +}); + +test("webhook treats zero content length as empty body", async () => { + const secret = "zero-length-secret"; + const config = testConfig(tempStateDir(), secret); + + const response = await callWebhook( + Buffer.from('{"zen":"Keep it logically awesome."}'), + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-zero-length", + "X-Hub-Signature-256": signature(secret, Buffer.alloc(0)), + }, + "0", + config, + ); + + assert.equal(response.status, 400); + assert.equal(response.body.error, "invalid json"); +}); + +test("webhook rejects oversized content length before signature check", async () => { + const response = await callWebhook( + Buffer.alloc(0), + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-large-body", + "X-Hub-Signature-256": "sha256=deadbeef", + }, + String(10 * 1024 * 1024 + 1), + ); + + assert.equal(response.status, 413); + assert.equal(response.body.error, "payload too large"); +}); + +test("webhook signature allows surrounding whitespace", async () => { + const secret = "whitespace-secret"; + const config = testConfig(tempStateDir(), secret); + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + + const response = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-whitespace-signature", + "X-Hub-Signature-256": ` ${signature(secret, body)} `, + }, + "auto", + config, + ); + + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); +}); + +test("webhook reports missing secret as server misconfiguration", async () => { + const config = testConfig(tempStateDir(), ""); + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + + const response = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-missing-secret", + "X-Hub-Signature-256": signature("ignored", body), + }, + "auto", + config, + ); + + assert.equal(response.status, 500); + assert.equal(response.body.error, "webhook secret not configured"); +}); + +test("webhook secret supports smoke script environment name", async () => { + const secret = "legacy-webhook-secret"; + const config = legacySecretConfig(tempStateDir(), secret); + const body = Buffer.from('{"zen":"Keep it logically awesome."}'); + + const response = await callWebhook( + body, + { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": "delivery-legacy", + "X-Hub-Signature-256": signature(secret, body), + }, + "auto", + config, + ); + + assert.equal(response.status, 200); + assert.equal(response.body.ok, true); +}); + +test("missing familiar policy does not fall back to hardcoded installation", () => { + const task = buildTaskFromEvent( + "issues", + "delivery-4", + { + action: "opened", + installation: {id: 111}, + repository: { + id: 222, + full_name: "OpenCoven/example", + clone_url: "https://github.com/OpenCoven/example.git", + default_branch: "main", + }, + issue: {number: 7, title: "Fix it", body: "Please fix it."}, + } as JsonObject, + { + trigger_labels: ["coven:fix"], + bot_usernames: ["coven-github[bot]"], + publication: {mode: "record_only"}, + } as JsonObject, + ); + + assert.equal(task.state, "ignored"); + assert.equal(task.ignored_reason, "missing_familiar_policy"); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2bb9ee7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +}