From 616b185f1534da1d7cb9995783decb87b422f9c7 Mon Sep 17 00:00:00 2001 From: liplus-lin-lay <259586417+liplus-lin-lay@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:50:04 +0900 Subject: [PATCH] feat(webhook): add direct trigger mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook受信時に任意コマンドを直列実行できる direct trigger queue を追加し、trigger_status と last_triggered_at を保存するようにしました。\nCodex 用の bundled wrapper に resume 対応と notify-only fallback を加え、README/.env.example/tests も更新しています。\n\nRefs #13 --- .env.example | 3 + .gitignore | 2 + README.md | 76 +++++++++++- codex_reaction.py | 220 ++++++++++++++++++++++++++++++++++ main.py | 266 +++++++++++++++++++++++++++++++++++++++-- test_codex_reaction.py | 124 +++++++++++++++++++ test_main.py | 137 +++++++++++++++++++++ 7 files changed, 815 insertions(+), 13 deletions(-) create mode 100644 codex_reaction.py create mode 100644 test_codex_reaction.py diff --git a/.env.example b/.env.example index 3dc48fe..cc0c140 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ WEBHOOK_SECRET=your_webhook_secret_here +WEBHOOK_TRIGGER_COMMAND=python codex_reaction.py --workspace /path/to/workspace +WEBHOOK_TRIGGER_CWD=/path/to/github-webhook-mcp +CODEX_REACTION_RESUME_SESSION=thread-or-session-id diff --git a/.gitignore b/.gitignore index d31d2ac..5e3be6e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .env events.json +trigger-events/ +codex-runs/ __pycache__/ *.pyc diff --git a/README.md b/README.md index 0c33bb7..d8b1dd8 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,18 @@ GitHub webhook receiver as an MCP server. Receives GitHub webhook events and enables Lin and Lay to autonomously handle PR reviews and issue management. +It can either expose events to MCP for polling or trigger Codex immediately when a webhook arrives. ## Architecture ``` GitHub → Cloudflare Tunnel → webhook server (FastAPI :8080) ↓ events.json - MCP server (stdio) ← Claude (Lin/Lay) + ┌──────────────┴──────────────┐ + ↓ ↓ + MCP server (stdio) direct trigger queue + ↓ ↓ + Codex / Claude codex exec (one-by-one) ``` Recommended polling flow: @@ -33,6 +38,67 @@ pip install -r requirements.txt WEBHOOK_SECRET=your_secret python main.py webhook --port 8080 --event-profile notifications ``` +### 2b. Start webhook receiver with direct Codex reactions + +`main.py webhook` accepts `--trigger-command`, which runs once per stored event. +When a service manager already splits arguments for you, put `--trigger-command` last and pass the command tokens after it without wrapping the whole trigger in quotes. +The command receives the full event JSON on stdin and also gets these environment variables: + +- `GITHUB_WEBHOOK_EVENT_ID` +- `GITHUB_WEBHOOK_EVENT_TYPE` +- `GITHUB_WEBHOOK_EVENT_ACTION` +- `GITHUB_WEBHOOK_EVENT_REPO` +- `GITHUB_WEBHOOK_EVENT_SENDER` +- `GITHUB_WEBHOOK_EVENT_NUMBER` +- `GITHUB_WEBHOOK_EVENT_TITLE` +- `GITHUB_WEBHOOK_EVENT_URL` +- `GITHUB_WEBHOOK_EVENT_PATH` +- `GITHUB_WEBHOOK_RECEIVED_AT` + +The webhook server serializes trigger execution, so only one direct reaction runs at a time. +Successful runs are marked processed automatically. Failed runs stay pending. +If the trigger command intentionally defers handling, it can exit with code `86`. +That is recorded as `trigger_status=skipped` and the event stays pending for foreground polling. + +Use the bundled Codex wrapper if you want the webhook to launch `codex exec` immediately: + +```bash +python main.py webhook \ + --port 8080 \ + --event-profile notifications \ + --trigger-command "python codex_reaction.py --workspace /path/to/workspace --output-dir /path/to/github-webhook-mcp/codex-runs" +``` + +Service-manager style is also supported: + +```text +python main.py webhook --port 8080 --event-profile notifications --trigger-command python codex_reaction.py --workspace /path/to/workspace --output-dir /path/to/github-webhook-mcp/codex-runs +``` + +On Windows PowerShell the same idea looks like this: + +```powershell +py -3 .\main.py webhook ` + --port 8080 ` + --event-profile notifications ` + --trigger-command "py -3 C:\path\to\github-webhook-mcp\codex_reaction.py --workspace C:\path\to\workspace --output-dir C:\path\to\github-webhook-mcp\codex-runs" +``` + +`codex_reaction.py` builds a short prompt, points Codex at the saved event JSON file, and runs: + +```text +codex -a never -s workspace-write exec -C ... +``` + +If you want the result to appear in an existing Codex app thread instead of a markdown file, switch the wrapper to resume mode: + +```text +python codex_reaction.py --workspace /path/to/workspace --resume-session +``` + +If you want webhook delivery to stay notification-only for a workspace, create a `.codex-webhook-notify-only` +file in that workspace. The bundled wrapper will skip direct Codex execution and leave the event pending. + ### 3. Set up Cloudflare Tunnel ```bash @@ -87,6 +153,8 @@ Add to your Claude MCP config: | `mark_processed` | Mark an event as processed | `get_webhook_events` is still available, but it returns raw webhook payloads and is much heavier than the status → summary → detail flow above. +When direct trigger mode is enabled, the saved event metadata also records `trigger_status` and `last_triggered_at`. +Possible statuses are `succeeded`, `failed`, and `skipped`. ## Event Profiles @@ -97,6 +165,12 @@ The webhook receiver supports two profiles: Use `notifications` for low-noise polling. +## Files + +- `main.py`: webhook receiver + MCP server + direct trigger queue +- `codex_reaction.py`: helper wrapper that launches `codex exec` per event +- `trigger-events/.json`: saved payload passed to direct trigger commands + ## Related - [Liplus-Project/liplus-language](https://github.com/Liplus-Project/liplus-language) — Li+ language specification diff --git a/codex_reaction.py b/codex_reaction.py new file mode 100644 index 0000000..a544c26 --- /dev/null +++ b/codex_reaction.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +codex_reaction.py - run Codex immediately for a GitHub webhook event + +The webhook server passes the full event JSON on stdin and also exposes +GITHUB_WEBHOOK_* environment variables, including GITHUB_WEBHOOK_EVENT_PATH. +""" +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +try: + from dotenv import load_dotenv +except ModuleNotFoundError: + def load_dotenv() -> bool: + return False + +load_dotenv() + +NOTIFY_ONLY_MARKER = ".codex-webhook-notify-only" +NOTIFY_ONLY_EXIT_CODE = 86 + + +def load_event(raw_text: str | None = None, event_path: str | None = None) -> dict[str, Any]: + source_text = raw_text + if source_text is None: + path = event_path or os.environ.get("GITHUB_WEBHOOK_EVENT_PATH", "") + if path: + source_text = Path(path).read_text(encoding="utf-8") + else: + source_text = sys.stdin.read() + if not source_text.strip(): + raise ValueError("No webhook event payload was provided") + return json.loads(source_text) + + +def build_prompt( + event: dict[str, Any], + *, + workspace: str, + event_path: str | None, + extra_instructions: str = "", +) -> str: + payload = event.get("payload", {}) + repo = (payload.get("repository") or {}).get("full_name", "") + sender = (payload.get("sender") or {}).get("login", "") + issue = payload.get("issue") or {} + pull_request = payload.get("pull_request") or {} + discussion = payload.get("discussion") or {} + number = payload.get("number") or issue.get("number") or pull_request.get("number") + title = ( + issue.get("title") + or pull_request.get("title") + or discussion.get("title") + or (payload.get("check_run") or {}).get("name") + or (payload.get("workflow_run") or {}).get("name") + or "" + ) + url = ( + issue.get("html_url") + or pull_request.get("html_url") + or discussion.get("html_url") + or (payload.get("check_run") or {}).get("html_url") + or (payload.get("workflow_run") or {}).get("html_url") + or "" + ) + lines = [ + "A GitHub webhook event has just arrived.", + "", + f"Workspace: {workspace}", + f"Event JSON path: {event_path or '(stdin only)'}", + "Summary:", + f"- id: {event.get('id', '')}", + f"- type: {event.get('type', '')}", + f"- action: {payload.get('action', '')}", + f"- repo: {repo}", + f"- sender: {sender}", + f"- number: {number or ''}", + f"- title: {title}", + f"- url: {url}", + "", + "Instructions:", + "- Read AGENTS.md in the workspace and follow it.", + "- Read the webhook event JSON file for full context before acting.", + "- React directly to this event in the workspace.", + "- If no action is needed, explain briefly why and stop.", + "- Do not wait for another poll cycle.", + ] + if extra_instructions.strip(): + lines.extend(["", "Additional instructions:", extra_instructions.strip()]) + return "\n".join(lines) + + +def build_codex_command( + *, + codex_bin: str, + workspace: str, + prompt: str, + output_file: Path | None, + sandbox: str, + approval: str, + skip_git_repo_check: bool, +) -> list[str]: + cmd = [codex_bin, "-a", approval, "-s", sandbox, "exec", "-C", workspace] + if skip_git_repo_check: + cmd.append("--skip-git-repo-check") + if output_file is not None: + cmd.extend(["-o", str(output_file)]) + cmd.append(prompt) + return cmd + + +def build_codex_resume_command( + *, + codex_bin: str, + session_id: str, + prompt: str, + output_file: Path | None, + skip_git_repo_check: bool, +) -> list[str]: + cmd = [codex_bin, "exec", "resume", session_id] + if skip_git_repo_check: + cmd.append("--skip-git-repo-check") + if output_file is not None: + cmd.extend(["-o", str(output_file)]) + cmd.append(prompt) + return cmd + + +def notify_only_enabled(workspace: str) -> bool: + return (Path(workspace) / NOTIFY_ONLY_MARKER).exists() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run Codex for a webhook event") + parser.add_argument("--workspace", required=True, help="Workspace passed to codex exec -C") + parser.add_argument("--codex-bin", default=os.environ.get("CODEX_BIN", "codex")) + parser.add_argument( + "--codex-home", + default=os.environ.get("CODEX_HOME", ""), + help="Optional CODEX_HOME passed to codex exec.", + ) + parser.add_argument( + "--resume-session", + default=os.environ.get("CODEX_REACTION_RESUME_SESSION", ""), + help="Optional Codex thread/session id to target with `codex exec resume`.", + ) + parser.add_argument("--sandbox", default=os.environ.get("CODEX_SANDBOX", "workspace-write")) + parser.add_argument("--approval", default=os.environ.get("CODEX_APPROVAL", "never")) + parser.add_argument( + "--output-dir", + default=os.environ.get("CODEX_REACTION_OUTPUT_DIR", ""), + help="Optional directory for codex exec output files.", + ) + parser.add_argument( + "--extra-instructions", + default=os.environ.get("CODEX_REACTION_EXTRA_INSTRUCTIONS", ""), + help="Extra instructions appended to the generated Codex prompt.", + ) + parser.add_argument( + "--skip-git-repo-check", + action="store_true", + help="Forward --skip-git-repo-check to codex exec.", + ) + args = parser.parse_args() + + event_path = os.environ.get("GITHUB_WEBHOOK_EVENT_PATH", "") + event = load_event(event_path=event_path) + + if notify_only_enabled(args.workspace): + print( + f"notify-only mode active via {Path(args.workspace) / NOTIFY_ONLY_MARKER}; " + "leaving webhook event pending", + file=sys.stderr, + ) + return NOTIFY_ONLY_EXIT_CODE + + output_file: Path | None = None + if args.output_dir: + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + output_file = output_dir / f"{event.get('id', 'webhook-event')}.md" + + prompt = build_prompt( + event, + workspace=args.workspace, + event_path=event_path or None, + extra_instructions=args.extra_instructions, + ) + env = os.environ.copy() + if args.codex_home: + env["CODEX_HOME"] = args.codex_home + if args.resume_session: + cmd = build_codex_resume_command( + codex_bin=args.codex_bin, + session_id=args.resume_session, + prompt=prompt, + output_file=output_file, + skip_git_repo_check=args.skip_git_repo_check, + ) + else: + cmd = build_codex_command( + codex_bin=args.codex_bin, + workspace=args.workspace, + prompt=prompt, + output_file=output_file, + sandbox=args.sandbox, + approval=args.approval, + skip_git_repo_check=args.skip_git_repo_check, + ) + completed = subprocess.run(cmd, check=False, env=env) + return completed.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/main.py b/main.py index b4a1421..bde838f 100644 --- a/main.py +++ b/main.py @@ -12,18 +12,27 @@ import hmac import json import os +import shlex +import sys import uuid from collections import Counter +from contextlib import asynccontextmanager -from dotenv import load_dotenv +try: + from dotenv import load_dotenv +except ModuleNotFoundError: + def load_dotenv() -> bool: + return False load_dotenv() from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, Awaitable, Callable DATA_FILE = Path(__file__).parent / "events.json" +TRIGGER_EVENTS_DIR = Path(__file__).parent / "trigger-events" PRIMARY_ENCODING = "utf-8" LEGACY_ENCODINGS = ("utf-8-sig", "cp932", "shift_jis") +NOTIFY_ONLY_EXIT_CODE = 86 NOTIFICATION_EVENT_ACTIONS = { "issues": {"assigned", "closed", "opened", "reopened", "unassigned"}, "issue_comment": {"created"}, @@ -87,6 +96,15 @@ def add_event(event_type: str, payload: dict) -> dict: def get_pending() -> list[dict]: return [e for e in _load() if not e["processed"]] +def update_event(event_id: str, **updates: Any) -> bool: + events = _load() + for event in events: + if event["id"] == event_id: + event.update(updates) + _save(events) + return True + return False + def _normalize_event_profile(profile: str) -> str: normalized = (profile or "all").strip().lower() if normalized not in {"all", "notifications"}: @@ -136,6 +154,8 @@ def summarize_event(event: dict) -> dict: "type": event["type"], "received_at": event["received_at"], "processed": event["processed"], + "trigger_status": event.get("trigger_status"), + "last_triggered_at": event.get("last_triggered_at"), "action": payload.get("action"), "repo": (payload.get("repository") or {}).get("full_name"), "sender": (payload.get("sender") or {}).get("login"), @@ -165,23 +185,213 @@ def get_event(event_id: str) -> dict | None: return None def mark_done(event_id: str) -> bool: - events = _load() - for e in events: - if e["id"] == event_id: - e["processed"] = True - _save(events) - return True - return False + return update_event(event_id, processed=True) + + +# ── Direct Trigger Execution ─────────────────────────────────────────────────── + +def parse_trigger_command(command: str) -> list[str]: + raw = (command or "").strip() + if not raw: + return [] + windows_style = os.name == "nt" or ":\\" in raw or raw.startswith("\\\\") + return shlex.split(raw, posix=not windows_style) + +def resolve_trigger_command( + env_command: str, + cli_tokens: list[str] | None, +) -> list[str]: + if not cli_tokens: + return parse_trigger_command(env_command) + if len(cli_tokens) == 1: + return parse_trigger_command(cli_tokens[0]) + return cli_tokens + +def persist_trigger_event(event: dict) -> Path: + TRIGGER_EVENTS_DIR.mkdir(parents=True, exist_ok=True) + event_path = TRIGGER_EVENTS_DIR / f"{event['id']}.json" + event_path.write_text( + json.dumps(event, ensure_ascii=False, indent=2), + encoding=PRIMARY_ENCODING, + ) + return event_path + +def _stringify_env(value: Any) -> str: + if value is None: + return "" + return str(value) + +def build_trigger_env(event: dict, event_path: Path) -> dict[str, str]: + payload = event.get("payload", {}) + env = os.environ.copy() + env.update( + { + "GITHUB_WEBHOOK_EVENT_ID": event["id"], + "GITHUB_WEBHOOK_EVENT_TYPE": event["type"], + "GITHUB_WEBHOOK_EVENT_ACTION": _stringify_env(payload.get("action")), + "GITHUB_WEBHOOK_EVENT_REPO": _stringify_env( + (payload.get("repository") or {}).get("full_name") + ), + "GITHUB_WEBHOOK_EVENT_SENDER": _stringify_env( + (payload.get("sender") or {}).get("login") + ), + "GITHUB_WEBHOOK_EVENT_NUMBER": _stringify_env(_event_number(payload)), + "GITHUB_WEBHOOK_EVENT_TITLE": _stringify_env(_event_title(payload)), + "GITHUB_WEBHOOK_EVENT_URL": _stringify_env(_event_url(payload)), + "GITHUB_WEBHOOK_EVENT_PATH": str(event_path), + "GITHUB_WEBHOOK_RECEIVED_AT": event["received_at"], + } + ) + return env + +def _summarize_process_output(stdout: bytes, stderr: bytes) -> str: + parts: list[str] = [] + if stdout: + parts.append(f"stdout={stdout.decode(PRIMARY_ENCODING, errors='replace').strip()[:400]}") + if stderr: + parts.append(f"stderr={stderr.decode(PRIMARY_ENCODING, errors='replace').strip()[:400]}") + return " ".join(part for part in parts if part).strip() + +async def run_trigger_command( + command: list[str], + event: dict, + cwd: Path | None = None, +) -> None: + if not command: + return + event_path = persist_trigger_event(event) + proc = await asyncio.create_subprocess_exec( + *command, + cwd=str(cwd) if cwd else None, + env=build_trigger_env(event, event_path), + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + payload_bytes = json.dumps(event, ensure_ascii=False, indent=2).encode(PRIMARY_ENCODING) + stdout, stderr = await proc.communicate(payload_bytes) + if proc.returncode != 0: + if proc.returncode == NOTIFY_ONLY_EXIT_CODE: + raise TriggerSkipped("trigger command requested notify-only fallback") + details = _summarize_process_output(stdout, stderr) + raise RuntimeError( + f"trigger command failed with exit code {proc.returncode}" + + (f" ({details})" if details else "") + ) + + +TriggerRunner = Callable[[list[str], dict, Path | None], Awaitable[None]] + + +class TriggerSkipped(Exception): + """A trigger command chose not to handle the event directly.""" + + +class TriggerDispatcher: + def __init__( + self, + command: list[str], + *, + cwd: Path | None = None, + mark_processed_on_success: bool = True, + runner: TriggerRunner = run_trigger_command, + ) -> None: + self.command = command + self.cwd = cwd + self.mark_processed_on_success = mark_processed_on_success + self.runner = runner + self._queue: asyncio.Queue[dict | None] = asyncio.Queue() + self._worker_task: asyncio.Task[None] | None = None + + @property + def enabled(self) -> bool: + return bool(self.command) + + async def start(self) -> None: + if self.enabled and self._worker_task is None: + self._worker_task = asyncio.create_task(self._worker()) + + async def stop(self) -> None: + if self._worker_task is None: + return + await self._queue.put(None) + await self._worker_task + self._worker_task = None + + async def enqueue(self, event: dict) -> None: + if not self.enabled: + return + await self._queue.put(event) + + async def _worker(self) -> None: + while True: + event = await self._queue.get() + if event is None: + self._queue.task_done() + return + try: + await self.runner(self.command, event, self.cwd) + except TriggerSkipped as exc: + update_event( + event["id"], + trigger_status="skipped", + trigger_error=str(exc), + last_triggered_at=datetime.now(timezone.utc).isoformat(), + ) + except Exception as exc: + update_event( + event["id"], + trigger_status="failed", + trigger_error=str(exc), + last_triggered_at=datetime.now(timezone.utc).isoformat(), + ) + print( + f"[github-webhook-mcp] trigger failed for {event['id']}: {exc}", + file=sys.stderr, + ) + else: + updates: dict[str, Any] = { + "trigger_status": "succeeded", + "trigger_error": "", + "last_triggered_at": datetime.now(timezone.utc).isoformat(), + } + if self.mark_processed_on_success: + updates["processed"] = True + update_event(event["id"], **updates) + finally: + self._queue.task_done() # ── Webhook Server (FastAPI) ────────────────────────────────────────────────── -def run_webhook(port: int, secret: str, event_profile: str) -> None: +def run_webhook( + port: int, + secret: str, + event_profile: str, + *, + trigger_command: list[str] | None = None, + trigger_cwd: Path | None = None, + mark_processed_on_trigger_success: bool = True, +) -> None: from fastapi import FastAPI, Header, HTTPException, Request import uvicorn - app = FastAPI(title="github-webhook-mcp") normalized_profile = _normalize_event_profile(event_profile) + dispatcher = TriggerDispatcher( + trigger_command or [], + cwd=trigger_cwd, + mark_processed_on_success=mark_processed_on_trigger_success, + ) + + @asynccontextmanager + async def lifespan(_: Any): + await dispatcher.start() + try: + yield + finally: + await dispatcher.stop() + + app = FastAPI(title="github-webhook-mcp", lifespan=lifespan) def _verify(body: bytes, sig: str) -> bool: if not secret: @@ -208,6 +418,7 @@ async def webhook( if not should_store_event(x_github_event, payload, normalized_profile): return {"ignored": True, "type": x_github_event, "profile": normalized_profile} event = add_event(x_github_event, payload) + await dispatcher.enqueue(event) return {"id": event["id"], "type": x_github_event} uvicorn.run(app, host="0.0.0.0", port=port) @@ -371,12 +582,43 @@ async def call_tool( default=os.environ.get("WEBHOOK_EVENT_PROFILE", "all"), choices=["all", "notifications"], ) + wp.add_argument( + "--trigger-command", + nargs=argparse.REMAINDER, + help=( + "Optional command to run for each stored event. " + "When provided on the CLI, put it last and pass the command tokens " + "after --trigger-command. " + "The event JSON is sent to stdin and metadata is provided via " + "GITHUB_WEBHOOK_* environment variables." + ), + ) + wp.add_argument( + "--trigger-cwd", + default=os.environ.get("WEBHOOK_TRIGGER_CWD", ""), + help="Optional working directory for the trigger command.", + ) + wp.add_argument( + "--keep-pending-on-trigger-success", + action="store_true", + help="Leave events pending even when the trigger command exits successfully.", + ) sub.add_parser("mcp", help="Start MCP server (stdio transport)") args = parser.parse_args() if args.mode == "webhook": - run_webhook(port=args.port, secret=args.secret, event_profile=args.event_profile) + run_webhook( + port=args.port, + secret=args.secret, + event_profile=args.event_profile, + trigger_command=resolve_trigger_command( + os.environ.get("WEBHOOK_TRIGGER_COMMAND", ""), + args.trigger_command, + ), + trigger_cwd=Path(args.trigger_cwd).expanduser() if args.trigger_cwd else None, + mark_processed_on_trigger_success=not args.keep_pending_on_trigger_success, + ) elif args.mode == "mcp": asyncio.run(run_mcp()) diff --git a/test_codex_reaction.py b/test_codex_reaction.py new file mode 100644 index 0000000..9907eac --- /dev/null +++ b/test_codex_reaction.py @@ -0,0 +1,124 @@ +import json +import tempfile +import unittest +from pathlib import Path + +import codex_reaction + + +class CodexReactionTests(unittest.TestCase): + def test_load_event_reads_file(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + event_path = Path(temp_dir) / "event.json" + event_path.write_text(json.dumps({"id": "evt-1", "payload": {}}), encoding="utf-8") + + event = codex_reaction.load_event(event_path=str(event_path)) + + self.assertEqual(event["id"], "evt-1") + + def test_build_prompt_includes_summary_and_path(self) -> None: + event = { + "id": "evt-2", + "type": "pull_request", + "payload": { + "action": "opened", + "repository": {"full_name": "owner/repo"}, + "sender": {"login": "smile"}, + "pull_request": { + "number": 42, + "title": "Add direct trigger", + "html_url": "https://example.invalid/pull/42", + }, + }, + } + + prompt = codex_reaction.build_prompt( + event, + workspace="/workspace", + event_path="/tmp/event.json", + extra_instructions="Reply in Japanese.", + ) + + self.assertIn("Workspace: /workspace", prompt) + self.assertIn("Event JSON path: /tmp/event.json", prompt) + self.assertIn("- type: pull_request", prompt) + self.assertIn("- number: 42", prompt) + self.assertIn("Reply in Japanese.", prompt) + + def test_build_codex_command_orders_root_flags_before_exec(self) -> None: + cmd = codex_reaction.build_codex_command( + codex_bin="codex", + workspace="/workspace", + prompt="Handle the event.", + output_file=Path("/tmp/output.md"), + sandbox="workspace-write", + approval="never", + skip_git_repo_check=True, + ) + + self.assertEqual( + cmd, + [ + "codex", + "-a", + "never", + "-s", + "workspace-write", + "exec", + "-C", + "/workspace", + "--skip-git-repo-check", + "-o", + "/tmp/output.md", + "Handle the event.", + ], + ) + + def test_build_codex_command_uses_supplied_binary(self) -> None: + cmd = codex_reaction.build_codex_command( + codex_bin="C:/tools/codex.exe", + workspace="/workspace", + prompt="Handle the event.", + output_file=None, + sandbox="workspace-write", + approval="never", + skip_git_repo_check=False, + ) + + self.assertEqual(cmd[0], "C:/tools/codex.exe") + + def test_build_codex_resume_command_targets_session(self) -> None: + cmd = codex_reaction.build_codex_resume_command( + codex_bin="codex", + session_id="019cef1e-fb9d-7ae0-998c-2d66971f55c0", + prompt="Handle the event in-app.", + output_file=Path("/tmp/output.md"), + skip_git_repo_check=True, + ) + + self.assertEqual( + cmd, + [ + "codex", + "exec", + "resume", + "019cef1e-fb9d-7ae0-998c-2d66971f55c0", + "--skip-git-repo-check", + "-o", + "/tmp/output.md", + "Handle the event in-app.", + ], + ) + + def test_notify_only_enabled_checks_workspace_marker(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + self.assertFalse(codex_reaction.notify_only_enabled(str(workspace))) + + (workspace / codex_reaction.NOTIFY_ONLY_MARKER).write_text("", encoding="utf-8") + + self.assertTrue(codex_reaction.notify_only_enabled(str(workspace))) + + +if __name__ == "__main__": + unittest.main() diff --git a/test_main.py b/test_main.py index e481b14..be0fafa 100644 --- a/test_main.py +++ b/test_main.py @@ -1,3 +1,4 @@ +import asyncio import json import tempfile import unittest @@ -140,5 +141,141 @@ def test_all_profile_keeps_everything(self) -> None: ) +class TriggerCommandParsingTests(unittest.TestCase): + def test_resolve_trigger_command_reads_env_string(self) -> None: + command = main.resolve_trigger_command( + 'python codex_reaction.py --workspace C:\\Users\\smile\\Codex', + None, + ) + + self.assertEqual( + command, + ["python", "codex_reaction.py", "--workspace", "C:\\Users\\smile\\Codex"], + ) + + def test_resolve_trigger_command_accepts_cli_remainder_tokens(self) -> None: + command = main.resolve_trigger_command( + "", + [ + "C:\\Python312\\python.exe", + "C:\\Users\\smile\\github-webhook-mcp\\codex_reaction.py", + "--workspace", + "C:\\Users\\smile\\Codex", + ], + ) + + self.assertEqual( + command, + [ + "C:\\Python312\\python.exe", + "C:\\Users\\smile\\github-webhook-mcp\\codex_reaction.py", + "--workspace", + "C:\\Users\\smile\\Codex", + ], + ) + + +class TriggerExecutionTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.original_data_file = main.DATA_FILE + self.original_trigger_dir = main.TRIGGER_EVENTS_DIR + main.DATA_FILE = Path(self.temp_dir.name) / "events.json" + main.TRIGGER_EVENTS_DIR = Path(self.temp_dir.name) / "trigger-events" + + async def asyncTearDown(self) -> None: + main.DATA_FILE = self.original_data_file + main.TRIGGER_EVENTS_DIR = self.original_trigger_dir + self.temp_dir.cleanup() + + async def test_dispatcher_marks_successful_events_processed(self) -> None: + calls: list[str] = [] + + async def runner(command: list[str], event: dict, cwd: Path | None) -> None: + self.assertEqual(command, ["codex", "exec"]) + self.assertIsNone(cwd) + calls.append(event["id"]) + + event_a = main.add_event("issues", {"action": "opened"}) + event_b = main.add_event("issues", {"action": "reopened"}) + dispatcher = main.TriggerDispatcher(["codex", "exec"], runner=runner) + + await dispatcher.start() + await dispatcher.enqueue(event_a) + await dispatcher.enqueue(event_b) + await dispatcher.stop() + + self.assertEqual(calls, [event_a["id"], event_b["id"]]) + stored_a = main.get_event(event_a["id"]) + stored_b = main.get_event(event_b["id"]) + self.assertTrue(stored_a["processed"]) + self.assertTrue(stored_b["processed"]) + self.assertEqual(stored_a["trigger_status"], "succeeded") + self.assertEqual(stored_b["trigger_status"], "succeeded") + + async def test_dispatcher_keeps_failed_events_pending(self) -> None: + async def runner(command: list[str], event: dict, cwd: Path | None) -> None: + raise RuntimeError("boom") + + event = main.add_event("issues", {"action": "opened"}) + dispatcher = main.TriggerDispatcher(["codex", "exec"], runner=runner) + + await dispatcher.start() + await dispatcher.enqueue(event) + await dispatcher.stop() + + stored = main.get_event(event["id"]) + self.assertFalse(stored["processed"]) + self.assertEqual(stored["trigger_status"], "failed") + self.assertEqual(stored["trigger_error"], "boom") + + async def test_dispatcher_records_notify_only_fallback_as_skipped(self) -> None: + async def runner(command: list[str], event: dict, cwd: Path | None) -> None: + raise main.TriggerSkipped("notify-only fallback") + + event = main.add_event("issues", {"action": "opened"}) + dispatcher = main.TriggerDispatcher(["codex", "exec"], runner=runner) + + await dispatcher.start() + await dispatcher.enqueue(event) + await dispatcher.stop() + + stored = main.get_event(event["id"]) + self.assertFalse(stored["processed"]) + self.assertEqual(stored["trigger_status"], "skipped") + self.assertEqual(stored["trigger_error"], "notify-only fallback") + + async def test_run_trigger_command_writes_event_file(self) -> None: + captured: dict[str, str] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + class FakeProcess: + returncode = 0 + + async def communicate(self, payload_bytes: bytes): + captured["stdin"] = payload_bytes.decode("utf-8") + captured["event_path"] = kwargs["env"]["GITHUB_WEBHOOK_EVENT_PATH"] + captured["event_type"] = kwargs["env"]["GITHUB_WEBHOOK_EVENT_TYPE"] + return b"", b"" + + captured["command"] = " ".join(cmd) + return FakeProcess() + + event = main.add_event("issues", {"action": "opened"}) + original = asyncio.create_subprocess_exec + asyncio.create_subprocess_exec = fake_create_subprocess_exec + try: + await main.run_trigger_command(["codex", "exec"], event) + finally: + asyncio.create_subprocess_exec = original + + self.assertEqual(captured["command"], "codex exec") + self.assertEqual(captured["event_type"], "issues") + self.assertIn(event["id"], captured["stdin"]) + event_path = Path(captured["event_path"]) + self.assertTrue(event_path.exists()) + self.assertEqual(json.loads(event_path.read_text(encoding="utf-8"))["id"], event["id"]) + + if __name__ == "__main__": unittest.main()