Massive Improvements & MVP Patches
All checks were successful
ci / test (push) Successful in 27s
ci / publish (push) Successful in 1m24s

This commit is contained in:
Space-Banane
2026-05-22 21:27:48 +02:00
parent ae18420744
commit 7b63ecd536
20 changed files with 713 additions and 72 deletions

View File

@@ -8,11 +8,20 @@ GITEA_BOT_USERNAME=codex-bot
# Shared secret configured on the Gitea webhook. # Shared secret configured on the Gitea webhook.
GITEA_WEBHOOK_SECRET=replace GITEA_WEBHOOK_SECRET=replace
# OpenAI API credentials for Codex review generation. # OpenAI API credentials for API-key mode (required when CODEX_AUTH_MODE=api_key).
OPENAI_API_KEY=replace OPENAI_API_KEY=replace
OPENAI_PROJECT_ID= OPENAI_PROJECT_ID=
OPENAI_ORG_ID= OPENAI_ORG_ID=
# Codex runner auth mode:
# - api_key: use OPENAI_API_KEY inside the review container.
# - chatgpt: mount auth.json into the container and use ChatGPT-managed auth.
CODEX_AUTH_MODE=api_key
# Optional custom host path for auth.json when CODEX_AUTH_MODE=chatgpt.
# Defaults to ~/.codex/auth.json when unset.
CODEX_AUTH_JSON_PATH=
# Comma-separated allowlist of repositories this bot may process. # Comma-separated allowlist of repositories this bot may process.
# Example: space/gitea-codex,space/another-repo # Example: space/gitea-codex,space/another-repo
ALLOWED_REPOS=space/gitea-codex ALLOWED_REPOS=space/gitea-codex

View File

@@ -111,16 +111,18 @@ Required:
- `GITEA_TOKEN` - `GITEA_TOKEN`
- `GITEA_BOT_USERNAME` - `GITEA_BOT_USERNAME`
- `GITEA_WEBHOOK_SECRET` - `GITEA_WEBHOOK_SECRET`
- `OPENAI_API_KEY`
- `ALLOWED_REPOS` - `ALLOWED_REPOS`
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
Common optional: Common optional:
- `DATABASE_URL` (overrides DB parts) - `DATABASE_URL` (overrides DB parts)
- `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`)
- `OPENAI_PROJECT_ID`, `OPENAI_ORG_ID` - `OPENAI_PROJECT_ID`, `OPENAI_ORG_ID`
- `OPENAI_REVIEW_MODEL` - `OPENAI_REVIEW_MODEL`
- `OPENAI_REASONING_EFFORT` - `OPENAI_REASONING_EFFORT`
- `CODEX_AUTH_MODE` (`api_key` default, `chatgpt` supported)
- `CODEX_AUTH_JSON_PATH` (custom path to `auth.json` for `chatgpt` mode)
- `WORKDIR`, `MAX_DIFF_BYTES`, `MAX_REVIEW_MINUTES`, `CONCURRENCY` - `WORKDIR`, `MAX_DIFF_BYTES`, `MAX_REVIEW_MINUTES`, `CONCURRENCY`
- `REVIEW_RUNNER_IMAGE` - `REVIEW_RUNNER_IMAGE`
- `ENABLE_FIX_COMMANDS` - `ENABLE_FIX_COMMANDS`
@@ -169,4 +171,3 @@ Treat these as high-sensitivity areas when modifying worker/runner paths.
3. Add/update tests with behavior changes. 3. Add/update tests with behavior changes.
4. Run `pytest`. 4. Run `pytest`.
5. Summarize impact, risks, and follow-ups in PR/commit notes. 5. Summarize impact, risks, and follow-ups in PR/commit notes.

View File

@@ -1,4 +1,4 @@
FROM python:3.12-slim FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1

View File

@@ -46,14 +46,16 @@ Required:
- `GITEA_TOKEN` - `GITEA_TOKEN`
- `GITEA_BOT_USERNAME` - `GITEA_BOT_USERNAME`
- `GITEA_WEBHOOK_SECRET` - `GITEA_WEBHOOK_SECRET`
- `OPENAI_API_KEY`
- `ALLOWED_REPOS` - `ALLOWED_REPOS`
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
Optional: Optional:
- `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`, optional when `CODEX_AUTH_MODE=chatgpt`)
- `OPENAI_PROJECT_ID` - `OPENAI_PROJECT_ID`
- `OPENAI_ORG_ID` - `OPENAI_ORG_ID`
- `CODEX_AUTH_MODE` (`api_key` default, or `chatgpt`)
- `CODEX_AUTH_JSON_PATH` (custom host path to `auth.json`; defaults to `~/.codex/auth.json` in `chatgpt` mode)
- `DATABASE_URL` (overrides composed DB URL) - `DATABASE_URL` (overrides composed DB URL)
## Local Run ## Local Run

43
TODO.md
View File

