Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.env
events.json
trigger-events/
codex-runs/
__pycache__/
*.pyc
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <workspace> ...
```

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 <thread-or-session-id>
```

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
Expand Down Expand Up @@ -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

Expand All @@ -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/<event-id>.json`: saved payload passed to direct trigger commands

## Related

- [Liplus-Project/liplus-language](https://github.com/Liplus-Project/liplus-language) — Li+ language specification
Expand Down
220 changes: 220 additions & 0 deletions codex_reaction.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading