From 22a891a32db28978d55e21860b5a2208371583f7 Mon Sep 17 00:00:00 2001 From: Lin & Lay Date: Wed, 25 Mar 2026 20:24:07 +0900 Subject: [PATCH] Remove legacy Python receiver and update CI to Node.js (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧 Python receiver(main.py, codex_reaction.py, requirements.txt)、 テスト、Cloudflare Tunnel 設定、.env.example を削除。 CI を Python unittest から Node.js syntax check + TypeScript type check に切り替え。 refs #58 Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 4 - .github/workflows/ci.yml | 10 +- cloudflared/config.yml.example | 19 - codex_reaction.py | 220 ----------- main.py | 667 --------------------------------- requirements.txt | 4 - test_codex_reaction.py | 124 ------ test_main.py | 281 -------------- 8 files changed, 5 insertions(+), 1324 deletions(-) delete mode 100644 .env.example delete mode 100644 cloudflared/config.yml.example delete mode 100644 codex_reaction.py delete mode 100644 main.py delete mode 100644 requirements.txt delete mode 100644 test_codex_reaction.py delete mode 100644 test_main.py diff --git a/.env.example b/.env.example deleted file mode 100644 index cc0c140..0000000 --- a/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 782c962..65c7d12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,9 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - python-version: "3.12" - - run: pip install -r requirements.txt - - run: python -m unittest discover -v + node-version: "22" + - run: node --check mcp-server/server/index.js + - run: cd worker && npm ci && npx tsc --noEmit diff --git a/cloudflared/config.yml.example b/cloudflared/config.yml.example deleted file mode 100644 index 45353cc..0000000 --- a/cloudflared/config.yml.example +++ /dev/null @@ -1,19 +0,0 @@ -# Cloudflare Tunnel config example -# Copy to ~/.cloudflared/config.yml and fill in your values. -# -# Setup steps: -# 1. cloudflared tunnel login -# 2. cloudflared tunnel create github-webhook-mcp -# 3. Copy the tunnel ID below -# 4. Add a CNAME record in Cloudflare DNS: -# Name: webhook.yourdomain.com -# Target: .cfargotunnel.com -# 5. cloudflared tunnel run (or: cloudflared service install) - -tunnel: -credentials-file: /path/to/.cloudflared/.json - -ingress: - - hostname: webhook.yourdomain.com - service: http://localhost:8080 - - service: http_status:404 diff --git a/codex_reaction.py b/codex_reaction.py deleted file mode 100644 index a544c26..0000000 --- a/codex_reaction.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/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 deleted file mode 100644 index b206a0b..0000000 --- a/main.py +++ /dev/null @@ -1,667 +0,0 @@ -#!/usr/bin/env python3 -""" -github-webhook-mcp — GitHub webhook receiver + MCP server - -Usage: - python main.py webhook [--port 8080] [--secret $WEBHOOK_SECRET] [--event-profile all|notifications] - python main.py mcp -""" -import argparse -import asyncio -import hashlib -import hmac -import json -import os -import shlex -import sys -import uuid -from collections import Counter -from contextlib import asynccontextmanager - -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, 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 -DEFAULT_PURGE_DAYS = 1 -NOTIFICATION_EVENT_ACTIONS = { - "issues": {"assigned", "closed", "opened", "reopened", "unassigned"}, - "issue_comment": {"created"}, - "pull_request": { - "assigned", - "closed", - "converted_to_draft", - "opened", - "ready_for_review", - "reopened", - "review_requested", - "review_request_removed", - "synchronize", - "unassigned", - }, - "pull_request_review": {"dismissed", "submitted"}, - "pull_request_review_comment": {"created"}, - "check_run": {"completed"}, - "workflow_run": {"completed"}, - "discussion": {"answered", "closed", "created", "reopened"}, - "discussion_comment": {"created"}, -} - - -# ── Event Store ─────────────────────────────────────────────────────────────── - -def _load() -> list[dict]: - if DATA_FILE.exists(): - raw = DATA_FILE.read_bytes() - for encoding in (PRIMARY_ENCODING, *LEGACY_ENCODINGS): - try: - text = raw.decode(encoding) - events = json.loads(text) - if encoding != PRIMARY_ENCODING: - _save(events) - return events - except (UnicodeDecodeError, json.JSONDecodeError): - continue - raise ValueError(f"Unable to decode event store: {DATA_FILE}") - return [] - -def _save(events: list[dict]) -> None: - DATA_FILE.write_text( - json.dumps(events, ensure_ascii=False, indent=2), - encoding=PRIMARY_ENCODING, - ) - -def add_event(event_type: str, payload: dict) -> dict: - events = _load() - event = { - "id": str(uuid.uuid4()), - "type": event_type, - "payload": payload, - "received_at": datetime.now(timezone.utc).isoformat(), - "processed": False, - } - events.append(event) - _save(events) - return event - -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 _purge_days() -> int: - env = os.environ.get("PURGE_AFTER_DAYS") - if env is not None: - try: - n = int(env) - if n >= 0: - return n - except ValueError: - pass - return DEFAULT_PURGE_DAYS - -def purge_processed(events: list[dict]) -> tuple[list[dict], int]: - days = _purge_days() - if days < 0: - return events, 0 - cutoff = datetime.now(timezone.utc).timestamp() - days * 86400 - before = len(events) - kept = [] - for e in events: - if not e.get("processed"): - kept.append(e) - continue - try: - ts = datetime.fromisoformat(e["received_at"]).timestamp() - except (KeyError, ValueError): - kept.append(e) - continue - if ts > cutoff: - kept.append(e) - return kept, before - len(kept) - -def _normalize_event_profile(profile: str) -> str: - normalized = (profile or "all").strip().lower() - if normalized not in {"all", "notifications"}: - raise ValueError(f"Unknown event profile: {profile}") - return normalized - -def should_store_event(event_type: str, payload: dict, profile: str) -> bool: - normalized = _normalize_event_profile(profile) - if normalized == "all": - return True - allowed_actions = NOTIFICATION_EVENT_ACTIONS.get(event_type) - if not allowed_actions: - return False - action = payload.get("action") - return action in allowed_actions - -def _event_number(payload: dict) -> int | str | None: - return ( - payload.get("number") - or (payload.get("issue") or {}).get("number") - or (payload.get("pull_request") or {}).get("number") - ) - -def _event_title(payload: dict) -> str | None: - return ( - (payload.get("issue") or {}).get("title") - or (payload.get("pull_request") or {}).get("title") - or (payload.get("discussion") or {}).get("title") - or (payload.get("check_run") or {}).get("name") - or (payload.get("workflow_run") or {}).get("name") - or (payload.get("workflow_job") or {}).get("name") - ) - -def _event_url(payload: dict) -> str | None: - return ( - (payload.get("issue") or {}).get("html_url") - or (payload.get("pull_request") or {}).get("html_url") - or (payload.get("discussion") or {}).get("html_url") - or (payload.get("check_run") or {}).get("html_url") - or (payload.get("workflow_run") or {}).get("html_url") - ) - -def summarize_event(event: dict) -> dict: - payload = event.get("payload", {}) - return { - "id": event["id"], - "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"), - "number": _event_number(payload), - "title": _event_title(payload), - "url": _event_url(payload), - } - -def get_pending_status() -> dict: - pending = get_pending() - return { - "pending_count": len(pending), - "latest_received_at": pending[-1]["received_at"] if pending else None, - "types": dict(Counter(event["type"] for event in pending)), - } - -def get_pending_summaries(limit: int = 20) -> list[dict]: - pending = get_pending() - if limit > 0: - pending = pending[-limit:] - return [summarize_event(event) for event in pending] - -def get_event(event_id: str) -> dict | None: - for event in _load(): - if event["id"] == event_id: - return event - return None - -def mark_done(event_id: str) -> dict: - events = _load() - found = False - for event in events: - if event["id"] == event_id: - event["processed"] = True - found = True - break - if not found: - return {"success": False, "purged": 0} - kept, purged = purge_processed(events) - _save(kept) - return {"success": True, "purged": purged} - - -# ── 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, - *, - 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 - - 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: - return True - if not sig.startswith("sha256="): - return False - expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() - return hmac.compare_digest(f"sha256={expected}", sig) - - @app.get("/health") - async def health(): - return {"status": "ok"} - - @app.post("/webhook") - async def webhook( - request: Request, - x_github_event: str = Header(default=""), - x_hub_signature_256: str = Header(default=""), - ): - body = await request.body() - if not _verify(body, x_hub_signature_256): - raise HTTPException(status_code=401, detail="Invalid signature") - payload = json.loads(body) - 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) - - -# ── MCP Server (stdio) ──────────────────────────────────────────────────────── - -async def run_mcp() -> None: - from mcp.server import Server - from mcp.server.stdio import stdio_server - import mcp.types as types - - server = Server("github-webhook-mcp") - - @server.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name="get_pending_status", - description=( - "Get a lightweight snapshot of pending GitHub webhook events. " - "Use this for periodic polling before requesting details." - ), - inputSchema={"type": "object", "properties": {}, "required": []}, - ), - types.Tool( - name="list_pending_events", - description=( - "List lightweight summaries for pending GitHub webhook events. " - "Returns metadata only, without full payloads." - ), - inputSchema={ - "type": "object", - "properties": { - "limit": { - "type": "integer", - "description": "Maximum number of pending events to return", - "minimum": 1, - "maximum": 100, - "default": 20, - } - }, - "required": [], - }, - ), - types.Tool( - name="get_event", - description="Get the full payload for a single webhook event by ID.", - inputSchema={ - "type": "object", - "properties": { - "event_id": { - "type": "string", - "description": "The event ID to retrieve", - } - }, - "required": ["event_id"], - }, - ), - types.Tool( - name="get_webhook_events", - description=( - "Get pending (unprocessed) GitHub webhook events with full payloads. " - "Prefer get_pending_status or list_pending_events for polling." - ), - inputSchema={"type": "object", "properties": {}, "required": []}, - ), - types.Tool( - name="mark_processed", - description="Mark a webhook event as processed so it won't appear again.", - inputSchema={ - "type": "object", - "properties": { - "event_id": { - "type": "string", - "description": "The event ID to mark as processed", - } - }, - "required": ["event_id"], - }, - ), - ] - - @server.call_tool() - async def call_tool( - name: str, arguments: dict[str, Any] - ) -> list[types.TextContent]: - if name == "get_pending_status": - status = get_pending_status() - return [ - types.TextContent( - type="text", - text=json.dumps(status, ensure_ascii=False, indent=2), - ) - ] - if name == "list_pending_events": - limit = arguments.get("limit", 20) - if not isinstance(limit, int): - raise ValueError("limit must be an integer") - summaries = get_pending_summaries(limit=limit) - return [ - types.TextContent( - type="text", - text=json.dumps(summaries, ensure_ascii=False, indent=2), - ) - ] - if name == "get_event": - event_id = arguments.get("event_id", "") - event = get_event(event_id) - if event is None: - return [ - types.TextContent( - type="text", - text=json.dumps({"error": "not_found", "event_id": event_id}), - ) - ] - return [ - types.TextContent( - type="text", - text=json.dumps(event, ensure_ascii=False, indent=2), - ) - ] - if name == "get_webhook_events": - events = get_pending() - return [ - types.TextContent( - type="text", - text=json.dumps(events, ensure_ascii=False, indent=2), - ) - ] - if name == "mark_processed": - event_id = arguments.get("event_id", "") - result = mark_done(event_id) - return [ - types.TextContent( - type="text", - text=json.dumps({"success": result["success"], "event_id": event_id, "purged": result["purged"]}), - ) - ] - raise ValueError(f"Unknown tool: {name}") - - async with stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -# ── Entry Point ─────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="github-webhook-mcp") - sub = parser.add_subparsers(dest="mode", required=True) - - wp = sub.add_parser("webhook", help="Start webhook receiver (HTTP server)") - wp.add_argument("--port", type=int, default=8080) - wp.add_argument("--secret", default=os.environ.get("WEBHOOK_SECRET", "")) - wp.add_argument( - "--event-profile", - 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, - 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/requirements.txt b/requirements.txt deleted file mode 100644 index ff65e21..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -fastapi>=0.110.0 -uvicorn>=0.29.0 -mcp>=1.0.0 -python-dotenv>=1.0.0 diff --git a/test_codex_reaction.py b/test_codex_reaction.py deleted file mode 100644 index 9907eac..0000000 --- a/test_codex_reaction.py +++ /dev/null @@ -1,124 +0,0 @@ -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 deleted file mode 100644 index be0fafa..0000000 --- a/test_main.py +++ /dev/null @@ -1,281 +0,0 @@ -import asyncio -import json -import tempfile -import unittest -from pathlib import Path - -import main - - -class EventStoreEncodingTests(unittest.TestCase): - def setUp(self) -> None: - self.temp_dir = tempfile.TemporaryDirectory() - self.original_data_file = main.DATA_FILE - main.DATA_FILE = Path(self.temp_dir.name) / "events.json" - - def tearDown(self) -> None: - main.DATA_FILE = self.original_data_file - self.temp_dir.cleanup() - - def test_save_uses_utf8(self) -> None: - event = {"id": "1", "type": "issue_comment", "payload": {"body": "日本語"}, "processed": False} - - main._save([event]) - - raw = main.DATA_FILE.read_bytes() - self.assertIn("日本語".encode("utf-8"), raw) - self.assertEqual(json.loads(raw.decode("utf-8")), [event]) - - def test_load_reads_legacy_cp932_and_rewrites_utf8(self) -> None: - event = {"id": "1", "type": "issue_comment", "payload": {"body": "日本語"}, "processed": False} - legacy_text = json.dumps([event], ensure_ascii=False, indent=2) - main.DATA_FILE.write_bytes(legacy_text.encode("cp932")) - - loaded = main._load() - - self.assertEqual(loaded, [event]) - self.assertEqual(json.loads(main.DATA_FILE.read_text(encoding="utf-8")), [event]) - - -class EventSummaryTests(unittest.TestCase): - def setUp(self) -> None: - self.temp_dir = tempfile.TemporaryDirectory() - self.original_data_file = main.DATA_FILE - main.DATA_FILE = Path(self.temp_dir.name) / "events.json" - - def tearDown(self) -> None: - main.DATA_FILE = self.original_data_file - self.temp_dir.cleanup() - - def test_pending_status_and_summaries_are_lightweight(self) -> None: - main.add_event( - "issues", - { - "action": "opened", - "repository": {"full_name": "Liplus-Project/liplus-language"}, - "sender": {"login": "smileygames"}, - "issue": { - "number": 624, - "title": "test", - "html_url": "https://github.com/Liplus-Project/liplus-language/issues/624", - }, - }, - ) - main.add_event( - "workflow_run", - { - "action": "completed", - "repository": {"full_name": "Liplus-Project/liplus-language"}, - "sender": {"login": "github-actions"}, - "workflow_run": {"name": "Governance CI", "html_url": "https://example.invalid/run"}, - }, - ) - - status = main.get_pending_status() - summaries = main.get_pending_summaries(limit=10) - - self.assertEqual(status["pending_count"], 2) - self.assertEqual(status["types"], {"issues": 1, "workflow_run": 1}) - self.assertEqual(len(summaries), 2) - self.assertEqual(summaries[0]["number"], 624) - self.assertEqual(summaries[0]["title"], "test") - self.assertEqual(summaries[1]["title"], "Governance CI") - self.assertNotIn("payload", summaries[0]) - - def test_get_event_returns_full_payload(self) -> None: - event = main.add_event( - "issues", - { - "action": "opened", - "repository": {"full_name": "Liplus-Project/liplus-language"}, - "issue": {"number": 624, "title": "test"}, - }, - ) - - stored = main.get_event(event["id"]) - - self.assertIsNotNone(stored) - self.assertEqual(stored["payload"]["issue"]["number"], 624) - - -class EventProfileTests(unittest.TestCase): - def test_notifications_profile_keeps_issue_and_ci_events(self) -> None: - self.assertTrue( - main.should_store_event( - "issues", - {"action": "opened"}, - "notifications", - ) - ) - self.assertTrue( - main.should_store_event( - "workflow_run", - {"action": "completed"}, - "notifications", - ) - ) - - def test_notifications_profile_drops_non_notification_events(self) -> None: - self.assertFalse( - main.should_store_event( - "workflow_job", - {"action": "completed"}, - "notifications", - ) - ) - self.assertFalse( - main.should_store_event( - "issue_comment", - {"action": "edited"}, - "notifications", - ) - ) - - def test_all_profile_keeps_everything(self) -> None: - self.assertTrue( - main.should_store_event( - "workflow_job", - {"action": "completed"}, - "all", - ) - ) - - -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()