@@ -1,20 +1,39 @@
# TODO # TODO
## Open Items By Priority ## Open Items By Priority
### P0 (Critical) ### P0 (Critical)
- [ ] True isolated runner flow: clone/fetch/checkout PR branch inside the ephemeral container itself, not on host before prompt generation. - [ ] `BUG`: True isolated runner flow: clone/fetch/checkout PR branch inside the ephemeral container itself, not on host before prompt generation.
- [ ] Remove host-side fallback path for review execution or gate it behind explicit `ALLOW_HOST_FALLBACK` to avoid silently bypassing isolation. - [ ] `BUG`: Remove host-side fallback path for review execution, or gate it behind explicit `ALLOW_HOST_FALLBACK=false` by default so isolation cannot be bypassed silently.
- [ ] Add integration test that proves runner container receives repo+PR context and executes review for the exact PR head SHA. - [ ] `BUG`: Enforce `.codex-review.yml` `enabled=false` at runtime (currently loaded but not enforced).
- [ ] `BUG`: Enforce `.codex-review.yml` fix policy (`commands.allow_fix`) for `@codex fix` (currently only global `ENABLE_FIX_COMMANDS` is checked).
- [ ] `BUG`: Add stuck-job recovery for `running` jobs (lease timeout + requeue/fail) so one crashed worker does not deadlock the queue.
- [ ] `BUG`: Validate required secrets/settings are non-empty at startup (`GITEA_WEBHOOK_SECRET`, `GITEA_TOKEN`, `ALLOWED_REPOS`) and fail fast if blank.
- [ ] `TEST`: Add integration test proving the runner executes the exact PR head SHA in isolated mode and does not rely on host checkout.
### P1 (Important) ### P1 (Important)
- [ ] `WEBHOOK_MODE` is currently informational only; add runtime validation/check endpoint that confirms expected webhook scope (`repo` or `global`) is actually configured in Gitea by host admin. - [ ] `FEATURE`: Full control UI to update the bots settings. Password in env variable protected login page. No more env variables.
- [x] Make review model configurable via env (for example `OPENAI_REVIEW_MODEL`) instead of hardcoding `gpt-5`. - [ ] `FEATURE`: Automatic Trigger on new PRs and or commits on PRs with context that its a change that needs review not the whole PR again. GITEA_ALLOW_PR_AUTO_REVIEW=true would be needed
- [ ] Add retries/backoff for `codex exec` bootstrap (`npm install -g @openai/codex`) to reduce transient network/setup failures. - [ ] `BUG`: Container runner hardcodes `codex exec --json -m gpt-5`; use `OPENAI_REVIEW_MODEL` and `OPENAI_REASONING_EFFORT` consistently across runner paths.
- [ ] Add end-to-end test path against live Gitea + MariaDB + docker runner (webhook -> queue -> runner -> PR comment update). - [ ] `BUG`: Preserve command arguments losslessly (quoted args are currently flattened by `" ".join(...)` + `.split()` roundtrip).
- [ ] `BUG`: `parse_command` only matches when `@codex` is at the start of the comment; support inline command usage in normal review-discussion comments.
- [ ] `BUG`: Add max comment length handling/chunking before posting to Gitea to avoid failures on large review outputs.
- [ ] `FEATURE`: Add retries/backoff for `codex exec` bootstrap (`npm install -g @openai/codex`) to reduce transient network/setup failures.
- [ ] `FEATURE`: `WEBHOOK_MODE` is currently informational only; add runtime validation/check endpoint that confirms expected webhook scope (`repo` or `global`) is actually configured in Gitea by host admin.
- [ ] `TEST`: Add end-to-end test path against live Gitea + MariaDB + docker runner (webhook -> queue -> runner -> PR comment update).
- [ ] `FEATURE`: Add username as possible command prefix, ex. "@bot-name review" in addition to "@codex review", for better UX discoverability.
### P2 (Nice to have) ### P2 (Nice to Have)
- [ ] Add explicit env docs for reverse-proxy deployment (`BASE_PUBLIC_URL`, trusted headers). - [ ] `FEATURE`: Add a note line generated by the reviewer at the end of comments to show model tokens used and such.
- [ ] Add per-repo command policy in `.codex-review.yml` for enabling/disabling commands (`review`, `fix`, `explain`, `rerun`). - [ ] `FEATURE`: Little static tailwind cdn styled page for any http endpoint that just shows what this is, incase this gets discovered by some random lad. Other routes than "/" should return a 404 with if a browser accessed it a again, tailwind cdn themed 404 page. Both should be nicely designed and minimalistic.
- [ ] Add structured log redaction tests to ensure PAT/keys never appear in logs/comments. - [ ] `FEATURE`: Apply `.codex-review.yml` `review.default_mode` when `@codex review` is issued without explicit mode.
- [ ] `FEATURE`: Add per-repo command policy in `.codex-review.yml` for enabling/disabling `review`, `fix`, `explain`, and `rerun` independently.
- [ ] `TEST`: Add structured log redaction tests to ensure PAT/keys never appear in logs/comments.
- [ ] `TEST`: Stabilize pytest temp/cache paths on locked-down hosts (configure workspace-local `basetemp` and cache path) to avoid `PermissionError` in test setup.
- [ ] `DOCS`: Add explicit env docs for reverse-proxy deployment (`BASE_PUBLIC_URL`, trusted headers).
### P3 (Backlog)
- [ ] `FEATURE`: Add queue metrics and traces (queued/running age, success/failure counters, fallback usage) for operations visibility.
- [ ] `FEATURE`: Add superseded-job cancellation for same PR/head to avoid running obsolete queued jobs.
- [ ] `FEATURE`: Add `@codex status` command to report latest job state/run ID for a PR.
- [ ] `TEST`: Add property/fuzz tests for command parsing and webhook payload edge cases.

View File

