diff --git a/.env.example b/.env.example index 0dec048..9348323 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,20 @@ GITEA_BOT_USERNAME=codex-bot # Shared secret configured on the Gitea webhook. 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_PROJECT_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. # Example: space/gitea-codex,space/another-repo ALLOWED_REPOS=space/gitea-codex diff --git a/AGENTS.md b/AGENTS.md index 88c499d..329b644 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,16 +111,18 @@ Required: - `GITEA_TOKEN` - `GITEA_BOT_USERNAME` - `GITEA_WEBHOOK_SECRET` -- `OPENAI_API_KEY` - `ALLOWED_REPOS` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` Common optional: - `DATABASE_URL` (overrides DB parts) +- `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`) - `OPENAI_PROJECT_ID`, `OPENAI_ORG_ID` - `OPENAI_REVIEW_MODEL` - `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` - `REVIEW_RUNNER_IMAGE` - `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. 4. Run `pytest`. 5. Summarize impact, risks, and follow-ups in PR/commit notes. - diff --git a/Dockerfile b/Dockerfile index 4bf7766..0945a74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM python:3.12-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 @@ -15,4 +15,4 @@ COPY alembic /app/alembic RUN pip install --no-cache-dir . EXPOSE 8000 -CMD ["uvicorn", "gitea_codex_bot.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["uvicorn", "gitea_codex_bot.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 0ae1cbe..5bb4180 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,16 @@ Required: - `GITEA_TOKEN` - `GITEA_BOT_USERNAME` - `GITEA_WEBHOOK_SECRET` -- `OPENAI_API_KEY` - `ALLOWED_REPOS` - `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` Optional: +- `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`, optional when `CODEX_AUTH_MODE=chatgpt`) - `OPENAI_PROJECT_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) ## Local Run diff --git a/TODO.md b/TODO.md index cf20fd8..cdb8ff2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,20 +1,39 @@ # TODO - ## Open Items By Priority ### P0 (Critical) -- [ ] 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. -- [ ] Add integration test that proves runner container receives repo+PR context and executes review for the exact PR head SHA. +- [ ] `BUG`: True isolated runner flow: clone/fetch/checkout PR branch inside the ephemeral container itself, not on host before prompt generation. +- [ ] `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. +- [ ] `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) -- [ ] `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. -- [x] Make review model configurable via env (for example `OPENAI_REVIEW_MODEL`) instead of hardcoding `gpt-5`. -- [ ] Add retries/backoff for `codex exec` bootstrap (`npm install -g @openai/codex`) to reduce transient network/setup failures. -- [ ] Add end-to-end test path against live Gitea + MariaDB + docker runner (webhook -> queue -> runner -> PR comment update). +- [ ] `FEATURE`: Full control UI to update the bots settings. Password in env variable protected login page. No more env variables. +- [ ] `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 +- [ ] `BUG`: Container runner hardcodes `codex exec --json -m gpt-5`; use `OPENAI_REVIEW_MODEL` and `OPENAI_REASONING_EFFORT` consistently across runner paths. +- [ ] `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) -- [ ] Add explicit env docs for reverse-proxy deployment (`BASE_PUBLIC_URL`, trusted headers). -- [ ] Add per-repo command policy in `.codex-review.yml` for enabling/disabling commands (`review`, `fix`, `explain`, `rerun`). -- [ ] Add structured log redaction tests to ensure PAT/keys never appear in logs/comments. +### P2 (Nice to Have) +- [ ] `FEATURE`: Add a note line generated by the reviewer at the end of comments to show model tokens used and such. +- [ ] `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. +- [ ] `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. diff --git a/docker-compose.yml b/docker-compose.yml index 446db60..1394c48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,21 @@ services: - mariadb: - image: mariadb:11 - restart: unless-stopped - environment: - MARIADB_DATABASE: gitea_codex - MARIADB_USER: gitea_codex - MARIADB_PASSWORD: gitea_codex - MARIADB_ROOT_PASSWORD: rootpass - ports: - - "3306:3306" - volumes: - - ./db:/var/lib/mysql - healthcheck: - test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-uroot", "-prootpass"] - interval: 5s - timeout: 3s - retries: 20 + # mariadb: # Uncomment this block to run a local MariaDB instance. + # image: mariadb:11 + # restart: unless-stopped + # environment: + # MARIADB_DATABASE: gitea_codex + # MARIADB_USER: gitea_codex + # MARIADB_PASSWORD: gitea_codex + # MARIADB_ROOT_PASSWORD: rootpass + # ports: + # - "3306:3306" + # volumes: + # - ./db:/var/lib/mysql + # healthcheck: + # test: ["CMD", "mariadb-admin", "ping", "-h", "localhost", "-uroot", "-prootpass"] + # interval: 5s + # timeout: 3s + # retries: 20 bot: build: . @@ -26,5 +26,7 @@ services: - .env volumes: - ./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: - "8000:8000" diff --git a/src/gitea_codex_bot/config.py b/src/gitea_codex_bot/config.py index 250b6ad..b6d3481 100644 --- a/src/gitea_codex_bot/config.py +++ b/src/gitea_codex_bot/config.py @@ -15,11 +15,13 @@ class Settings(BaseSettings): gitea_bot_username: str = Field(alias="GITEA_BOT_USERNAME") 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_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_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") cooldown_seconds: int = Field(default=60, alias="COOLDOWN_SECONDS") diff --git a/src/gitea_codex_bot/main.py b/src/gitea_codex_bot/main.py index 815029a..d3e122b 100644 --- a/src/gitea_codex_bot/main.py +++ b/src/gitea_codex_bot/main.py @@ -1,8 +1,10 @@ from __future__ import annotations import asyncio +import json import logging from contextlib import asynccontextmanager +from pathlib import Path from typing import Any from fastapi import Depends, FastAPI, Header, HTTPException, Request, status @@ -26,10 +28,56 @@ logger = logging.getLogger(__name__) 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") +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: repository = payload.get("repository", {}) 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): settings = get_settings() _validate_required_env(settings) + _log_startup_identity(settings) + _log_startup_auth_json_status(settings) Base.metadata.create_all(bind=get_engine()) stop_event = asyncio.Event() @@ -119,7 +169,23 @@ async def gitea_webhook( comment_body = str(payload.get("comment", {}).get("body", "")).strip() parsed_command = parse_command(comment_body) 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"} + 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: return {"accepted": False, "reason": "repo not allowed"} diff --git a/src/gitea_codex_bot/services/review_format.py b/src/gitea_codex_bot/services/review_format.py index b207770..83e959d 100644 --- a/src/gitea_codex_bot/services/review_format.py +++ b/src/gitea_codex_bot/services/review_format.py @@ -3,6 +3,19 @@ from __future__ import annotations from gitea_codex_bot.types import ParsedCommand +def _inject_head_sha_marker(head_sha: str, body: str) -> str: + marker = f"" + stripped = body.strip() + if not stripped: + return marker + if stripped.startswith("\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": "\n## Codex Review\n\nText.", + }, + ) + assert body.startswith("") + assert "old" not in body.splitlines()[0] + diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 14cf819..c7a5916 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -79,3 +79,57 @@ def test_webhook_accepts_review_and_queues(monkeypatch) -> None: assert response.status_code == 200 assert response.json()["status"] == "queued" 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)