@@ -1,21 +1,21 @@
services: services:
mariadb: # mariadb: # Uncomment this block to run a local MariaDB instance.
image: mariadb:11 # image: mariadb:11
restart: unless-stopped # restart: unless-stopped
environment: # environment:
MARIADB_DATABASE: gitea_codex # MARIADB_DATABASE: gitea_codex
MARIADB_USER: gitea_codex # MARIADB_USER: gitea_codex
MARIADB_PASSWORD: gitea_codex # MARIADB_PASSWORD: gitea_codex
MARIADB_ROOT_PASSWORD: rootpass # MARIADB_ROOT_PASSWORD: rootpass
ports: # ports:
- "3306:3306" # - "3306:3306"
volumes: # volumes:
- ./db:/var/lib/mysql # - ./db:/var/lib/mysql
healthcheck: # healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-uroot", "-prootpass"] # test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
interval: 5s # interval: 5s
timeout: 3s # timeout: 3s
retries: 20 # retries: 20
bot: bot:
build: . build: .
@@ -26,5 +26,7 @@ services:
- .env - .env
volumes: volumes:
- ./worktrees:/var/lib/gitea-codex/worktrees - ./worktrees:/var/lib/gitea-codex/worktrees
- ~/.codex/auth.json:/root/.codex/auth.json:ro # Comment this out if you are not using ChatGPT Auth
- //var/run/docker.sock:/var/run/docker.sock
ports: ports:
- "8000:8000" - "8000:8000"

View File

@@ -15,11 +15,13 @@ class Settings(BaseSettings):
gitea_bot_username: str = Field(alias="GITEA_BOT_USERNAME") gitea_bot_username: str = Field(alias="GITEA_BOT_USERNAME")
gitea_webhook_secret: SecretStr = Field(alias="GITEA_WEBHOOK_SECRET") gitea_webhook_secret: SecretStr = Field(alias="GITEA_WEBHOOK_SECRET")
openai_api_key: SecretStr = Field(alias="OPENAI_API_KEY") openai_api_key: SecretStr | None = Field(default=None, alias="OPENAI_API_KEY")
openai_project_id: str | None = Field(default=None, alias="OPENAI_PROJECT_ID") openai_project_id: str | None = Field(default=None, alias="OPENAI_PROJECT_ID")
openai_org_id: str | None = Field(default=None, alias="OPENAI_ORG_ID") openai_org_id: str | None = Field(default=None, alias="OPENAI_ORG_ID")
openai_review_model: str = Field(default="gpt-5.3-codex", alias="OPENAI_REVIEW_MODEL") openai_review_model: str = Field(default="gpt-5.3-codex", alias="OPENAI_REVIEW_MODEL")
openai_reasoning_effort: Literal["none", "low", "medium", "high"] = Field(default="high", alias="OPENAI_REASONING_EFFORT") openai_reasoning_effort: Literal["none", "low", "medium", "high"] = Field(default="high", alias="OPENAI_REASONING_EFFORT")
codex_auth_mode: Literal["api_key", "chatgpt"] = Field(default="api_key", alias="CODEX_AUTH_MODE")
codex_auth_json_path: str | None = Field(default=None, alias="CODEX_AUTH_JSON_PATH")
allowed_repos: str = Field(alias="ALLOWED_REPOS") allowed_repos: str = Field(alias="ALLOWED_REPOS")
cooldown_seconds: int = Field(default=60, alias="COOLDOWN_SECONDS") cooldown_seconds: int = Field(default=60, alias="COOLDOWN_SECONDS")

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
@@ -26,10 +28,56 @@ logger = logging.getLogger(__name__)
def _validate_required_env(settings: Settings) -> None: def _validate_required_env(settings: Settings) -> None:
if not settings.openai_api_key.get_secret_value().strip(): if settings.codex_auth_mode != "api_key":
return
api_key = settings.openai_api_key.get_secret_value() if settings.openai_api_key else ""
if not api_key.strip():
raise RuntimeError("OPENAI_API_KEY is required") raise RuntimeError("OPENAI_API_KEY is required")
def _configured_auth_json_path(settings: Settings) -> Path:
raw_path = settings.codex_auth_json_path.strip() if settings.codex_auth_json_path else "~/.codex/auth.json"
return Path(raw_path).expanduser()
def _log_startup_identity(settings: Settings) -> None:
logger.info(
"Bot startup identity: username=%s gitea_base_url=%s auth_mode=%s",
settings.gitea_bot_username,
settings.gitea_base_url,
settings.codex_auth_mode,
)
def _log_startup_auth_json_status(settings: Settings) -> None:
if settings.codex_auth_mode != "chatgpt":
logger.info("Codex auth configuration: mode=api_key (auth.json not used)")
return
auth_path = _configured_auth_json_path(settings)
try:
content = auth_path.read_text(encoding="utf-8")
parsed = json.loads(content)
except FileNotFoundError:
logger.warning("Codex auth configuration: mode=chatgpt auth.json missing path=%s", auth_path)
return
except json.JSONDecodeError as exc:
logger.warning("Codex auth configuration: mode=chatgpt invalid auth.json path=%s error=%s", auth_path, exc.msg)
return
except OSError as exc:
logger.warning("Codex auth configuration: mode=chatgpt auth.json unreadable path=%s error=%s", auth_path, exc)
return
root_type = type(parsed).__name__
configured_mode = parsed.get("auth_mode") if isinstance(parsed, dict) else None
logger.info(
"Codex auth configuration: mode=chatgpt auth.json valid path=%s root_type=%s auth_mode=%s",
auth_path,
root_type,
configured_mode or "unknown",
)
def _extract_pr_event(payload: dict[str, Any], event_name: str) -> tuple[str, int, str, int, str] | None: def _extract_pr_event(payload: dict[str, Any], event_name: str) -> tuple[str, int, str, int, str] | None:
repository = payload.get("repository", {}) repository = payload.get("repository", {})
repo = repository.get("full_name") repo = repository.get("full_name")
@@ -68,6 +116,8 @@ def _extract_pr_event(payload: dict[str, Any], event_name: str) -> tuple[str, in
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
_validate_required_env(settings) _validate_required_env(settings)
_log_startup_identity(settings)
_log_startup_auth_json_status(settings)
Base.metadata.create_all(bind=get_engine()) Base.metadata.create_all(bind=get_engine())
stop_event = asyncio.Event() stop_event = asyncio.Event()
@@ -119,7 +169,23 @@ async def gitea_webhook(
comment_body = str(payload.get("comment", {}).get("body", "")).strip() comment_body = str(payload.get("comment", {}).get("body", "")).strip()
parsed_command = parse_command(comment_body) parsed_command = parse_command(comment_body)
if not parsed_command: if not parsed_command:
logger.info(
"Webhook ignored: no @codex review command repo=%s pr=%s comment_id=%s sender=%s",
repo,
pr_number,
comment_id,
sender_username,
)
return {"accepted": False, "reason": "no codex command"} return {"accepted": False, "reason": "no codex command"}
if parsed_command.name != "review":
logger.info(
"Webhook without @codex review command repo=%s pr=%s comment_id=%s sender=%s parsed_command=%s",
repo,
pr_number,
comment_id,
sender_username,
parsed_command.name,
)
if repo not in settings.allowed_repo_set: if repo not in settings.allowed_repo_set:
return {"accepted": False, "reason": "repo not allowed"} return {"accepted": False, "reason": "repo not allowed"}

View File

@@ -3,6 +3,19 @@ from __future__ import annotations
from gitea_codex_bot.types import ParsedCommand from gitea_codex_bot.types import ParsedCommand
def _inject_head_sha_marker(head_sha: str, body: str) -> str:
marker = f"<!-- codex-review:head_sha={head_sha} -->"
stripped = body.strip()
if not stripped:
return marker
if stripped.startswith("<!-- codex-review:head_sha="):
lines = stripped.splitlines()
if lines:
lines[0] = marker
return "\n".join(lines).strip()
return f"{marker}\n{stripped}"
def format_queue_ack(head_sha: str) -> str: def format_queue_ack(head_sha: str) -> str:
short_sha = head_sha[:7] short_sha = head_sha[:7]
return f"👀 Codex review queued for commit `{short_sha}`." return f"👀 Codex review queued for commit `{short_sha}`."
@@ -21,6 +34,10 @@ def format_unsupported_ack(command: ParsedCommand) -> str:
def format_result_comment(head_sha: str, result: dict) -> str: def format_result_comment(head_sha: str, result: dict) -> str:
markdown_comment = result.get("markdown_comment")
if isinstance(markdown_comment, str) and markdown_comment.strip():
return _inject_head_sha_marker(head_sha, markdown_comment)
verdict = result.get("verdict", "has_issues") verdict = result.get("verdict", "has_issues")
confidence = float(result.get("confidence", 0.0)) confidence = float(result.get("confidence", 0.0))
summary = str(result.get("summary", "No summary returned.")) summary = str(result.get("summary", "No summary returned."))
@@ -47,4 +64,4 @@ def format_result_comment(head_sha: str, result: dict) -> str:
f" Suggestion: {suggestion}" if suggestion else " Suggestion: n/a", f" Suggestion: {suggestion}" if suggestion else " Suggestion: n/a",
] ]
) )
return "\n".join(lines).strip() return _inject_head_sha_marker(head_sha, "\n".join(lines).strip())

View File

@@ -124,7 +124,8 @@ def _build_prompt(
' "verdict": "correct" | "has_issues",\n' ' "verdict": "correct" | "has_issues",\n'
' "confidence": 0.0,\n' ' "confidence": 0.0,\n'
' "summary": "...",\n' ' "summary": "...",\n'
' "findings": [{"severity":"low|medium|high|critical","file":"...","line_start":1,"line_end":1,"title":"...","body":"...","suggestion":"..."}]\n' ' "findings": [{"severity":"low|medium|high|critical","file":"...","line_start":1,"line_end":1,"title":"...","body":"...","suggestion":"..."}],\n'
' "markdown_comment": "Full markdown comment body to post to Gitea. Include clear section breaks and blank lines."\n'
"}\n\n" "}\n\n"
f"PR URL: {pr.html_url}\n" f"PR URL: {pr.html_url}\n"
f"Mode: {mode}\n" f"Mode: {mode}\n"
@@ -138,8 +139,11 @@ def _build_prompt(
def _call_openai_review(settings: Settings, prompt: str) -> dict[str, Any]: def _call_openai_review(settings: Settings, prompt: str) -> dict[str, Any]:
api_key = settings.openai_api_key.get_secret_value() if settings.openai_api_key else ""
if not api_key.strip():
raise ReviewError("OPENAI_API_KEY is required for API-key review mode.")
headers: dict[str, str] = { headers: dict[str, str] = {
"Authorization": f"Bearer {settings.openai_api_key.get_secret_value()}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
if settings.openai_org_id: if settings.openai_org_id:

View File

@@ -1,6 +1,11 @@
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import logging
import os
import re
import shlex
import subprocess import subprocess
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -11,6 +16,9 @@ from gitea_codex_bot.services.gitea import GiteaClient
from gitea_codex_bot.services.reviewer import normalize_review_result, prepare_review_prompt, run_review_for_pr from gitea_codex_bot.services.reviewer import normalize_review_result, prepare_review_prompt, run_review_for_pr
from gitea_codex_bot.types import ParsedCommand from gitea_codex_bot.types import ParsedCommand
CONTAINER_CODEX_HOME = "/root/.codex"
logger = logging.getLogger(__name__)
def run_review_ephemeral( def run_review_ephemeral(
settings: Settings, settings: Settings,
@@ -22,11 +30,58 @@ def run_review_ephemeral(
gitea = GiteaClient(settings) gitea = GiteaClient(settings)
prompt, _diff_context, _repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command) prompt, _diff_context, _repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command)
container_name = f"codex-review-{uuid.uuid4().hex[:12]}" container_name = f"codex-review-{uuid.uuid4().hex[:12]}"
install_and_run = ( install_and_run = _build_install_and_run_command(settings)
"set -euo pipefail; " extra_env: dict[str, str] = {}
"npm install -g @openai/codex >/tmp/codex-install.log 2>&1; " if settings.codex_auth_mode == "chatgpt":
"codex exec --json -m gpt-5" extra_env["CODEX_AUTH_JSON_B64"] = _load_codex_auth_json_b64(settings)
cmd = _build_docker_command(settings, container_name=container_name, install_and_run=install_and_run)
try:
completed = subprocess.run(
cmd,
input=prompt,
text=True,
check=False,
capture_output=True,
timeout=settings.max_review_minutes * 60,
env={**os.environ, **extra_env},
) )
if completed.returncode != 0:
raise RuntimeError(_format_runner_failure(completed))
parsed = _parse_codex_exec_stdout(completed.stdout)
return normalize_review_result(parsed)
except Exception as exc:
if settings.codex_auth_mode == "chatgpt":
logger.warning("Ephemeral chatgpt runner failed, skipping API-key fallback: %s", exc)
return _chatgpt_runner_failure_result(exc)
result, _repo_cfg = run_review_for_pr(settings, gitea, repo, pr_number, command)
return result
def _build_install_and_run_command(settings: Settings) -> str:
steps = ["set -euo pipefail"]
if settings.codex_auth_mode == "chatgpt":
steps.extend(
[
f"mkdir -p {CONTAINER_CODEX_HOME}",
'printf "%s" "$CODEX_AUTH_JSON_B64" | base64 -d > /root/.codex/auth.json',
f"chmod 600 {CONTAINER_CODEX_HOME}/auth.json",
]
)
steps.extend(
[
"apt-get update >/tmp/apt-update.log 2>&1 && apt-get install -y --no-install-recommends ca-certificates >/tmp/apt-install.log 2>&1 || { rc=$?; echo 'ca-certificates install failed'; tail -n 80 /tmp/apt-update.log || true; tail -n 80 /tmp/apt-install.log || true; exit $rc; }",
"npm install -g @openai/codex >/tmp/codex-install.log 2>&1 || { rc=$?; echo 'codex install failed'; tail -n 200 /tmp/codex-install.log || true; exit $rc; }",
]
)
model = settings.openai_review_model.strip()
if model:
steps.append(f"codex exec --skip-git-repo-check --json -m {shlex.quote(model)}")
else:
steps.append("codex exec --skip-git-repo-check --json")
return "; ".join(steps)
def _build_docker_command(settings: Settings, *, container_name: str, install_and_run: str) -> list[str]:
cmd = [ cmd = [
"docker", "docker",
"run", "run",
@@ -34,33 +89,88 @@ def run_review_ephemeral(
"-i", "-i",
"--name", "--name",
container_name, container_name,
"-e",
"CODEX_DISABLE_TELEMETRY=1",
]
if settings.codex_auth_mode == "chatgpt":
cmd.extend(
[
"-e",
f"CODEX_HOME={CONTAINER_CODEX_HOME}",
"-e",
"CODEX_AUTH_JSON_B64",
]
)
else:
cmd.extend(
[
"-e", "-e",
"OPENAI_API_KEY", "OPENAI_API_KEY",
"-e", "-e",
"OPENAI_ORG_ID", "OPENAI_ORG_ID",
"-e", "-e",
"OPENAI_PROJECT_ID", "OPENAI_PROJECT_ID",
"-e",
"CODEX_DISABLE_TELEMETRY=1",
settings.review_runner_image,
"bash",
"-lc",
install_and_run,
] ]
try:
completed = subprocess.run(
cmd,
input=prompt,
text=True,
check=True,
capture_output=True,
timeout=settings.max_review_minutes * 60,
) )
parsed = _parse_codex_exec_stdout(completed.stdout) cmd.extend([settings.review_runner_image, "bash", "-lc", install_and_run])
return normalize_review_result(parsed) return cmd
except Exception:
result, _repo_cfg = run_review_for_pr(settings, gitea, repo, pr_number, command)
return result def _chatgpt_runner_failure_result(exc: Exception) -> dict[str, Any]:
message = str(exc).strip() or exc.__class__.__name__
summary = f"ChatGPT auth runner failed before review execution. Error: {message}"
return {
"verdict": "has_issues",
"confidence": 0.6,
"summary": summary,
"findings": [
{
"severity": "high",
"file": "runner",
"line_start": 1,
"line_end": 1,
"title": "Ephemeral chatgpt review runner failed",
"body": message,
"suggestion": "Check ephemeral runner logs for model/auth/network issues, then rerun @codex review.",
}
],
}
def _format_runner_failure(completed: subprocess.CompletedProcess[str]) -> str:
stdout_tail = _tail_text(completed.stdout)
stderr_tail = _tail_text(completed.stderr)
message = f"ephemeral runner exited with code {completed.returncode}"
if stdout_tail:
message = f"{message}; stdout_tail={stdout_tail}"
if stderr_tail:
message = f"{message}; stderr_tail={stderr_tail}"
return message
def _tail_text(text: str, limit: int = 1200) -> str:
compact = " ".join(text.split())
if len(compact) <= limit:
return compact
return f"...{compact[-limit:]}"
def _resolve_codex_auth_json_path(settings: Settings) -> Path:
raw_path = settings.codex_auth_json_path.strip() if settings.codex_auth_json_path else "~/.codex/auth.json"
path = Path(raw_path).expanduser()
if not path.exists() or not path.is_file():
raise FileNotFoundError(
f"CODEX_AUTH_MODE=chatgpt requires a readable auth.json file. Checked path: {path}"
)
return path.resolve()
def _load_codex_auth_json_b64(settings: Settings) -> str:
auth_path = _resolve_codex_auth_json_path(settings)
content = auth_path.read_text(encoding="utf-8")
# Validate JSON before handing it to the ephemeral runner.
json.loads(content)
return base64.b64encode(content.encode("utf-8")).decode("ascii")
def ensure_workdir(path: str) -> Path: def ensure_workdir(path: str) -> Path:
@@ -84,9 +194,35 @@ def _parse_codex_exec_stdout(stdout: str) -> dict[str, Any]:
extracted = _extract_text(payload) extracted = _extract_text(payload)
if extracted: if extracted:
last_text = extracted last_text = extracted
parsed = _parse_review_json_from_text(extracted)
if parsed:
return parsed
if not last_text: if not last_text:
raise RuntimeError("codex exec output did not include parseable JSON text") raise RuntimeError("codex exec output did not include parseable review payload text")
return json.loads(last_text) raise RuntimeError(f"codex exec output text did not contain review JSON; text_tail={_tail_text(last_text, 400)}")
def _parse_review_json_from_text(text: str) -> dict[str, Any] | None:
candidates: list[str] = [text.strip()]
fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, flags=re.DOTALL | re.IGNORECASE)
if fenced:
candidates.append(fenced.group(1).strip())
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
candidates.append(text[start : end + 1].strip())
seen: set[str] = set()
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
continue
if isinstance(payload, dict) and {"verdict", "summary", "findings"}.issubset(payload.keys()):
return payload
return None
def _extract_text(payload: Any) -> str | None: def _extract_text(payload: Any) -> str | None:
@@ -99,6 +235,8 @@ def _extract_text(payload: Any) -> str | None:
if text: if text:
return text return text
for value in payload.values(): for value in payload.values():
if not isinstance(value, (dict, list)):
continue
text = _extract_text(value) text = _extract_text(value)
if text: if text:
return text return text

View File

@@ -4,6 +4,7 @@ import asyncio
import logging import logging
from typing import Any from typing import Any
import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -110,7 +111,18 @@ def process_one_job(settings: Settings) -> bool:
with session_factory() as session: with session_factory() as session:
comment_id = get_persistent_review_comment_id(session, job.repo, job.pr_number) comment_id = get_persistent_review_comment_id(session, job.repo, job.pr_number)
if comment_id: if comment_id:
try:
gitea.edit_issue_comment(job.repo, comment_id, comment_body) gitea.edit_issue_comment(job.repo, comment_id, comment_body)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 404:
raise
logger.warning(
"Persistent review comment not found; posting a new one repo=%s pr=%s old_comment_id=%s",
job.repo,
job.pr_number,
comment_id,
)
comment_id = gitea.post_issue_comment(job.repo, job.pr_number, comment_body)
else: else:
comment_id = gitea.post_issue_comment(job.repo, job.pr_number, comment_body) comment_id = gitea.post_issue_comment(job.repo, job.pr_number, comment_body)
upsert_persistent_review_comment_id( upsert_persistent_review_comment_id(

View File

@@ -16,6 +16,8 @@ def _env_defaults(monkeypatch: pytest.MonkeyPatch, tmp_path, request: pytest.Fix
monkeypatch.setenv("GITEA_BOT_USERNAME", "codex-bot") monkeypatch.setenv("GITEA_BOT_USERNAME", "codex-bot")
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "secret") monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "secret")
monkeypatch.setenv("OPENAI_API_KEY", "openai-key") monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.setenv("CODEX_AUTH_MODE", "api_key")
monkeypatch.delenv("CODEX_AUTH_JSON_PATH", raising=False)
monkeypatch.setenv("ALLOWED_REPOS", "acme/repo") monkeypatch.setenv("ALLOWED_REPOS", "acme/repo")
monkeypatch.setenv("COOLDOWN_SECONDS", "60") monkeypatch.setenv("COOLDOWN_SECONDS", "60")
monkeypatch.setenv("WEBHOOK_MODE", "repo") monkeypatch.setenv("WEBHOOK_MODE", "repo")

View File

@@ -1,6 +1,13 @@
from gitea_codex_bot.config import get_settings from gitea_codex_bot.config import get_settings
def test_openai_api_key_required() -> None: def test_openai_api_key_from_env() -> None:
settings = get_settings() settings = get_settings()
assert settings.openai_api_key is not None
assert settings.openai_api_key.get_secret_value() == "openai-key" assert settings.openai_api_key.get_secret_value() == "openai-key"
def test_codex_auth_defaults_to_api_key_mode() -> None:
settings = get_settings()
assert settings.codex_auth_mode == "api_key"
assert settings.codex_auth_json_path is None

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from pathlib import Path
import pytest
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.workers.container_runner import (
CONTAINER_CODEX_HOME,
_build_docker_command,
_build_install_and_run_command,
_load_codex_auth_json_b64,
_parse_codex_exec_stdout,
_resolve_codex_auth_json_path,
run_review_ephemeral,
)
def test_build_docker_command_api_key_mode_uses_openai_env() -> None:
settings = get_settings()
cmd = _build_docker_command(settings, container_name="codex-review-test", install_and_run="echo ok")
assert "OPENAI_API_KEY" in cmd
assert "OPENAI_ORG_ID" in cmd
assert "OPENAI_PROJECT_ID" in cmd
assert "--mount" not in cmd
def test_build_docker_command_chatgpt_mode_mounts_auth_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
auth_file = tmp_path / "custom-auth.json"
auth_file.write_text('{"auth_mode":"chatgpt"}', encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
monkeypatch.setenv("CODEX_AUTH_JSON_PATH", str(auth_file))
get_settings.cache_clear()
settings = get_settings()
cmd = _build_docker_command(settings, container_name="codex-review-test", install_and_run="echo ok")
env_items = {value for index, value in enumerate(cmd) if index > 0 and cmd[index - 1] == "-e"}
assert "OPENAI_API_KEY" not in cmd
assert f"CODEX_HOME={CONTAINER_CODEX_HOME}" in env_items
assert "CODEX_AUTH_JSON_B64" in env_items
def test_build_install_command_chatgpt_mode_copies_auth_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
auth_file = tmp_path / "auth.json"
auth_file.write_text("{}", encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
monkeypatch.setenv("CODEX_AUTH_JSON_PATH", str(auth_file))
get_settings.cache_clear()
settings = get_settings()
command = _build_install_and_run_command(settings)
assert 'printf "%s" "$CODEX_AUTH_JSON_B64" | base64 -d > /root/.codex/auth.json' in command
assert "codex exec --skip-git-repo-check --json -m gpt-5.3-codex" in command
def test_chatgpt_mode_requires_existing_auth_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
missing = tmp_path / "missing-auth.json"
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
monkeypatch.setenv("CODEX_AUTH_JSON_PATH", str(missing))
get_settings.cache_clear()
settings = get_settings()
with pytest.raises(FileNotFoundError):
_resolve_codex_auth_json_path(settings)
def test_load_codex_auth_json_b64_roundtrip(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
auth_file = tmp_path / "auth.json"
auth_file.write_text('{"auth_mode":"chatgpt","access_token":"abc"}', encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
monkeypatch.setenv("CODEX_AUTH_JSON_PATH", str(auth_file))
get_settings.cache_clear()
settings = get_settings()
encoded = _load_codex_auth_json_b64(settings)
assert encoded
def test_run_review_ephemeral_chatgpt_does_not_fallback_to_api_key_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
auth_file = tmp_path / "auth.json"
auth_file.write_text('{"auth_mode":"chatgpt"}', encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
monkeypatch.setenv("CODEX_AUTH_JSON_PATH", str(auth_file))
get_settings.cache_clear()
settings = get_settings()
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.prepare_review_prompt",
lambda *_args, **_kwargs: ("prompt", {"diff": ""}, object()),
)
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", lambda _settings: object())
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.subprocess.run",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("docker unavailable")),
)
def _api_fallback_should_not_run(*_args, **_kwargs):
raise AssertionError("API-key fallback should not run in chatgpt mode")
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.run_review_for_pr", _api_fallback_should_not_run)
from gitea_codex_bot.types import ParsedCommand
result = run_review_ephemeral(
settings,
repo="acme/repo",
pr_number=1,
command=ParsedCommand(name="review", raw="@codex review"),
)
assert result["verdict"] == "has_issues"
assert "ChatGPT auth runner failed" in result["summary"]
def test_parse_codex_exec_stdout_from_stream_item_text_json() -> None:
stdout = '\n'.join(
[
'{"type":"thread.started","thread_id":"abc"}',
'{"type":"item.completed","item":{"type":"agent_message","text":"{\\"verdict\\":\\"correct\\",\\"confidence\\":0.9,\\"summary\\":\\"ok\\",\\"findings\\":[]}"}}',
]
)
parsed = _parse_codex_exec_stdout(stdout)
assert parsed["verdict"] == "correct"
assert parsed["summary"] == "ok"
def test_parse_codex_exec_stdout_from_fenced_json_text() -> None:
stdout = '\n'.join(
[
'{"type":"thread.started","thread_id":"abc"}',
'{"type":"item.completed","item":{"type":"agent_message","text":"Here is the result:\\n```json\\n{\\"verdict\\":\\"has_issues\\",\\"confidence\\":0.8,\\"summary\\":\\"x\\",\\"findings\\":[]}\\n```"}}',
]
)
parsed = _parse_codex_exec_stdout(stdout)
assert parsed["verdict"] == "has_issues"
assert parsed["summary"] == "x"

73
tests/test_dispatcher.py Normal file
View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from types import SimpleNamespace
import httpx
from sqlalchemy import select
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.db import get_session_factory
from gitea_codex_bot.models import ReviewJob
from gitea_codex_bot.services.comments import get_persistent_review_comment_id, upsert_persistent_review_comment_id
from gitea_codex_bot.services.jobs import enqueue_job
from gitea_codex_bot.types import ParsedCommand
from gitea_codex_bot.workers.dispatcher import process_one_job
def test_process_one_job_recreates_persistent_comment_when_edit_returns_404(monkeypatch) -> None:
session_factory = get_session_factory()
with session_factory() as session:
job = enqueue_job(
session,
repo="acme/repo",
pr_number=9,
head_sha="deadbeef",
trigger_comment_id=111,
requested_by="alice",
command=ParsedCommand(name="review", raw="@codex review"),
)
upsert_persistent_review_comment_id(
session,
repo=job.repo,
pr_number=job.pr_number,
head_sha=job.head_sha,
comment_id=289,
)
monkeypatch.setattr(
"gitea_codex_bot.workers.dispatcher.run_review_ephemeral",
lambda *_args, **_kwargs: {
"verdict": "has_issues",
"confidence": 0.7,
"summary": "runner error",
"findings": [],
},
)
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
self.posted_comment_id = 0
def get_pull_request(self, _repo: str, _pr_number: int):
return SimpleNamespace(is_fork=False)
def edit_issue_comment(self, _repo: str, _comment_id: int, _body: str) -> int:
request = httpx.Request("PATCH", "https://gitea.test/api/v1/repos/acme/repo/issues/comments/289")
response = httpx.Response(404, request=request, text='{"message":"not found"}')
raise httpx.HTTPStatusError("not found", request=request, response=response)
def post_issue_comment(self, _repo: str, _pr_number: int, _body: str) -> int:
self.posted_comment_id = 990
return self.posted_comment_id
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient)
settings = get_settings()
processed = process_one_job(settings)
assert processed is True
with session_factory() as session:
persisted_comment_id = get_persistent_review_comment_id(session, "acme/repo", 9)
assert persisted_comment_id == 990
stored_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
assert stored_job.status.value == "succeeded"

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import pytest
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.main import _validate_required_env
def test_validate_required_env_requires_api_key_in_api_key_mode(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "")
monkeypatch.setenv("CODEX_AUTH_MODE", "api_key")
get_settings.cache_clear()
settings = get_settings()
with pytest.raises(RuntimeError, match="OPENAI_API_KEY is required"):
_validate_required_env(settings)
def test_validate_required_env_allows_missing_key_in_chatgpt_mode(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "")
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
get_settings.cache_clear()
settings = get_settings()
_validate_required_env(settings)

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
import logging
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.main import _log_startup_auth_json_status, _log_startup_identity
def test_log_startup_identity_includes_bot_username(caplog) -> None:
settings = get_settings()
caplog.set_level(logging.INFO, logger="gitea_codex_bot.main")
_log_startup_identity(settings)
assert "Bot startup identity:" in caplog.text
assert "username=codex-bot" in caplog.text
def test_log_startup_auth_json_valid_when_configured(monkeypatch, tmp_path, caplog) -> None:
auth_file = tmp_path / "auth.json"
auth_file.write_text('{"auth_mode":"chatgpt"}', encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
monkeypatch.setenv("CODEX_AUTH_JSON_PATH", str(auth_file))
get_settings.cache_clear()
settings = get_settings()
caplog.set_level(logging.INFO, logger="gitea_codex_bot.main")
_log_startup_auth_json_status(settings)
assert "mode=chatgpt auth.json valid" in caplog.text
assert str(auth_file) in caplog.text

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from gitea_codex_bot.services.review_format import format_result_comment
def test_format_result_comment_uses_markdown_comment_verbatim_with_marker() -> None:
body = format_result_comment(
"abc1234",
{
"verdict": "correct",
"confidence": 0.9,
"summary": "ignored when markdown_comment exists",
"findings": [],
"markdown_comment": "## Codex Review\n\nAll good.\n\nNo issues found.",
},
)
assert body.startswith("<!-- codex-review:head_sha=abc1234 -->\n## Codex Review")
assert "All good.\n\nNo issues found." in body
def test_format_result_comment_replaces_existing_marker() -> None:
body = format_result_comment(
"def5678",
{
"markdown_comment": "<!-- codex-review:head_sha=old -->\n## Codex Review\n\nText.",
},
)
assert body.startswith("<!-- codex-review:head_sha=def5678 -->")
assert "old" not in body.splitlines()[0]

View File

@@ -79,3 +79,57 @@ def test_webhook_accepts_review_and_queues(monkeypatch) -> None:
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["status"] == "queued" assert response.json()["status"] == "queued"
assert posted_comments assert posted_comments
def test_webhook_logs_when_no_codex_review_command(monkeypatch) -> None:
messages: list[str] = []
def _log_info(message: str, *args, **_kwargs) -> None:
messages.append(message % args if args else message)
monkeypatch.setattr("gitea_codex_bot.main.logger.info", _log_info)
client = TestClient(app)
payload_obj = _payload("hello world", username="alice", comment_id=222)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-3",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "no codex command"
assert any("Webhook ignored: no @codex review command" in item for item in messages)
def test_webhook_logs_when_codex_command_is_not_review(monkeypatch) -> None:
messages: list[str] = []
def _log_info(message: str, *args, **_kwargs) -> None:
messages.append(message % args if args else message)
monkeypatch.setattr("gitea_codex_bot.main.logger.info", _log_info)
client = TestClient(app)
payload_obj = _payload("@codex explain", username="alice", comment_id=223)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-4",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert any("Webhook without @codex review command" in item for item in messages)