feat. rebuild service in Go
ci / test (pull_request) Successful in 1m54s
ci / publish (pull_request) Has been skipped

Rebuild the Gitea Codex review bot from the product contract with a Go HTTP service, durable SQL queue, typed Gitea client, isolated runner, and deployment updates.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Space-Banane
2026-07-12 21:51:01 +02:00
parent fdd3819ff8
commit f19b271642
74 changed files with 2623 additions and 4819 deletions
+5
View File
@@ -52,3 +52,8 @@ REVIEW_RUNNER_IMAGE=node:22-bookworm-slim
# Security: fork PRs are skipped unless explicitly enabled. # Security: fork PRs are skipped unless explicitly enabled.
ALLOW_UNTRUSTED_FORKS=false ALLOW_UNTRUSTED_FORKS=false
# Optional SQLite/MariaDB override. When unset, DB_* values compose a MariaDB DSN.
DATABASE_URL=sqlite://./gitea-codex.db
WEBHOOK_MAX_BYTES=2097152
PORT=8000
+16 -155
View File
@@ -24,74 +24,22 @@ jobs:
--health-interval 5s --health-interval 5s
--health-timeout 5s --health-timeout 5s
--health-retries 20 --health-retries 20
env:
GITEA_BASE_URL: https://gitea.reversed.dev
GITEA_TOKEN: test
GITEA_BOT_USERNAME: codex-bot
GITEA_WEBHOOK_SECRET: testsecret
OPENAI_API_KEY: test-openai
ALLOWED_REPOS: org/repo
COOLDOWN_SECONDS: 60
WEBHOOK_MODE: repo
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: gitea_codex
DB_USER: gitea_codex
DB_PASSWORD: gitea_codex
TEST_DATABASE_URL: mysql+pymysql://gitea_codex:gitea_codex@mariadb:3306/gitea_codex?charset=utf8mb4
WORKDIR: /tmp/work
MAX_DIFF_BYTES: 200000
MAX_REVIEW_MINUTES: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-go@v5
with: with:
python-version: '3.12' go-version: '1.25.x'
- name: Install deps - name: Check formatting
run: | shell: bash
python -m pip install --upgrade pip run: test -z "$(gofmt -l cmd internal)"
pip install -e .[dev] - name: Test
- name: Wait for MariaDB env:
run: | DATABASE_URL: sqlite://./ci.db
python - <<'PY' run: go test -race ./...
import os - name: Vet
import time run: go vet ./...
import pymysql - name: Build
run: CGO_ENABLED=0 go build -trimpath -o gitea-codex ./cmd/gitea-codex
host = os.environ["DB_HOST"]
port = int(os.environ["DB_PORT"])
user = os.environ["DB_USER"]
password = os.environ["DB_PASSWORD"]
database = os.environ["DB_NAME"]
for _ in range(60):
try:
conn = pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
connect_timeout=2,
read_timeout=2,
write_timeout=2,
)
conn.close()
print("MariaDB is ready")
raise SystemExit(0)
except Exception as exc:
print(f"Waiting for MariaDB: {exc}")
time.sleep(2)
print("MariaDB did not become ready in time")
raise SystemExit(1)
PY
- name: Run Alembic migrations
run: alembic upgrade head
- name: Run tests
run: pytest
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -103,19 +51,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Derive package metadata
id: meta
shell: bash
run: |
set -euo pipefail
owner="${IMAGE_NAME%%/*}"
repo="${IMAGE_NAME##*/}"
if [ -z "${owner}" ] || [ -z "${repo}" ]; then
echo "::error::Failed to derive owner/repo from IMAGE_NAME=${IMAGE_NAME}"
exit 1
fi
echo "owner=${owner}" >> "${GITHUB_OUTPUT}"
echo "repo=${repo}" >> "${GITHUB_OUTPUT}"
- name: Login to Gitea container registry - name: Login to Gitea container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -129,80 +64,6 @@ jobs:
CI_REF_NAME: ${{ gitea.ref_name }} CI_REF_NAME: ${{ gitea.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
IMAGE="${REGISTRY}/${IMAGE_NAME}" image="${REGISTRY}/${IMAGE_NAME}"
SHA_TAG="sha-${CI_SHA::12}" docker buildx build --push -t "${image}:sha-${CI_SHA::12}" -t "${image}:${CI_REF_NAME}" .
REF_TAG="${CI_REF_NAME}" if [ "${CI_REF_NAME}" = "main" ]; then docker buildx build --push -t "${image}:latest" .; fi
docker buildx build --push \
-t "${IMAGE}:${SHA_TAG}" \
-t "${IMAGE}:${REF_TAG}" \
.
if [ "${CI_REF_NAME}" = "main" ]; then
docker buildx build --push -t "${IMAGE}:latest" .
fi
- name: Publish image summary
shell: bash
env:
CI_SHA: ${{ gitea.sha }}
CI_REF_NAME: ${{ gitea.ref_name }}
run: |
set -euo pipefail
IMAGE="${REGISTRY}/${IMAGE_NAME}"
echo "Published image tags:" >> "${GITHUB_STEP_SUMMARY}"
echo "- ${IMAGE}:${CI_REF_NAME}" >> "${GITHUB_STEP_SUMMARY}"
echo "- ${IMAGE}:sha-${CI_SHA::12}" >> "${GITHUB_STEP_SUMMARY}"
if [ "${CI_REF_NAME}" = "main" ]; then
echo "- ${IMAGE}:latest" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Link package to repository
shell: bash
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
PACKAGE_OWNER: ${{ steps.meta.outputs.owner }}
PACKAGE_NAME: ${{ steps.meta.outputs.repo }}
REPO_NAME: ${{ steps.meta.outputs.repo }}
run: |
set -euo pipefail
token="${REGISTRY_PASSWORD:-${REGISTRY_TOKEN:-}}"
if [ -z "$token" ]; then
echo "::error::Registry token/password is empty. Set REGISTRY_PASSWORD or REGISTRY_TOKEN."
exit 1
fi
python3 - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
owner = os.environ["PACKAGE_OWNER"]
package = os.environ["PACKAGE_NAME"]
repo = os.environ["REPO_NAME"]
token = os.environ["REGISTRY_PASSWORD"] or os.environ["REGISTRY_TOKEN"]
base = "https://gitea.reversed.dev/api/v1"
headers = {
"Authorization": f"token {token}",
"Accept": "application/json",
}
latest_url = f"{base}/packages/{urllib.parse.quote(owner)}/container/{urllib.parse.quote(package)}/-/latest"
req = urllib.request.Request(latest_url, headers=headers)
with urllib.request.urlopen(req) as resp:
current = json.load(resp)
linked_repo = (current.get("repository") or {}).get("name")
if linked_repo == repo:
print(f"package already linked to {owner}/{repo}")
sys.exit(0)
link_url = f"{base}/packages/{urllib.parse.quote(owner)}/container/{urllib.parse.quote(package)}/-/link/{urllib.parse.quote(repo)}"
link_req = urllib.request.Request(link_url, data=b"", method="POST", headers=headers)
try:
with urllib.request.urlopen(link_req) as resp:
print(f"linked package to {owner}/{repo}, status={resp.status}")
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
print(f"link failed: status={exc.code} body={body}")
raise
PY
+2
View File
@@ -11,3 +11,5 @@ creds.txt
.tmp_mig.db .tmp_mig.db
.tmp_pytest .tmp_pytest
db/ db/
.tmp/
*.exe
+61 -153
View File
@@ -4,173 +4,81 @@ Guidance for autonomous/code-assist agents working in this repository.
## Mission ## Mission
Build and maintain a webhook-driven Gitea PR review bot that: Maintain a Go service that:
1. verifies webhook authenticity, 1. verifies Gitea webhook authenticity,
2. parses `@codex` commands, 2. parses `@codex` commands,
3. queues and executes review jobs, 3. queues and executes durable review jobs,
4. posts/updates PR comments with structured findings. 4. checks out the exact pull-request head SHA in an isolated runner,
5. posts structured findings back to Gitea.
Primary implementation lives under `src/gitea_codex_bot`. ## Tech stack
## Tech Stack - Go 1.25+
- `net/http`
- `database/sql`
- MariaDB in production; SQLite for local tests
- embedded Go migrations
- Docker-based review runner
- standard Go tests, race detector, vet, and formatting checks
- Python `>=3.11` ## Repository map
- FastAPI + Uvicorn
- SQLAlchemy + Alembic
- MariaDB (default), SQLite possible via `DATABASE_URL`
- Pytest for tests
- Docker-based review runner with host fallback
## Repository Map - `cmd/gitea-codex/main.go` — process startup, signals, migrations, HTTP server, worker.
- `internal/config` — environment loading and startup validation.
- `internal/domain` — typed commands, job/run states, review results, and policies.
- `internal/commands` — mention aliases and safe command lexer.
- `internal/webhook` — raw-body HMAC and typed event extraction.
- `internal/httpapi` — routes, webhook acknowledgements, and health endpoints.
- `internal/store` and `internal/store/sqlstore` — storage contracts, migrations, transactions, and queue claims.
- `internal/gitea` — typed Gitea REST client.
- `internal/review` — repository config, prompts, result validation, and comment formatting.
- `internal/runner` — Docker/Codex execution and cleanup.
- `internal/worker` — queue orchestration, retries, stale recovery, and non-review commands.
- `migrations` — logical compatibility baseline and migration notes.
- `tests under internal/*` — unit and fake-service integration tests.
- `src/gitea_codex_bot/main.py` ## Runtime flow
- FastAPI app, `/healthz`, `/webhook/gitea`, lifespan worker boot.
- `src/gitea_codex_bot/config.py`
- Environment-backed settings and DB URL composition.
- `src/gitea_codex_bot/db.py`
- Engine/session factory and dependency session provider.
- `src/gitea_codex_bot/models.py`
- ORM models: `WebhookEvent`, `ReviewJob`, `ReviewRun`, `BotComment`.
- `src/gitea_codex_bot/services/commands.py`
- `@codex` command parsing.
- `src/gitea_codex_bot/services/jobs.py`
- Event dedupe, queue transitions, cooldown logic.
- `src/gitea_codex_bot/services/security.py`
- HMAC signature verification and payload digest.
- `src/gitea_codex_bot/services/gitea.py`
- Gitea API client wrapper.
- `src/gitea_codex_bot/services/reviewer.py`
- PR checkout/diff collection/prompt build/OpenAI call/fallback/fix helpers.
- `src/gitea_codex_bot/services/review_format.py`
- Outbound comment formatting.
- `src/gitea_codex_bot/services/comments.py`
- Persistent summary comment id tracking.
- `src/gitea_codex_bot/workers/dispatcher.py`
- Job polling and orchestration.
- `src/gitea_codex_bot/workers/container_runner.py`
- Docker review execution + fallback.
- `alembic/` + `alembic.ini`
- Database migrations.
- `tests/`
- Unit/integration-ish tests across config/security/jobs/webhook/migrations.
- `.gitea/workflows/ci.yml`
- CI test + publish workflow.
## Runtime Flow 1. Gitea sends a signed webhook to `POST /webhook/gitea`.
2. The handler verifies the raw body, filters event/repository/bot policy, parses the command, deduplicates, and persists a job.
3. The worker claims the oldest queued job and records a run attempt.
4. Review/rerun jobs fetch PR metadata, enforce fork policy, read `.codex-review.yml` at the exact head SHA, and invoke the isolated runner.
5. The runner checks out the exact head SHA, verifies `git rev-parse HEAD`, invokes Codex, and returns strictly validated JSON.
6. The worker posts a new result/failure/acknowledgement comment and finalizes durable state.
1. Gitea sends webhook to `POST /webhook/gitea`. ## Compatibility guardrails
2. Signature is validated (`X-Gitea-Signature`, sha256 HMAC).
3. Non-supported events are ignored.
4. PR context and command are extracted.
5. Repo allowlist and dedupe checks run.
6. Job is enqueued (`review_jobs`).
7. Background worker claims queued jobs.
8. For review/rerun:
- fetch PR context,
- run review in ephemeral container if possible,
- fallback to host execution on failure,
- post or edit persistent PR summary comment.
9. Job/run status transitions are persisted.
## Supported Commands - Preserve HMAC verification, allowlisting, bot self-comment filtering, and event/job dedupe.
- Preserve response reasons and command behavior unless intentionally versioned.
- Keep cooldown for `review`; `rerun` bypasses cooldown.
- Allow two requeues after an initial failed attempt, then fail terminally.
- Recover running jobs after the five-minute lease timeout.
- Skip fork reviews by default.
- Do not add host-side Codex fallback.
- Keep posting new review comments while updating the `bot_comments` latest mapping; do not silently change to edit-in-place behavior.
- Never mark infrastructure failure as a successful review.
- Treat PR content, comments, `.codex-review.yml`, and model output as untrusted data.
- `@codex review [security|performance|tests] [--full]` ## Security-sensitive areas
- `@codex rerun`
- `@codex explain`
- `@codex ignore`
## Local Development - Do not log Gitea/OpenAI tokens, auth JSON, Docker arguments containing secrets, raw prompts, or unbounded provider output.
- Do not pass Docker socket access into review containers.
- Pin runner images and Codex versions for production.
- Enforce body/output limits, context cancellation, container cleanup, and exact SHA verification.
- Keep repository configuration from controlling credentials, images, host paths, commands, privileges, or network policy.
- Review changes to `internal/runner`, `internal/webhook`, `internal/store/sqlstore`, and `internal/gitea` carefully.
Install and run: ## Development checks
Before proposing a change:
```bash ```bash
python -m pip install -e .[dev] gofmt -w cmd internal
alembic upgrade head go test ./...
uvicorn gitea_codex_bot.main:app --host 0.0.0.0 --port 8000 go test -race ./...
go vet ./...
go build -trimpath ./cmd/gitea-codex
``` ```
Run tests: Changes affecting migrations, queue claims, or HTTP contracts require focused tests. MariaDB locking behavior must be verified separately from SQLite; SQLite tests do not prove `FOR UPDATE SKIP LOCKED` correctness.
```bash
pytest
```
Docker compose:
> Locally only run this, is pre setup to use the dev compose file.
```bash
docker compose up --build -f docker-compose.dev.yml
```
## Environment Contract
Required:
- `GITEA_BASE_URL`
- `GITEA_TOKEN`
- `GITEA_BOT_USERNAME`
- `GITEA_WEBHOOK_SECRET`
- `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`
- `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`
- `ALLOW_UNTRUSTED_FORKS`
## Database and Migrations
- SQLAlchemy models are authoritative for runtime behavior.
- Alembic migrations in `alembic/versions` must track schema changes.
- If model schema changes, add a migration and keep migration tests passing.
- CI runs `alembic upgrade head` before pytest.
## Testing Expectations
Before opening/merging changes:
1. run `pytest`,
2. if DB/model changes were made, ensure migration test still passes,
3. for webhook/queue logic, add or update focused tests in `tests/`.
Current tests rely on `tests/conftest.py` to inject default env and DB URL behavior.
## Change Guardrails
- Preserve webhook security checks and allowlist semantics.
- Preserve dedupe constraints (`delivery_id`, `repo+comment_id`, `repo+trigger_comment_id`).
- Keep bot self-comment ignore behavior.
- Keep persistent comment update behavior (avoid comment spam regressions).
- Be explicit when changing runner isolation/fallback behavior; this is a security-sensitive area.
- Keep response payloads and command parsing backward compatible unless intentionally versioned.
## Known Risks / Active Gaps
See `TODO.md` for priority backlog, especially:
- stronger isolated runner flow,
- stricter host fallback controls,
- end-to-end integration coverage.
Treat these as high-sensitivity areas when modifying worker/runner paths.
## Recommended Workflow for Agents
1. Read touched service + corresponding tests first.
2. Make minimal cohesive changes.
3. Add/update tests with behavior changes.
4. Run `pytest`.
5. Summarize impact, risks, and follow-ups in PR/commit notes.
## Commiting After Completion
If you are confident that your changes are ready to be committed, please follow the commit message format below:
```[type]. Short description (max 50 chars)```
Push after commiting. Ask the user once if you have permission to commit and from then on commit without asking.
+18 -16
View File
@@ -1,20 +1,22 @@
FROM python:3.12-slim-bookworm FROM golang:1.25-bookworm AS build
ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /src
PYTHONUNBUFFERED=1 COPY go.mod go.sum ./
RUN go mod download
RUN apt-get update && apt-get install -y --no-install-recommends git docker.io ca-certificates && rm -rf /var/lib/apt/lists/* COPY cmd ./cmd
COPY internal ./internal
COPY migrations ./migrations
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-codex ./cmd/gitea-codex
FROM debian:bookworm-slim
ENV LANG=C.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates docker.io \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=build /out/gitea-codex /app/gitea-codex
COPY pyproject.toml README.md /app/ # The bot needs access to the mounted Docker API socket to launch runners.
COPY src /app/src # Prefer a Docker socket proxy or a separate runner service in production.
COPY alembic.ini /app/ USER root
COPY alembic /app/alembic
COPY docker/entrypoint.sh /app/docker/entrypoint.sh
RUN pip install --no-cache-dir .
RUN chmod +x /app/docker/entrypoint.sh
EXPOSE 8000 EXPOSE 8000
CMD ["/app/docker/entrypoint.sh"] ENTRYPOINT ["/app/gitea-codex"]
+81 -61
View File
@@ -1,99 +1,119 @@
# Gitea Codex Review Bot # Gitea Codex Review Bot
Webhook-driven PR review bot for Gitea. A self-hosted, webhook-driven pull-request review bot for Gitea, rebuilt in Go. It validates signed Gitea comment webhooks, queues durable review jobs, runs Codex in an isolated container at the exact PR head SHA, and posts structured feedback back to the pull request.
## Features ## Features
- Handles `issue_comment` and `pull_request_comment` events. - HMAC-SHA256 verification of `X-Gitea-Signature` over the raw webhook body.
- Verifies `X-Gitea-Signature` HMAC (`sha256`). - `issue_comment` and `pull_request_comment` support.
- Triggers on `@codex ...`, `@<GITEA_BOT_USERNAME> ...`, plus optional custom aliases from `GITEA_BOT_MENTIONS`. - `@codex`, bot-username, and configured mention aliases.
- Ignores bot-authored comments. - Bot-loop prevention and exact `ALLOWED_REPOS` enforcement.
- Enforces strict repository allowlist (`ALLOWED_REPOS`). - Delivery/comment deduplication and PR review cooldowns.
- Deduplicates webhook deliveries/comments in DB. - Durable FIFO jobs with retry and stale-running-job recovery.
- Enforces PR cooldown for review requests. - MariaDB-compatible persistence with SQLite support for local tests.
- Uses MariaDB + SQLAlchemy + Alembic. - `.codex-review.yml` at the exact PR head SHA.
- Runs review jobs through ephemeral runner containers (with local fallback if Docker runtime is unavailable). - Fork review policy, disabled-repository acknowledgements, and non-review commands.
- Posts/updates one persistent PR summary comment. - Strict structured review result validation and bounded Markdown comments.
- Supports repository config via `.codex-review.yml`. - Isolated Docker runner with detached exact-SHA checkout verification.
- Health and operational endpoints with bounded error output.
## Endpoints The runner executes untrusted repository content. Secure the Docker API/socket, use least-privilege tokens, pin the runner image, restrict egress, and review the threat model before production use.
- `POST /webhook/gitea` ## Routes
- `GET /healthz`
## Webhook Setup Model - `GET /` — embedded service landing page.
- `GET /healthz` — liveness response: `{"status":"ok"}`.
- `GET /healthz/latest-job` — bounded latest-job metadata.
- `GET /healthz/latest-failure` — bounded latest-failure metadata.
- `POST /webhook/gitea` — signed Gitea webhook receiver.
This bot is designed for self-hosted deployment: ## Commands
1. You host this service yourself. ```text
2. A Gitea admin points webhook events to your hosted endpoint: @codex review
- `https://your-bot-domain/webhook/gitea` @codex review security
3. Gitea sends `issue_comment` and `pull_request_comment` events to that endpoint. @codex review performance --full
@codex review tests
@codex rerun
@codex explain
@codex ignore
@codex help
```
Webhook configuration is manual by design. Commands must begin the comment. `@codex fix` is intentionally unsupported. Unknown prefixed commands receive an explanatory comment; ordinary comments without a command are ignored.
Detailed setup instructions for both global and repository-only webhooks: ## Configuration
- [docs/webhook-setup.md](docs/webhook-setup.md) Copy `.env.example` to `.env`. Required values:
## Environment
Use `.env.example` as template.
Required:
- `GITEA_BASE_URL` - `GITEA_BASE_URL`
- `GITEA_TOKEN` - `GITEA_TOKEN`
- `GITEA_BOT_USERNAME` - `GITEA_BOT_USERNAME`
- `GITEA_WEBHOOK_SECRET` - `GITEA_WEBHOOK_SECRET`
- `ALLOWED_REPOS` - `ALLOWED_REPOS`
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` - `DATABASE_URL` or `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
- `OPENAI_API_KEY` when `CODEX_AUTH_MODE=api_key`
Optional: Important optional values include `OPENAI_REVIEW_MODEL`, `CODEX_AUTH_MODE`, `CODEX_AUTH_JSON_PATH`, `COOLDOWN_SECONDS`, `MAX_REVIEW_MINUTES`, `CONCURRENCY`, `REVIEW_RUNNER_IMAGE`, `ALLOW_UNTRUSTED_FORKS`, and `WEBHOOK_MAX_BYTES`.
- `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`, optional when `CODEX_AUTH_MODE=chatgpt`) For local development, use SQLite:
- `OPENAI_PROJECT_ID`
- `OPENAI_ORG_ID`
- `GITEA_BOT_MENTIONS` (comma-separated extra mention aliases, e.g. `@review-buddy,helper-bot`)
- `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 ```dotenv
DATABASE_URL=sqlite://./gitea-codex.db
```
For production, use MariaDB and a scoped Gitea token. The Go service applies schema migrations on startup.
## Local development
Requirements: Go 1.25+, Docker for real review execution, and Gitea credentials for integration use.
```bash ```bash
python -m pip install -e .[dev] go mod download
alembic upgrade head go test ./...
uvicorn gitea_codex_bot.main:app --host 0.0.0.0 --port 8000 go vet ./...
go build -trimpath -o gitea-codex ./cmd/gitea-codex
# With environment configured:
./gitea-codex
``` ```
The default listener is `:8000`; set `PORT` to change it. The unit/integration tests use a temporary SQLite database and fake Gitea HTTP server, so they do not require a live Gitea instance or Docker.
## Docker Compose ## Docker Compose
```bash ```bash
# Local dev image build cp .env.example .env
# Edit .env, then:
docker compose -f docker-compose.dev.yml up --build docker compose -f docker-compose.dev.yml up --build
# Published image
docker compose up
``` ```
## CI The bot container needs access to the host Docker API to launch isolated review containers. Mounting `/var/run/docker.sock` is a privileged deployment decision; use a dedicated runner service or hardened Docker host where possible.
The workflow in `.gitea/workflows/ci.yml`: ## Repository configuration
1. starts MariaDB service, A target repository may provide `.codex-review.yml`:
2. runs Alembic migrations + tests,
3. builds and pushes image tags to `gitea.reversed.dev/space/gitea-codex` on push.
Expected secrets for publish job: ```yaml
enabled: true
review:
default_mode: full
max_diff_bytes: 200000
include_tests: false
focus:
- correctness
- security
- maintainability
ignore:
- generated/
```
- `REGISTRY_USERNAME` The file is read from the PR head and is treated as untrusted data. It cannot choose commands, credentials, images, host paths, container privileges, or network policy. Tests are disabled by default; `tests` mode or `include_tests: true` explicitly permits the runner to execute project tests.
- `REGISTRY_PASSWORD`
## AI Note ## Webhooks and deployment
This project is a super big experiment i made because i wanted to have codex reviews in gitea. I hate using Github and i will never willingly without good reasons use their copilot bs.
This project was made WITH codex and is meant to be used WITH codex as a review agent.
If you are as rich as Peter Steinberg and get a free OpenAI API Key, feel free to use it for this bot.
## Contributing Webhook provisioning is manual in Gitea. See [docs/webhook-setup.md](docs/webhook-setup.md) for global and repository-only configuration. CI runs Go formatting, race-enabled tests, vet, and a static build before publishing an image.
Contributions are welcome! Please open issues or submit pull requests for bug fixes, improvements, or new features.
## Design notes
This project exists to provide Codex-based review workflows for self-hosted Gitea installations without requiring GitHub. The implementation is intentionally provider- and runner-bound at the outer edge, while the domain, queue, storage, and HTTP layers remain independently testable.
-38
View File
@@ -1,38 +0,0 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
sqlalchemy.url = mysql+pymysql://user:pass@localhost/db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
-1
View File
@@ -1 +0,0 @@
# Alembic migrations
-46
View File
@@ -1,46 +0,0 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.db import Base
from gitea_codex_bot import models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.sqlalchemy_url)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-123
View File
@@ -1,123 +0,0 @@
"""initial schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-05-22 19:00:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"webhook_events",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("delivery_id", sa.String(length=255), nullable=True),
sa.Column("event_name", sa.String(length=128), nullable=False),
sa.Column("repo", sa.String(length=255), nullable=False),
sa.Column("comment_id", sa.Integer(), nullable=True),
sa.Column("payload_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("delivery_id", name="uq_webhook_events_delivery_id"),
sa.UniqueConstraint("repo", "comment_id", name="uq_webhook_events_repo_comment"),
)
job_status_enum = sa.Enum("queued", "running", "succeeded", "failed", "skipped", name="jobstatus")
job_status_enum.create(op.get_bind(), checkfirst=True)
op.create_table(
"review_jobs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("repo", sa.String(length=255), nullable=False),
sa.Column("pr_number", sa.Integer(), nullable=False),
sa.Column("head_sha", sa.String(length=64), nullable=False),
sa.Column("trigger_comment_id", sa.Integer(), nullable=False),
sa.Column("command", sa.String(length=64), nullable=False),
sa.Column("command_args", sa.Text(), nullable=True),
sa.Column("requested_by", sa.String(length=255), nullable=False),
sa.Column("status", job_status_enum, nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("result_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("repo", "trigger_comment_id", name="uq_review_jobs_repo_trigger_comment"),
)
op.create_index("ix_review_jobs_lookup", "review_jobs", ["repo", "pr_number", "head_sha", "status", "created_at"], unique=False)
run_status_enum = sa.Enum("running", "succeeded", "failed", "skipped", name="runstatus")
run_status_enum.create(op.get_bind(), checkfirst=True)
op.create_table(
"review_runs",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("job_id", sa.Integer(), nullable=False),
sa.Column("status", run_status_enum, nullable=False),
sa.Column("runner_container_id", sa.String(length=128), nullable=True),
sa.Column("result_json", sa.JSON(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["job_id"], ["review_jobs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_review_runs_job_status", "review_runs", ["job_id", "status"], unique=False)
op.create_table(
"bot_comments",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("repo", sa.String(length=255), nullable=False),
sa.Column("pr_number", sa.Integer(), nullable=False),
sa.Column("head_sha", sa.String(length=64), nullable=False),
sa.Column("gitea_comment_id", sa.Integer(), nullable=False),
sa.Column("marker", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("repo", "pr_number", "marker", name="uq_bot_comments_marker"),
)
op.create_index("ix_bot_comments_repo_pr", "bot_comments", ["repo", "pr_number"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
def has_table(table_name: str) -> bool:
return table_name in inspector.get_table_names()
def has_index(table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
if has_table("bot_comments"):
if has_index("bot_comments", "ix_bot_comments_repo_pr"):
op.drop_index("ix_bot_comments_repo_pr", table_name="bot_comments")
op.drop_table("bot_comments")
if has_table("review_runs"):
if has_index("review_runs", "ix_review_runs_job_status"):
op.drop_index("ix_review_runs_job_status", table_name="review_runs")
op.drop_table("review_runs")
if has_table("review_jobs"):
if has_index("review_jobs", "ix_review_jobs_lookup"):
op.drop_index("ix_review_jobs_lookup", table_name="review_jobs")
op.drop_table("review_jobs")
if has_table("webhook_events"):
op.drop_table("webhook_events")
sa.Enum(name="runstatus").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="jobstatus").drop(op.get_bind(), checkfirst=True)
@@ -1,33 +0,0 @@
"""add trigger comment body to review jobs
Revision ID: 0002_trigger_comment_body
Revises: 0001_initial
Create Date: 2026-05-22 20:15:00
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0002_trigger_comment_body"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("review_jobs", sa.Column("trigger_comment_body", sa.Text(), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
table_names = inspector.get_table_names()
if "review_jobs" not in table_names:
return
column_names = {column["name"] for column in inspector.get_columns("review_jobs")}
if "trigger_comment_body" in column_names:
op.drop_column("review_jobs", "trigger_comment_body")
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/gitea"
"gitea-codex-bot/internal/httpapi"
"gitea-codex-bot/internal/runner"
"gitea-codex-bot/internal/store/sqlstore"
"gitea-codex-bot/internal/worker"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
settings, err := config.Load()
if err != nil {
logger.Error("invalid configuration", "error", err)
os.Exit(1)
}
st, err := sqlstore.Open(settings)
if err != nil {
logger.Error("open database", "error", err)
os.Exit(1)
}
defer st.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err = st.Migrate(ctx)
cancel()
if err != nil {
logger.Error("migrate database", "error", err)
os.Exit(1)
}
client := gitea.NewClient(settings)
reviewRunner := runner.NewDockerRunner(settings)
w := worker.New(settings, st, client, reviewRunner, logger)
runCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := w.Run(runCtx); err != nil && !errors.Is(err, context.Canceled) {
logger.Error("worker stopped", "error", err)
}
}()
server := &http.Server{Addr: listenAddress(envString("PORT", "8000")), Handler: httpapi.New(settings, st, client, logger), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second}
logger.Info("server starting", "addr", server.Addr, "gitea_base_url", settings.GiteaBaseURL, "auth_mode", settings.CodexAuthMode, "concurrency", settings.Concurrency)
go func() {
<-runCtx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = server.Shutdown(shutdown)
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}
func envString(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
func listenAddress(port string) string {
if strings.Contains(port, ":") {
return port
}
return ":" + port
}
+22 -19
View File
@@ -1,31 +1,34 @@
services: services:
# mariadb: # Uncomment this block to run a local MariaDB instance. mariadb:
# image: mariadb:11 image: mariadb:11
# 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: .
depends_on:
mariadb:
condition: service_healthy
env_file: env_file:
- .env - .env
environment: environment:
DATABASE_URL: gitea_codex:gitea_codex@tcp(mariadb:3306)/gitea_codex?parseTime=true
CODEX_AUTH_JSON_PATH: /root/.codex/auth.json CODEX_AUTH_JSON_PATH: /root/.codex/auth.json
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 - ~/.codex/auth.json:/root/.codex/auth.json:ro
- //var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
ports: ports:
- "8000:8000" - "8000:8000"
+5 -4
View File
@@ -1,5 +1,5 @@
services: services:
mariadb: # Uncomment this block to run a local MariaDB instance. mariadb:
image: mariadb:11 image: mariadb:11
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -18,7 +18,7 @@ services:
retries: 20 retries: 20
bot: bot:
image: gitea.reversed.dev/space/gitea-codex:latest build: .
depends_on: depends_on:
mariadb: mariadb:
condition: service_healthy condition: service_healthy
@@ -26,7 +26,8 @@ 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 - ~/.codex/auth.json:/root/.codex/auth.json:ro
- //var/run/docker.sock:/var/run/docker.sock # The bot needs the host Docker API to launch isolated review containers.
- /var/run/docker.sock:/var/run/docker.sock
ports: ports:
- "8000:8000" - "8000:8000"
-44
View File
@@ -1,44 +0,0 @@
#!/bin/sh
set -eu
echo "Checking migration baseline..."
python - <<'PY'
from sqlalchemy import create_engine, inspect, text
from gitea_codex_bot.config import get_settings
settings = get_settings()
engine = create_engine(settings.sqlalchemy_url)
with engine.connect() as conn:
inspector = inspect(conn)
tables = set(inspector.get_table_names())
has_alembic_version = "alembic_version" in tables
has_review_jobs = "review_jobs" in tables
has_webhook_events = "webhook_events" in tables
stamped_revision = None
if has_alembic_version:
row = conn.execute(text("SELECT version_num FROM alembic_version LIMIT 1")).fetchone()
if row and row[0]:
stamped_revision = row[0]
if (not has_alembic_version or not stamped_revision) and (has_review_jobs or has_webhook_events):
revision = "0001_initial"
if has_review_jobs:
columns = {c["name"] for c in inspector.get_columns("review_jobs")}
if "trigger_comment_body" in columns:
revision = "0002_trigger_comment_body"
conn.execute(text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL, PRIMARY KEY (version_num))"))
conn.execute(text("DELETE FROM alembic_version"))
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:revision)"), {"revision": revision})
conn.commit()
print(f"Stamped legacy database at revision {revision}")
PY
echo "Running database migrations..."
alembic upgrade head
echo "Starting API server..."
exec uvicorn gitea_codex_bot.main:app --host 0.0.0.0 --port 8000
+15
View File
@@ -0,0 +1,15 @@
# Go rebuild notes
The service was rebuilt from the product behavior rather than ported module-for-module from the previous Python implementation. The Go code keeps the durable table names and public webhook/health contracts while separating domain decisions from HTTP, SQL, Gitea, Docker, and Codex concerns.
## Deliberate differences
- There is no host-side review fallback. Runner failure is a failed attempt and is retried according to queue policy.
- Review result output is validated strictly and bounded before persistence or posting.
- The landing and 404 pages are embedded and do not load Tailwind from a third-party CDN.
- The current tested append-comment behavior is retained: each completed review posts a new comment and updates the latest `bot_comments` mapping.
- Docker execution is treated as a privileged deployment boundary. The bundled image runs the bot as root because direct Docker-socket access otherwise fails; production should replace this with a socket proxy or separate runner service, pinned images, resource limits, and least-privilege credentials.
## Migration compatibility
The Go startup migrator creates the logical `webhook_events`, `review_jobs`, `review_runs`, and `bot_comments` schema and preserves `trigger_comment_body`. Existing deployments should be backed up before switching binaries. The first Go release does not drop old columns or tables.
+36 -66
View File
@@ -1,83 +1,53 @@
# Webhook Setup (Global and Repository-Only) # Webhook setup
This bot accepts Gitea webhook events at: The Go service accepts signed Gitea webhooks at:
- `POST /webhook/gitea` - `POST /webhook/gitea`
It only processes these event types: It processes only `issue_comment` and `pull_request_comment` events. The handler verifies the exact raw request body with HMAC-SHA256 using `GITEA_WEBHOOK_SECRET`, enforces `ALLOWED_REPOS`, ignores bot-authored comments, and queues recognized `@codex` commands.
- `issue_comment` ## Configure Gitea
- `pull_request_comment`
It verifies `X-Gitea-Signature` using `GITEA_WEBHOOK_SECRET` (HMAC-SHA256). 1. Deploy the service at a URL reachable by Gitea, for example `https://bot.example.com/webhook/gitea`.
2. Set the same random secret in Gitea and `GITEA_WEBHOOK_SECRET`.
3. Configure either an instance/global webhook or one repository webhook per target repository.
4. Use JSON content type and enable only Issue comment and Pull request comment events.
5. Add every allowed `owner/repository` to `ALLOWED_REPOS`.
6. Test the webhook from Gitea, then check `GET /healthz`.
## Prerequisites `WEBHOOK_MODE=global` or `WEBHOOK_MODE=repo` is a deployment label; webhook provisioning remains an administrator responsibility.
1. Bot is reachable from Gitea (example: `https://bot.example.com/webhook/gitea`). ## Environment
2. `GITEA_WEBHOOK_SECRET` is set in your bot `.env`.
3. `ALLOWED_REPOS` includes repositories you want to allow (example: `team/repo-a,team/repo-b`).
## Option A: Global Webhook (single webhook, recommended) Required values are `GITEA_BASE_URL`, `GITEA_TOKEN`, `GITEA_BOT_USERNAME`, `GITEA_WEBHOOK_SECRET`, `ALLOWED_REPOS`, and either `DATABASE_URL` or the `DB_*` values. API-key Codex mode also requires `OPENAI_API_KEY`.
Use this when you want one webhook configuration for many repositories. The recommended local development database is SQLite:
1. In Gitea, open site administration webhook settings (instance-level/global webhooks).
2. Add a new webhook of type `Gitea` (JSON payload).
3. Set:
- `Payload URL`: `https://bot.example.com/webhook/gitea`
- `HTTP Method`: `POST`
- `Secret`: same value as `GITEA_WEBHOOK_SECRET`
- `Content Type`: `application/json`
4. Enable only these events:
- `Issue comment`
- `Pull request comment`
5. Save and use the webhook test/ping action.
6. Set `WEBHOOK_MODE=global` in bot env (informational, for deployment clarity).
Notes:
- The bot still enforces `ALLOWED_REPOS`; non-allowlisted repos are ignored.
- A global webhook is usually easiest to operate at scale.
## Option B: Repository-Only Webhook (per repository)
Use this when you want explicit repo-by-repo control.
1. Open the repository in Gitea.
2. Go to repository `Settings` -> `Webhooks`.
3. Add a new `Gitea` webhook.
4. Set:
- `Payload URL`: `https://bot.example.com/webhook/gitea`
- `HTTP Method`: `POST`
- `Secret`: same value as `GITEA_WEBHOOK_SECRET`
- `Content Type`: `application/json`
5. Enable only:
- `Issue comment`
- `Pull request comment`
6. Save and test.
7. Repeat for each repository.
8. Set `WEBHOOK_MODE=repo` in bot env.
Important:
- This bot has one configured secret (`GITEA_WEBHOOK_SECRET`) per bot instance.
- If multiple repo webhooks use different secrets, signature verification will fail for repos not matching the configured secret.
## Minimal `.env` snippet
```dotenv ```dotenv
GITEA_WEBHOOK_SECRET=replace-with-random-secret DATABASE_URL=sqlite://./gitea-codex.db
ALLOWED_REPOS=team/repo-a,team/repo-b
WEBHOOK_MODE=global
``` ```
For repo-only mode, use `WEBHOOK_MODE=repo`. Production deployments should use MariaDB and a token with only the Gitea permissions needed to read pull requests/files and create comments. Keep the runner image pinned to a reviewed digest and treat review execution as untrusted code execution.
## Validation Checklist ## Command examples
1. `GET /healthz` returns `{"status":"ok"}`. ```text
2. Webhook deliveries from Gitea return HTTP `200` (or bot returns accepted/ignored JSON, not `401`). @codex review
3. `401 invalid signature` means webhook secret mismatch. @codex review security
4. `{"accepted": false, "reason": "repo not allowed"}` means update `ALLOWED_REPOS`. @codex review performance --full
5. A PR comment with `@codex review` on an allowlisted repo queues a job. @codex review tests
@codex rerun
@codex explain
@codex ignore
@codex help
```
Commands must begin the comment. Inline mentions in ordinary discussion text are intentionally ignored for compatibility. `@codex fix` is not supported.
## Security notes
- Invalid signatures return HTTP 401.
- Fork pull requests are skipped unless `ALLOW_UNTRUSTED_FORKS=true`.
- The bot launches review containers through the host Docker API; secure the Docker socket and runner host accordingly.
- The review container receives credentials required by the configured Codex/Gitea workflow. Use least-privilege credentials, restrict network access, and do not use unpinned images in production.
- Health detail endpoints expose bounded job metadata. Put them behind an internal network or reverse-proxy authentication if repository names and review errors are sensitive.
+23
View File
@@ -0,0 +1,23 @@
module gitea-codex-bot
go 1.25
require (
github.com/go-sql-driver/mysql v1.9.3
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.39.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+57
View File
@@ -0,0 +1,57 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+135
View File
@@ -0,0 +1,135 @@
package commands
import (
"errors"
"strings"
"unicode"
"gitea-codex-bot/internal/domain"
)
var helpAliases = map[string]bool{"-h": true, "--help": true, "help": true}
var supported = map[string]bool{"review": true, "rerun": true, "explain": true, "ignore": true}
func DetectPrefixedCommand(body string, aliases map[string]bool) string {
parts, ok := prefixParts(body, aliases)
if !ok || len(parts) == 0 {
return ""
}
return strings.ToLower(parts[0])
}
func Parse(body string, aliases map[string]bool) (domain.ParsedCommand, bool) {
stripped := strings.TrimSpace(body)
parts, ok := prefixParts(stripped, aliases)
if !ok || len(parts) == 0 {
return domain.ParsedCommand{}, false
}
name := strings.ToLower(parts[0])
rawArgs := append([]string(nil), parts[1:]...)
if helpAliases[name] {
return domain.ParsedCommand{Name: "help", Alias: aliasOf(stripped), Raw: stripped, Arguments: rawArgs}, true
}
if !supported[name] {
return domain.ParsedCommand{}, false
}
cmd := domain.ParsedCommand{Name: name, Alias: aliasOf(stripped), Raw: stripped, Mode: "summary", Arguments: rawArgs}
if name == "review" {
for _, token := range rawArgs {
switch strings.ToLower(token) {
case "--full":
cmd.Full = true
cmd.Mode = "full"
cmd.ModeExplicit = true
case "security", "performance", "tests":
if !cmd.ModeExplicit || cmd.Mode == "full" {
cmd.Mode = strings.ToLower(token)
cmd.ModeExplicit = true
}
}
}
}
return cmd, true
}
func prefixParts(body string, aliases map[string]bool) ([]string, bool) {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "@") {
return nil, false
}
space := strings.IndexFunc(trimmed, unicode.IsSpace)
if space <= 1 {
return nil, false
}
alias := strings.ToLower(strings.TrimPrefix(trimmed[1:space], "@"))
if !aliases[alias] {
return nil, false
}
remainder := strings.TrimSpace(trimmed[space:])
tokens, err := lex(remainder)
if err != nil {
return nil, false
}
return tokens, true
}
func aliasOf(body string) string {
end := strings.IndexFunc(body[1:], unicode.IsSpace)
if end < 0 {
return body
}
return body[:end+1]
}
func lex(input string) ([]string, error) {
var out []string
var b strings.Builder
quoted := rune(0)
escaped := false
started := false
flush := func() {
if started {
out = append(out, b.String())
b.Reset()
started = false
}
}
for _, r := range input {
if escaped {
b.WriteRune(r)
started = true
escaped = false
continue
}
if r == '\\' {
escaped = true
started = true
continue
}
if quoted != 0 {
if r == quoted {
quoted = 0
} else {
b.WriteRune(r)
}
started = true
continue
}
if r == '\'' || r == '"' {
quoted = r
started = true
continue
}
if unicode.IsSpace(r) {
flush()
continue
}
b.WriteRune(r)
started = true
}
if escaped || quoted != 0 {
return nil, errors.New("unterminated command quote")
}
flush()
return out, nil
}
+42
View File
@@ -0,0 +1,42 @@
package commands
import "testing"
func TestParseSupportedCommandsAndAliases(t *testing.T) {
aliases := map[string]bool{"codex": true, "codex-bot": true}
cases := []struct{ body, name, mode string }{
{"@codex review", "review", "summary"},
{"@codex review security --full", "review", "full"},
{"@codex review tests", "review", "tests"},
{"@codex-bot explain", "explain", "summary"},
{"@codex --help", "help", ""},
}
for _, tc := range cases {
got, ok := Parse(tc.body, aliases)
if !ok || got.Name != tc.name || got.Mode != tc.mode {
t.Fatalf("Parse(%q) = %#v, %v", tc.body, got, ok)
}
}
}
func TestParsePreservesRawAndQuotedArguments(t *testing.T) {
got, ok := Parse("@codex review \"focus auth\" --full\nsecond line", map[string]bool{"codex": true})
if !ok || got.Raw != "@codex review \"focus auth\" --full\nsecond line" {
t.Fatalf("raw command was not preserved: %#v", got)
}
if len(got.Arguments) != 4 || got.Arguments[0] != "focus auth" || got.Arguments[1] != "--full" || got.Arguments[2] != "second" || got.Arguments[3] != "line" {
t.Fatalf("arguments were not lexed: %#v", got.Arguments)
}
}
func TestUnsupportedAndInlineCommands(t *testing.T) {
if _, ok := Parse("@codex fix", map[string]bool{"codex": true}); ok {
t.Fatal("fix must remain unsupported")
}
if DetectPrefixedCommand("Please run @codex review", map[string]bool{"codex": true}) != "" {
t.Fatal("inline mention must not trigger")
}
if DetectPrefixedCommand("@codex deploy now", map[string]bool{"codex": true}) != "deploy" {
t.Fatal("unsupported prefix was not detected")
}
}
+151
View File
@@ -0,0 +1,151 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
type Settings struct {
GiteaBaseURL string
GiteaToken string
GiteaBotUsername string
GiteaBotMentions string
GiteaWebhookSecret string
OpenAIAPIKey string
OpenAIProjectID string
OpenAIOrgID string
OpenAIReviewModel string
CodexAuthMode string
CodexAuthJSONPath string
AllowedRepos []string
CooldownSeconds int
WebhookMode string
DatabaseURL string
DBHost string
DBPort int
DBName string
DBUser string
DBPassword string
Workdir string
MaxDiffBytes int
MaxReviewMinutes int
Concurrency int
RunnerImage string
AllowUntrustedForks bool
WebhookMaxBytes int64
}
func Load() (Settings, error) {
s := Settings{
GiteaBaseURL: strings.TrimRight(os.Getenv("GITEA_BASE_URL"), "/"), GiteaToken: os.Getenv("GITEA_TOKEN"),
GiteaBotUsername: os.Getenv("GITEA_BOT_USERNAME"), GiteaBotMentions: os.Getenv("GITEA_BOT_MENTIONS"), GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"),
OpenAIAPIKey: os.Getenv("OPENAI_API_KEY"), OpenAIProjectID: os.Getenv("OPENAI_PROJECT_ID"), OpenAIOrgID: os.Getenv("OPENAI_ORG_ID"),
OpenAIReviewModel: envString("OPENAI_REVIEW_MODEL", "gpt-5.3-codex"), CodexAuthMode: envString("CODEX_AUTH_MODE", "api_key"), CodexAuthJSONPath: envString("CODEX_AUTH_JSON_PATH", "~/.codex/auth.json"),
AllowedRepos: splitCSV(os.Getenv("ALLOWED_REPOS")), WebhookMode: envString("WEBHOOK_MODE", "repo"), DatabaseURL: os.Getenv("DATABASE_URL"),
DBHost: os.Getenv("DB_HOST"), DBName: os.Getenv("DB_NAME"), DBUser: os.Getenv("DB_USER"), DBPassword: os.Getenv("DB_PASSWORD"),
Workdir: envString("WORKDIR", "/var/lib/gitea-codex/worktrees"), RunnerImage: envString("REVIEW_RUNNER_IMAGE", "node:22-bookworm-slim"),
}
var err error
if s.DBPort, err = envInt("DB_PORT", 3306); err != nil {
return Settings{}, err
}
if s.CooldownSeconds, err = envInt("COOLDOWN_SECONDS", 60); err != nil {
return Settings{}, err
}
if s.MaxDiffBytes, err = envInt("MAX_DIFF_BYTES", 200000); err != nil {
return Settings{}, err
}
if s.MaxReviewMinutes, err = envInt("MAX_REVIEW_MINUTES", 10); err != nil {
return Settings{}, err
}
if s.Concurrency, err = envInt("CONCURRENCY", 1); err != nil {
return Settings{}, err
}
maxBytes, err := envInt("WEBHOOK_MAX_BYTES", 2*1024*1024)
if err != nil {
return Settings{}, err
}
s.WebhookMaxBytes = int64(maxBytes)
s.AllowUntrustedForks, err = envBool("ALLOW_UNTRUSTED_FORKS", false)
if err != nil {
return Settings{}, err
}
if err := s.Validate(); err != nil {
return Settings{}, err
}
return s, nil
}
func (s Settings) Validate() error {
for name, value := range map[string]string{"GITEA_BASE_URL": s.GiteaBaseURL, "GITEA_TOKEN": s.GiteaToken, "GITEA_BOT_USERNAME": s.GiteaBotUsername, "GITEA_WEBHOOK_SECRET": s.GiteaWebhookSecret} {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", name)
}
}
if len(s.AllowedRepos) == 0 {
return errors.New("ALLOWED_REPOS is required")
}
if s.CodexAuthMode != "api_key" && s.CodexAuthMode != "chatgpt" {
return errors.New("CODEX_AUTH_MODE must be api_key or chatgpt")
}
if s.CodexAuthMode == "api_key" && strings.TrimSpace(s.OpenAIAPIKey) == "" {
return errors.New("OPENAI_API_KEY is required")
}
if s.CooldownSeconds < 0 || s.MaxDiffBytes <= 0 || s.MaxReviewMinutes <= 0 || s.Concurrency <= 0 || s.WebhookMaxBytes <= 0 {
return errors.New("numeric configuration values are invalid")
}
return nil
}
func (s Settings) RepoAllowed(repo string) bool {
for _, allowed := range s.AllowedRepos {
if allowed == repo {
return true
}
}
return false
}
func (s Settings) Aliases() map[string]bool {
out := map[string]bool{"codex": true, strings.ToLower(strings.TrimPrefix(strings.TrimSpace(s.GiteaBotUsername), "@")): true}
for _, x := range splitCSV(s.GiteaBotMentions) {
out[strings.ToLower(strings.TrimPrefix(x, "@"))] = true
}
return out
}
func envString(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
func envInt(name string, fallback int) (int, error) {
v := envString(name, strconv.Itoa(fallback))
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("%s: %w", name, err)
}
return n, nil
}
func envBool(name string, fallback bool) (bool, error) {
v := os.Getenv(name)
if v == "" {
return fallback, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("%s: %w", name, err)
}
return b, nil
}
func splitCSV(value string) []string {
var out []string
for _, item := range strings.Split(value, ",") {
if v := strings.TrimSpace(item); v != "" {
out = append(out, v)
}
}
return out
}
+180
View File
@@ -0,0 +1,180 @@
package domain
import (
"context"
"errors"
"strings"
"time"
)
type JobStatus string
const (
JobQueued JobStatus = "queued"
JobRunning JobStatus = "running"
JobSucceeded JobStatus = "succeeded"
JobFailed JobStatus = "failed"
JobSkipped JobStatus = "skipped"
)
type RunStatus string
const (
RunRunning RunStatus = "running"
RunSucceeded RunStatus = "succeeded"
RunFailed RunStatus = "failed"
RunSkipped RunStatus = "skipped"
)
type ParsedCommand struct {
Name string
Alias string
Raw string
Mode string
ModeExplicit bool
Full bool
Arguments []string
}
func (c ParsedCommand) IsReview() bool { return c.Name == "review" || c.Name == "rerun" }
type RepoReviewConfig struct {
Configured bool
Enabled bool
DefaultMode string
MaxDiffBytes int
IncludeTests bool
Focus []string
Ignore []string
}
func DefaultRepoReviewConfig() RepoReviewConfig {
return RepoReviewConfig{Configured: true, Enabled: true, DefaultMode: "full", MaxDiffBytes: 200000, Focus: []string{"correctness", "security", "maintainability"}}
}
type PullRequestContext struct {
Repo string
PRNumber int
BaseRef string
BaseSHA string
HeadRef string
HeadSHA string
CloneURL string
BaseCloneURL string
HeadCloneURL string
HTMLURL string
IsFork bool
}
type Finding struct {
Severity string `json:"severity"`
File string `json:"file"`
LineStart int `json:"line_start"`
LineEnd int `json:"line_end"`
Title string `json:"title"`
Body string `json:"body"`
Suggestion *string `json:"suggestion"`
}
type Usage struct {
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
}
type ReviewMeta struct {
Source string `json:"source,omitempty"`
Model string `json:"model,omitempty"`
Usage Usage `json:"usage,omitempty"`
}
type ReviewResult struct {
Verdict string `json:"verdict"`
Confidence float64 `json:"confidence"`
Summary string `json:"summary"`
MarkdownComment string `json:"markdown_comment"`
Findings []Finding `json:"findings"`
Meta *ReviewMeta `json:"_meta,omitempty"`
}
func (r ReviewResult) Validate() error {
if r.Verdict != "correct" && r.Verdict != "has_issues" {
return errors.New("invalid verdict")
}
if r.Confidence < 0 || r.Confidence > 1 {
return errors.New("confidence must be between 0 and 1")
}
if len(r.Summary) > 20000 || len(r.MarkdownComment) > 50000 {
return errors.New("review text is too long")
}
if len(r.Findings) > 200 {
return errors.New("too many findings")
}
for i, f := range r.Findings {
if f.Severity != "low" && f.Severity != "medium" && f.Severity != "high" && f.Severity != "critical" {
return errors.New("invalid finding severity")
}
if f.File == "" || len(f.File) > 1000 || f.LineStart < 1 || f.LineEnd < f.LineStart {
return errors.New("invalid finding location")
}
if len(f.Title) > 2000 || len(f.Body) > 20000 {
return errors.New("finding text is too long")
}
if f.Suggestion != nil && len(*f.Suggestion) > 20000 {
return errors.New("suggestion is too long")
}
_ = i
}
return nil
}
type WebhookEvent struct {
EventName string
DeliveryID string
Repo string
PRNumber int
HeadSHA string
CommentID int64
CommentBody string
Sender string
PayloadSHA256 string
}
type Job struct {
ID int64
Repo string
PRNumber int
HeadSHA string
TriggerCommentID int64
TriggerCommentBody string
Command string
CommandArgs string
RequestedBy string
Status JobStatus
LastError string
ResultJSON []byte
CreatedAt time.Time
UpdatedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
}
type ReviewRun struct {
ID int64
JobID int64
Status RunStatus
ContainerID string
ResultJSON []byte
Error string
StartedAt time.Time
FinishedAt *time.Time
}
func (j Job) AttemptNumber() int { return 1 }
// ReviewRunner isolates all process/container execution from orchestration.
type ReviewRunner interface {
Run(context.Context, PullRequestContext, ParsedCommand, RepoReviewConfig) (ReviewResult, error)
}
func NormalizeRepo(repo string) string { return strings.TrimSpace(repo) }
+196
View File
@@ -0,0 +1,196 @@
package gitea
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
)
type Client struct {
baseURL, token string
httpClient *http.Client
}
func NewClient(settings config.Settings) *Client {
return &Client{baseURL: strings.TrimRight(settings.GiteaBaseURL, "/"), token: settings.GiteaToken, httpClient: &http.Client{Timeout: 20 * time.Second}}
}
func (c *Client) request(ctx context.Context, method, path string, body any) ([]byte, int, error) {
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, 0, err
}
reader = strings.NewReader(string(data))
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "token "+c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, resp.StatusCode, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return data, resp.StatusCode, fmt.Errorf("gitea returned HTTP %d", resp.StatusCode)
}
return data, resp.StatusCode, nil
}
func splitRepo(repo string) (string, string, error) {
owner, name, ok := strings.Cut(repo, "/")
if !ok || owner == "" || name == "" {
return "", "", fmt.Errorf("invalid repository %q", repo)
}
return url.PathEscape(owner), url.PathEscape(name), nil
}
func (c *Client) GetPullRequest(ctx context.Context, repo string, number int) (domain.PullRequestContext, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return domain.PullRequestContext{}, err
}
data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, name, number), nil)
if err != nil {
return domain.PullRequestContext{}, err
}
var p struct {
HTMLURL string `json:"html_url"`
Base struct {
Ref, SHA string
Repo struct {
CloneURL string `json:"clone_url"`
FullName string `json:"full_name"`
} `json:"repo"`
} `json:"base"`
Head struct {
Ref, SHA string
Repo struct {
CloneURL string `json:"clone_url"`
FullName string `json:"full_name"`
} `json:"repo"`
} `json:"head"`
}
if err := json.Unmarshal(data, &p); err != nil {
return domain.PullRequestContext{}, err
}
if p.Base.SHA == "" || p.Head.SHA == "" || p.Base.Repo.CloneURL == "" || p.Head.Repo.CloneURL == "" {
return domain.PullRequestContext{}, fmt.Errorf("gitea pull request response missing required fields")
}
return domain.PullRequestContext{Repo: repo, PRNumber: number, BaseRef: p.Base.Ref, BaseSHA: p.Base.SHA, HeadRef: p.Head.Ref, HeadSHA: p.Head.SHA, CloneURL: p.Head.Repo.CloneURL, BaseCloneURL: p.Base.Repo.CloneURL, HeadCloneURL: p.Head.Repo.CloneURL, HTMLURL: p.HTMLURL, IsFork: p.Base.Repo.FullName != p.Head.Repo.FullName}, nil
}
func (c *Client) GetFileContent(ctx context.Context, repo, path, ref string) (string, bool, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return "", false, err
}
data, status, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?ref=%s", owner, name, url.PathEscape(path), url.QueryEscape(ref)), nil)
if err != nil && status == http.StatusNotFound {
return "", false, nil
}
if err != nil {
return "", false, err
}
var p struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := json.Unmarshal(data, &p); err != nil {
return "", false, err
}
if p.Encoding != "base64" || p.Content == "" {
return "", false, nil
}
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(p.Content, "\n", ""))
if err != nil {
return "", false, err
}
return string(decoded), true, nil
}
func (c *Client) PostIssueComment(ctx context.Context, repo string, number int, body string) (int64, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return 0, err
}
data, _, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, name, number), map[string]string{"body": body})
if err != nil {
return 0, err
}
var p struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(data, &p); err != nil {
return 0, err
}
return p.ID, nil
}
func (c *Client) EditIssueComment(ctx context.Context, repo string, commentID int64, body string) (int64, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return 0, err
}
data, _, err := c.request(ctx, http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", owner, name, commentID), map[string]string{"body": body})
if err != nil {
return 0, err
}
var p struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(data, &p); err != nil {
return 0, err
}
return p.ID, nil
}
func (c *Client) GetIssueComments(ctx context.Context, repo string, number int) ([]map[string]any, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, name, number), nil)
if err != nil {
return nil, err
}
var p []map[string]any
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return p, nil
}
func (c *Client) GetIssueComment(ctx context.Context, repo string, commentID int64) (map[string]any, error) {
owner, name, err := splitRepo(repo)
if err != nil {
return nil, err
}
data, _, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", owner, name, commentID), nil)
if err != nil {
return nil, err
}
var p map[string]any
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
return p, nil
}
var _ = strconv.Itoa
+217
View File
@@ -0,0 +1,217 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"gitea-codex-bot/internal/commands"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
"gitea-codex-bot/internal/gitea"
"gitea-codex-bot/internal/review"
"gitea-codex-bot/internal/store"
"gitea-codex-bot/internal/webhook"
)
type Server struct {
settings config.Settings
store store.Store
gitea *gitea.Client
logger *slog.Logger
mux *http.ServeMux
}
func New(settings config.Settings, st store.Store, client *gitea.Client, logger *slog.Logger) *Server {
s := &Server{settings: settings, store: st, gitea: client, logger: logger, mux: http.NewServeMux()}
s.routes()
return s
}
func (s *Server) Handler() http.Handler { return s }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/healthz" || r.URL.Path == "/healthz/latest-job" || r.URL.Path == "/healthz/latest-failure" || r.URL.Path == "/webhook/gitea" {
s.mux.ServeHTTP(w, r)
return
}
if strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html") {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, browser404)
return
}
writeJSON(w, http.StatusNotFound, map[string]string{"detail": "Not Found"})
}
func (s *Server) routes() {
s.mux.HandleFunc("/", s.root)
s.mux.HandleFunc("/healthz", s.health)
s.mux.HandleFunc("/healthz/latest-job", s.latestJob)
s.mux.HandleFunc("/healthz/latest-failure", s.latestFailure)
s.mux.HandleFunc("/webhook/gitea", s.webhook)
}
func (s *Server) root(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, landingPage)
}
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]any{"status": "ok"})
}
func (s *Server) latestFailure(w http.ResponseWriter, r *http.Request) {
job, err := s.store.LatestFailedJob(r.Context())
if err != nil {
writeError(w, err)
return
}
if job == nil {
writeJSON(w, 200, map[string]any{"status": "ok", "has_failed_job": false})
return
}
writeJSON(w, 200, map[string]any{"status": "ok", "has_failed_job": true, "job_id": job.ID, "repo": job.Repo, "pr_number": job.PRNumber, "command": job.Command, "head_sha": job.HeadSHA, "error": limit(job.LastError, 2000), "failed_at": timeString(job.FinishedAt)})
}
func (s *Server) latestJob(w http.ResponseWriter, r *http.Request) {
job, err := s.store.LatestJob(r.Context())
if err != nil {
writeError(w, err)
return
}
if job == nil {
writeJSON(w, 200, map[string]any{"status": "ok", "has_job": false})
return
}
summary := ""
if len(job.ResultJSON) > 0 {
var result domain.ReviewResult
if json.Unmarshal(job.ResultJSON, &result) == nil {
summary = limit(result.Summary, 2000)
}
}
writeJSON(w, 200, map[string]any{"status": "ok", "has_job": true, "job_id": job.ID, "repo": job.Repo, "pr_number": job.PRNumber, "command": job.Command, "head_sha": job.HeadSHA, "job_status": job.Status, "error": limit(job.LastError, 2000), "result_summary": summary, "created_at": job.CreatedAt, "started_at": job.StartedAt, "finished_at": job.FinishedAt})
}
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.settings.WebhookMaxBytes))
if err != nil {
writeJSON(w, 413, map[string]any{"detail": "request body too large"})
return
}
if !webhook.VerifySignature(body, s.settings.GiteaWebhookSecret, r.Header.Get("X-Gitea-Signature")) {
writeJSON(w, 401, map[string]any{"detail": "invalid signature"})
return
}
eventName := strings.TrimSpace(r.Header.Get("X-Gitea-Event"))
if eventName != "issue_comment" && eventName != "pull_request_comment" {
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "event ignored"})
return
}
event, err := webhook.ParseEvent(eventName, r.Header.Get("X-Gitea-Delivery"), body)
if err != nil {
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "not a pull request comment"})
return
}
if strings.EqualFold(event.Sender, s.settings.GiteaBotUsername) {
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "bot comment ignored"})
return
}
if !s.settings.RepoAllowed(event.Repo) {
s.logger.Info("Webhook ignored: repo not in ALLOWED_REPOS", "repo", event.Repo, "pr", event.PRNumber, "comment_id", event.CommentID)
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "repo not allowed"})
return
}
cmd, ok := commands.Parse(event.CommentBody, s.settings.Aliases())
if !ok {
attempted := commands.DetectPrefixedCommand(event.CommentBody, s.settings.Aliases())
if attempted != "" {
message := fmt.Sprintf("⚠️ Command `@codex %s` is not supported. Try `@codex -h`.", attempted)
if attempted == "fix" {
message = "⚠️ `@codex fix` is no longer supported on this bot."
}
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, message)
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "unsupported command", "command": attempted})
return
}
writeJSON(w, 200, map[string]any{"accepted": false, "reason": "no codex command"})
return
}
inserted, err := s.store.InsertWebhookEvent(r.Context(), event)
if err != nil {
writeError(w, err)
return
}
if !inserted {
writeJSON(w, 200, map[string]any{"accepted": true, "reason": "duplicate event"})
return
}
if cmd.IsReview() {
pr, prErr := s.gitea.GetPullRequest(r.Context(), event.Repo, event.PRNumber)
if prErr == nil {
event.HeadSHA = pr.HeadSHA
}
cfg := review.MissingRepoConfig()
if prErr == nil {
if text, configured, cfgErr := s.gitea.GetFileContent(r.Context(), event.Repo, ".codex-review.yml", event.HeadSHA); cfgErr == nil && configured {
cfg, err = review.ParseRepoConfig(text)
if err != nil {
writeError(w, err)
return
}
}
}
if !cfg.Enabled {
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, review.FormatDisabledAck())
writeJSON(w, 200, map[string]any{"accepted": true, "reason": "review disabled by repo config"})
return
}
if cmd.Name != "rerun" {
remaining, err := s.store.CooldownRemaining(r.Context(), event.Repo, event.PRNumber, time.Duration(s.settings.CooldownSeconds)*time.Second)
if err != nil {
writeError(w, err)
return
}
if remaining > 0 {
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, review.FormatCooldownAck(remaining))
writeJSON(w, 200, map[string]any{"accepted": true, "reason": "cooldown active", "cooldown_seconds_remaining": remaining})
return
}
}
}
job, err := s.store.EnqueueJob(r.Context(), event, cmd)
if err != nil {
writeError(w, err)
return
}
if cmd.IsReview() {
_, _ = s.gitea.PostIssueComment(r.Context(), event.Repo, event.PRNumber, review.FormatQueueAck(event.HeadSHA))
}
writeJSON(w, 200, map[string]any{"accepted": true, "job_id": job.ID, "status": "queued"})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, err error) {
if errors.Is(err, context.Canceled) {
writeJSON(w, 499, map[string]any{"detail": "request canceled"})
return
}
writeJSON(w, 500, map[string]any{"detail": "internal server error"})
}
func timeString(value *time.Time) any {
if value == nil {
return nil
}
return value.Format(time.RFC3339Nano)
}
func limit(value string, n int) string {
if len(value) <= n {
return value
}
return value[:n]
}
const landingPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gitea Codex Review Bot</title><style>body{margin:0;background:#020617;color:#e2e8f0;font:16px system-ui,sans-serif}main{max-width:760px;margin:12vh auto;padding:32px}section{border:1px solid #1e293b;border-radius:18px;background:#0f172a;padding:32px;box-shadow:0 20px 60px #0008}h1{color:#fff}a{color:#67e8f9}</style></head><body><main><section><p>WEBHOOK SERVICE</p><h1>Gitea Codex Review Bot</h1><p>This service validates signed Gitea webhook events, queues pull-request review jobs, and posts structured feedback.</p><p><a href="/healthz">Health</a> · <a href="/healthz/latest-job">Latest job</a> · <a href="/healthz/latest-failure">Latest failure</a></p><p>Webhook: <code>POST /webhook/gitea</code></p></section></main></body></html>`
const browser404 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Not Found</title><style>body{background:#020617;color:#e2e8f0;font:16px system-ui,sans-serif;text-align:center;padding:12vh 20px}section{max-width:600px;margin:auto;border:1px solid #1e293b;border-radius:18px;padding:32px;background:#0f172a}a{color:#67e8f9}</style></head><body><section><p>Error 404</p><h1>Page not found</h1><p>This service exposes a small set of routes.</p><a href="/">Go home</a></section></body></html>`
+65
View File
@@ -0,0 +1,65 @@
package httpapi
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/gitea"
"gitea-codex-bot/internal/store/sqlstore"
)
func TestWebhookQueuesSignedReview(t *testing.T) {
giteaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/pulls/"):
_, _ = w.Write([]byte(`{"base":{"ref":"main","sha":"base","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"head":{"ref":"feature","sha":"head","repo":{"clone_url":"https://gitea.test/acme/repo.git","full_name":"acme/repo"}},"html_url":"https://gitea.test"}`))
case r.Method == http.MethodPost:
_, _ = w.Write([]byte(`{"id":100}`))
case strings.Contains(r.URL.Path, "contents"):
http.NotFound(w, r)
default:
http.NotFound(w, r)
}
}))
defer giteaServer.Close()
settings := config.Settings{GiteaBaseURL: giteaServer.URL, GiteaToken: "token", GiteaBotUsername: "codex-bot", GiteaWebhookSecret: "secret", AllowedRepos: []string{"acme/repo"}, DatabaseURL: "sqlite://" + t.TempDir() + "/test.db", WebhookMaxBytes: 1 << 20, CooldownSeconds: 60}
st, err := sqlstore.Open(settings)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
server := New(settings, st, gitea.NewClient(settings), nilLogger())
payload := []byte(`{"repository":{"full_name":"acme/repo"},"sender":{"username":"alice"},"comment":{"id":11,"body":"@codex review security"},"issue":{"number":9,"pull_request":{"url":"x"}},"pull_request":{"head":{"sha":"head"}}}`)
mac := hmac.New(sha256.New, []byte("secret"))
_, _ = mac.Write(payload)
req := httptest.NewRequest(http.MethodPost, "/webhook/gitea", strings.NewReader(string(payload)))
req.Header.Set("X-Gitea-Event", "issue_comment")
req.Header.Set("X-Gitea-Signature", hex.EncodeToString(mac.Sum(nil)))
rec := httptest.NewRecorder()
server.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status %d", rec.Code)
}
var response map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response["status"] != "queued" {
t.Fatalf("response %#v", response)
}
}
func nilLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
+179
View File
@@ -0,0 +1,179 @@
package review
import (
"encoding/json"
"fmt"
"strings"
"gopkg.in/yaml.v3"
"gitea-codex-bot/internal/domain"
)
type rawConfig struct {
Enabled *bool `yaml:"enabled"`
Review struct {
DefaultMode string `yaml:"default_mode"`
MaxDiffBytes int `yaml:"max_diff_bytes"`
IncludeTests bool `yaml:"include_tests"`
Focus []string `yaml:"focus"`
} `yaml:"review"`
Ignore []string `yaml:"ignore"`
}
func ParseRepoConfig(text string) (domain.RepoReviewConfig, error) {
cfg := domain.DefaultRepoReviewConfig()
cfg.Configured = true
var raw rawConfig
if err := yaml.Unmarshal([]byte(text), &raw); err != nil {
return domain.RepoReviewConfig{}, err
}
if raw.Enabled != nil {
cfg.Enabled = *raw.Enabled
}
if raw.Review.DefaultMode != "" {
cfg.DefaultMode = strings.ToLower(strings.TrimSpace(raw.Review.DefaultMode))
}
if raw.Review.MaxDiffBytes > 0 {
cfg.MaxDiffBytes = raw.Review.MaxDiffBytes
}
cfg.IncludeTests = raw.Review.IncludeTests
if raw.Review.Focus != nil {
cfg.Focus = boundedStrings(raw.Review.Focus, 32, 200)
}
cfg.Ignore = boundedStrings(raw.Ignore, 128, 500)
return cfg, nil
}
func MissingRepoConfig() domain.RepoReviewConfig {
cfg := domain.DefaultRepoReviewConfig()
cfg.Configured = false
return cfg
}
func boundedStrings(input []string, maxItems, maxLen int) []string {
out := make([]string, 0, min(len(input), maxItems))
for _, item := range input {
item = strings.TrimSpace(item)
if item != "" && len(item) <= maxLen {
out = append(out, item)
}
if len(out) == maxItems {
break
}
}
return out
}
func ResolveMode(cmd *domain.ParsedCommand, cfg domain.RepoReviewConfig) {
if cmd.Name == "review" && !cmd.ModeExplicit {
if cfg.DefaultMode == "full" || cfg.DefaultMode == "summary" || cfg.DefaultMode == "security" || cfg.DefaultMode == "performance" || cfg.DefaultMode == "tests" {
cmd.Mode = cfg.DefaultMode
} else {
cmd.Mode = "summary"
}
}
}
func BuildPrompt(cmd domain.ParsedCommand, cfg domain.RepoReviewConfig, pr domain.PullRequestContext) string {
raw := strings.TrimSpace(cmd.Raw)
intent := raw
if at := strings.IndexAny(raw, " \t\r\n"); at >= 0 {
intent = strings.TrimSpace(raw[at:])
}
if at := strings.IndexAny(intent, " \t\r\n"); at >= 0 {
intent = strings.TrimSpace(intent[at:])
}
if intent == "" {
intent = "review this pull request and report introduced issues."
}
focus := strings.Join(cfg.Focus, ", ")
if focus == "" {
focus = "correctness, security, maintainability"
}
ignore := strings.Join(cfg.Ignore, ", ")
if ignore == "" {
ignore = "(none)"
}
tests := "Do not run tests, benchmarks, or other executables. Review changes statically unless explicitly asked."
if cmd.Mode == "tests" || cfg.IncludeTests {
tests = "Tests may be executed for this run because tests mode/include_tests is explicitly enabled."
}
return fmt.Sprintf("review: %s\nReview only issues introduced by this PR.\nCompare exactly these commits: base `%s` ... head `%s`.\nUse local git data from this checkout; do not review unrelated history.\nRequested mode: %s.\nFocus areas: %s.\nIgnore patterns: %s.\nInclude tests setting: %t.\n%s\nFull review requested: %t.\nReturn strict JSON matching the provided output schema.", intent, pr.BaseSHA, pr.HeadSHA, cmd.Mode, focus, ignore, cfg.IncludeTests, tests, cmd.Full)
}
func ValidateResult(result domain.ReviewResult) error { return result.Validate() }
func DecodeResult(data []byte) (domain.ReviewResult, error) {
var result domain.ReviewResult
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(&result); err != nil {
return domain.ReviewResult{}, err
}
if err := result.Validate(); err != nil {
return domain.ReviewResult{}, err
}
return result, nil
}
func FormatQueueAck(sha string) string {
return fmt.Sprintf("👀 Codex review queued for commit `%s`.", first(sha, 7))
}
func FormatCooldownAck(seconds int) string {
return fmt.Sprintf("⏳ Cooldown active. Please wait %ds before requesting another review on this PR.", seconds)
}
func FormatDisabledAck() string {
return "🚫 Review is disabled by `.codex-review.yml` for this repository."
}
func FormatUnsupportedAck(name string) string {
return fmt.Sprintf("⚠️ Command `@codex %s` is not enabled on this repository.", name)
}
func FormatResultComment(sha string, result domain.ReviewResult, configured bool) string {
marker := fmt.Sprintf("<!-- codex-review:head_sha=%s -->", sha)
body := strings.TrimSpace(result.MarkdownComment)
if body == "" {
body = fmt.Sprintf("## Codex Review\n\nVerdict: `%s`\nConfidence: `%.2f`\n\n%s", result.Verdict, result.Confidence, result.Summary)
if len(result.Findings) == 0 {
body += "\n\nNo blocking issues found."
} else {
body += "\n\nFindings:"
for i, f := range result.Findings {
suggestion := "n/a"
if f.Suggestion != nil && *f.Suggestion != "" {
suggestion = *f.Suggestion
}
body += fmt.Sprintf("\n\n%d. `%s:%d-%d` (%s)\n %s\n %s\n Suggestion: %s", i+1, f.File, f.LineStart, f.LineEnd, f.Severity, f.Title, f.Body, suggestion)
}
}
} else if len(result.Findings) > 0 {
body += "\n\n---\n\n### Structured Findings\n\n"
for i, f := range result.Findings {
body += fmt.Sprintf("%d. `%s:%d-%d` (%s)\n %s\n %s\n\n", i+1, f.File, f.LineStart, f.LineEnd, f.Severity, f.Title, f.Body)
}
}
if result.Meta != nil && result.Meta.Model != "" {
body += fmt.Sprintf("\n_Note: model `%s`, input `%d`, output `%d`, total `%d` tokens used._", result.Meta.Model, result.Meta.Usage.InputTokens, result.Meta.Usage.OutputTokens, result.Meta.Usage.TotalTokens)
}
if !configured {
body += "\n\n> ️.codex-review.yml is not configured"
}
if strings.HasPrefix(body, "<!-- codex-review:head_sha=") {
lines := strings.SplitN(body, "\n", 2)
if len(lines) == 2 {
body = marker + "\n" + lines[1]
}
} else {
body = marker + "\n" + body
}
return body
}
func FailureComment(sha, errText string) string {
return fmt.Sprintf("⚠️ Codex review run failed after queueing.\n\n- Commit: `%s`\n- Error: `%s`\n\nPlease rerun `@codex rerun` after checking worker logs.", first(sha, 7), first(strings.Join(strings.Fields(errText), " "), 500))
}
func first(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+45
View File
@@ -0,0 +1,45 @@
package review
import (
"testing"
"gitea-codex-bot/internal/domain"
)
func TestParseConfigAndResolveMode(t *testing.T) {
cfg, err := ParseRepoConfig("enabled: true\nreview:\n default_mode: security\n include_tests: false\n focus: [correctness, security]\nignore: [generated/]\n")
if err != nil {
t.Fatal(err)
}
if !cfg.Configured || cfg.DefaultMode != "security" || len(cfg.Focus) != 2 {
t.Fatalf("unexpected config: %#v", cfg)
}
cmd := domain.ParsedCommand{Name: "review", Raw: "@codex review", Mode: "summary"}
ResolveMode(&cmd, cfg)
if cmd.Mode != "security" {
t.Fatalf("mode was not resolved: %q", cmd.Mode)
}
}
func TestResultValidationAndFormatting(t *testing.T) {
suggestion := "Use a checked conversion."
result := domain.ReviewResult{Verdict: "has_issues", Confidence: .9, Summary: "Found one issue", Findings: []domain.Finding{{Severity: "high", File: "internal/x.go", LineStart: 4, LineEnd: 5, Title: "Unsafe conversion", Body: "The conversion can overflow.", Suggestion: &suggestion}}}
if err := ValidateResult(result); err != nil {
t.Fatal(err)
}
body := FormatResultComment("abcdef123", result, false)
if len(body) == 0 || body[:len("<!-- codex-review:head_sha=abcdef123 -->")] != "<!-- codex-review:head_sha=abcdef123 -->" {
t.Fatalf("missing SHA marker: %s", body)
}
if !contains(body, "not configured") || !contains(body, "Unsafe conversion") {
t.Fatalf("missing formatted details: %s", body)
}
}
func contains(s, needle string) bool {
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
return true
}
}
return false
}
+150
View File
@@ -0,0 +1,150 @@
package runner
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
"gitea-codex-bot/internal/review"
)
const startMarker = "__CODEX_REVIEW_RESULT_BEGIN__"
const endMarker = "__CODEX_REVIEW_RESULT_END__"
const maxRunnerOutput = 4 << 20
type DockerRunner struct{ settings config.Settings }
func NewDockerRunner(settings config.Settings) *DockerRunner {
return &DockerRunner{settings: settings}
}
func (r *DockerRunner) Run(parent context.Context, pr domain.PullRequestContext, cmd domain.ParsedCommand, cfg domain.RepoReviewConfig) (domain.ReviewResult, error) {
ctx, cancel := context.WithTimeout(parent, time.Duration(r.settings.MaxReviewMinutes)*time.Minute)
defer cancel()
nonce := fmt.Sprintf("%d", time.Now().UnixNano())
begin, end := startMarker+"_"+nonce, endMarker+"_"+nonce
prompt := review.BuildPrompt(cmd, cfg, pr)
script := r.script(pr, prompt, begin, end)
name := "codex-review-" + nonce
args := []string{"run", "--rm", "-i", "--name", name, "--cap-drop=ALL", "--security-opt", "no-new-privileges", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=512m", "--tmpfs", "/work:rw,nosuid,size=1g", "-e", "CODEX_DISABLE_TELEMETRY=1"}
if r.settings.CodexAuthMode == "chatgpt" {
args = append(args, "-e", "CODEX_AUTH_JSON_B64")
} else {
args = append(args, "-e", "OPENAI_API_KEY")
}
args = append(args, "-e", "GITEA_TOKEN", "-e", "GITEA_GIT_USERNAME", r.settings.RunnerImage, "bash", "-lc", script)
cmdExec := exec.CommandContext(ctx, "docker", args...)
cmdExec.Env = append(os.Environ(), "OPENAI_API_KEY="+r.settings.OpenAIAPIKey, "GITEA_TOKEN="+r.settings.GiteaToken, "GITEA_GIT_USERNAME="+r.settings.GiteaBotUsername)
if r.settings.CodexAuthMode == "chatgpt" {
data, err := readAuthJSON(r.settings.CodexAuthJSONPath)
if err != nil {
return domain.ReviewResult{}, err
}
cmdExec.Env = append(cmdExec.Env, "CODEX_AUTH_JSON_B64="+base64.StdEncoding.EncodeToString(data))
}
var output limitedBuffer
output.limit = maxRunnerOutput
cmdExec.Stdout = &output
cmdExec.Stderr = &output
if err := cmdExec.Run(); err != nil {
_ = exec.CommandContext(context.Background(), "docker", "rm", "-f", name).Run()
if ctx.Err() != nil {
return domain.ReviewResult{}, fmt.Errorf("review runner timeout: %w", ctx.Err())
}
return domain.ReviewResult{}, fmt.Errorf("review runner failed: %w", err)
}
text := output.String()
start := strings.Index(text, begin)
endPos := strings.LastIndex(text, end)
if start < 0 || endPos <= start {
return domain.ReviewResult{}, fmt.Errorf("review runner returned no result artifact")
}
artifact := strings.TrimSpace(text[start+len(begin) : endPos])
var result domain.ReviewResult
if err := json.Unmarshal([]byte(artifact), &result); err != nil {
return domain.ReviewResult{}, err
}
if err := review.ValidateResult(result); err != nil {
return domain.ReviewResult{}, err
}
return result, nil
}
func readAuthJSON(rawPath string) ([]byte, error) {
path := os.ExpandEnv(rawPath)
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return nil, err
}
if !json.Valid(data) {
return nil, fmt.Errorf("CODEX_AUTH_JSON_PATH is not valid JSON")
}
return data, nil
}
func (r *DockerRunner) script(pr domain.PullRequestContext, prompt, begin, end string) string {
auth := base64.StdEncoding.EncodeToString([]byte(r.settings.GiteaBotUsername + ":" + r.settings.GiteaToken))
schema := `{"type":"object","additionalProperties":false,"required":["verdict","confidence","summary","findings","markdown_comment"],"properties":{"verdict":{"type":"string","enum":["correct","has_issues"]},"confidence":{"type":"number"},"summary":{"type":"string"},"markdown_comment":{"type":"string"},"findings":{"type":"array"}}}`
quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" }
baseRemote := "origin"
remoteSetup := ""
if pr.BaseCloneURL != "" && pr.BaseCloneURL != pr.CloneURL {
baseRemote = "upstream"
remoteSetup = "git remote add upstream " + quote(pr.BaseCloneURL)
}
authSetup := ""
if r.settings.CodexAuthMode == "chatgpt" {
authSetup = "mkdir -p /root/.codex; printf '%s' \"$CODEX_AUTH_JSON_B64\" | base64 -d > /root/.codex/auth.json; chmod 600 /root/.codex/auth.json"
}
fetchHead := "git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags origin " + quote(pr.HeadRef) + " || git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags origin " + quote(pr.HeadSHA)
fetchBase := "git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags " + baseRemote + " " + quote(pr.BaseRef) + " || git -c http.extraHeader=" + quote("Authorization: Basic "+auth) + " fetch --no-tags " + baseRemote + " " + quote(pr.BaseSHA)
steps := []string{"set -eu", "printf '%s' " + quote(schema) + " > /tmp/schema.json"}
if authSetup != "" {
steps = append(steps, authSetup)
}
steps = append(steps, "git -c http.extraHeader="+quote("Authorization: Basic "+auth)+" clone --no-tags --depth 80 "+quote(pr.CloneURL)+" /work/repo", "cd /work/repo")
if remoteSetup != "" {
steps = append(steps, remoteSetup)
}
steps = append(steps, fetchHead, fetchBase, "git checkout --detach "+quote(pr.HeadSHA), "test \"$(git rev-parse HEAD)\" = "+quote(pr.HeadSHA), "unset GITEA_TOKEN", "codex exec --sandbox danger-full-access --json --output-schema /tmp/schema.json -o /tmp/result.json -m "+quote(r.settings.OpenAIReviewModel)+" "+quote(prompt), "test -s /tmp/result.json", "printf '%s\\n' "+quote(begin), "cat /tmp/result.json", "printf '%s\\n' "+quote(end))
return strings.Join(steps, "; ")
}
type limitedBuffer struct {
buffer bytes.Buffer
limit int
truncated bool
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remaining := b.limit - b.buffer.Len()
if remaining <= 0 {
b.truncated = true
return len(p), nil
}
if len(p) > remaining {
_, _ = b.buffer.Write(p[:remaining])
b.truncated = true
return len(p), nil
}
return b.buffer.Write(p)
}
func (b *limitedBuffer) String() string { return b.buffer.String() }
var _ domain.ReviewRunner = (*DockerRunner)(nil)
+47
View File
@@ -0,0 +1,47 @@
package runner
import (
"strings"
"testing"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
)
func samplePR() domain.PullRequestContext {
return domain.PullRequestContext{Repo: "acme/repo", PRNumber: 1, BaseRef: "main", BaseSHA: strings.Repeat("b", 40), HeadRef: "feature", HeadSHA: strings.Repeat("a", 40), CloneURL: "https://gitea.test/acme/repo.git", BaseCloneURL: "https://gitea.test/acme/repo.git", HTMLURL: "https://gitea.test/pulls/1"}
}
func TestScriptChecksExactHeadAndBase(t *testing.T) {
r := NewDockerRunner(config.Settings{GiteaBotUsername: "bot", GiteaToken: "token", OpenAIReviewModel: "model", CodexAuthMode: "api_key"})
script := r.script(samplePR(), "review prompt", "BEGIN_nonce", "END_nonce")
for _, fragment := range []string{"git checkout --detach", "git rev-parse HEAD", "fetch --no-tags origin 'feature'", "fetch --no-tags origin '" + strings.Repeat("b", 40) + "'", "BEGIN_nonce", "END_nonce", "--output-schema", "-o /tmp/result.json"} {
if !strings.Contains(script, fragment) {
t.Fatalf("script missing %q: %s", fragment, script)
}
}
if strings.Contains(script, "; ;") {
t.Fatalf("script contains an empty shell command: %s", script)
}
if strings.Contains(script, "|| true") {
t.Fatalf("runner must fail when Codex fails")
}
}
func TestForkScriptUsesUpstreamBaseRemote(t *testing.T) {
pr := samplePR()
pr.BaseCloneURL = "https://gitea.test/base/repo.git"
r := NewDockerRunner(config.Settings{GiteaBotUsername: "bot", GiteaToken: "token", OpenAIReviewModel: "model", CodexAuthMode: "api_key"})
script := r.script(pr, "prompt", "BEGIN", "END")
if !strings.Contains(script, "git remote add upstream") || !strings.Contains(script, "fetch --no-tags upstream") {
t.Fatalf("fork base remote was not configured: %s", script)
}
}
func TestChatGPTScriptWritesAuthFile(t *testing.T) {
r := NewDockerRunner(config.Settings{GiteaBotUsername: "bot", GiteaToken: "token", OpenAIReviewModel: "model", CodexAuthMode: "chatgpt"})
script := r.script(samplePR(), "prompt", "BEGIN", "END")
if !strings.Contains(script, "CODEX_AUTH_JSON_B64") || !strings.Contains(script, "chmod 600 /root/.codex/auth.json") {
t.Fatalf("chatgpt auth setup missing: %s", script)
}
}
+342
View File
@@ -0,0 +1,342 @@
package sqlstore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
"gitea-codex-bot/internal/store"
_ "github.com/go-sql-driver/mysql"
_ "modernc.org/sqlite"
)
type Store struct {
db *sql.DB
dialect string
}
func Open(settings config.Settings) (*Store, error) {
dsn := settings.DatabaseURL
dialect := "mysql"
if dsn == "" {
dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4", settings.DBUser, settings.DBPassword, settings.DBHost, settings.DBPort, settings.DBName)
}
if strings.HasPrefix(dsn, "mysql://") {
dsn = strings.TrimPrefix(dsn, "mysql://")
}
if strings.HasPrefix(dsn, "sqlite://") {
dialect = "sqlite"
dsn = strings.TrimPrefix(dsn, "sqlite://")
} else if strings.HasPrefix(dsn, "sqlite:") {
dialect = "sqlite"
dsn = strings.TrimPrefix(dsn, "sqlite:")
} else if strings.HasPrefix(dsn, "file:") || dsn == ":memory:" || filepath.Ext(dsn) == ".db" {
dialect = "sqlite"
}
db, err := sql.Open(map[string]string{"sqlite": "sqlite", "mysql": "mysql"}[dialect], dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(8)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db, dialect: dialect}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Migrate(ctx context.Context) error {
stmts := s.schema()
for _, stmt := range stmts {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migration: %w", err)
}
}
if _, err := s.db.ExecContext(ctx, s.alterAddTriggerCommentBody()); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate") && !strings.Contains(strings.ToLower(err.Error()), "exists") {
return fmt.Errorf("migration trigger_comment_body: %w", err)
}
return nil
}
func (s *Store) schema() []string {
if s.dialect == "sqlite" {
return []string{
`CREATE TABLE IF NOT EXISTS webhook_events (id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_id TEXT NULL UNIQUE, event_name TEXT NOT NULL, repo TEXT NOT NULL, comment_id INTEGER NULL, payload_sha256 TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(repo, comment_id))`,
`CREATE TABLE IF NOT EXISTS review_jobs (id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, head_sha TEXT NOT NULL, trigger_comment_id INTEGER NOT NULL, command TEXT NOT NULL, command_args TEXT NULL, trigger_comment_body TEXT NULL, requested_by TEXT NOT NULL, status TEXT NOT NULL, last_error TEXT NULL, result_json TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at TIMESTAMP NULL, finished_at TIMESTAMP NULL, UNIQUE(repo, trigger_comment_id))`,
`CREATE INDEX IF NOT EXISTS ix_review_jobs_lookup ON review_jobs(repo, pr_number, head_sha, status, created_at)`,
`CREATE TABLE IF NOT EXISTS review_runs (id INTEGER PRIMARY KEY AUTOINCREMENT, job_id INTEGER NOT NULL, status TEXT NOT NULL, runner_container_id TEXT NULL, result_json TEXT NULL, error_message TEXT NULL, started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at TIMESTAMP NULL, FOREIGN KEY(job_id) REFERENCES review_jobs(id) ON DELETE CASCADE)`,
`CREATE INDEX IF NOT EXISTS ix_review_runs_job_status ON review_runs(job_id, status)`,
`CREATE TABLE IF NOT EXISTS bot_comments (id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, pr_number INTEGER NOT NULL, head_sha TEXT NOT NULL, gitea_comment_id INTEGER NOT NULL, marker TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(repo, pr_number, marker))`,
`CREATE INDEX IF NOT EXISTS ix_bot_comments_repo_pr ON bot_comments(repo, pr_number)`,
}
}
return []string{
`CREATE TABLE IF NOT EXISTS webhook_events (id BIGINT AUTO_INCREMENT PRIMARY KEY, delivery_id VARCHAR(255) NULL UNIQUE, event_name VARCHAR(128) NOT NULL, repo VARCHAR(255) NOT NULL, comment_id BIGINT NULL, payload_sha256 VARCHAR(64) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE KEY uq_webhook_events_repo_comment (repo, comment_id)) ENGINE=InnoDB`,
`CREATE TABLE IF NOT EXISTS review_jobs (id BIGINT AUTO_INCREMENT PRIMARY KEY, repo VARCHAR(255) NOT NULL, pr_number INT NOT NULL, head_sha VARCHAR(64) NOT NULL, trigger_comment_id BIGINT NOT NULL, command VARCHAR(64) NOT NULL, command_args TEXT NULL, trigger_comment_body TEXT NULL, requested_by VARCHAR(255) NOT NULL, status VARCHAR(32) NOT NULL, last_error TEXT NULL, result_json JSON NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), started_at DATETIME(6) NULL, finished_at DATETIME(6) NULL, UNIQUE KEY uq_review_jobs_repo_trigger_comment (repo, trigger_comment_id), KEY ix_review_jobs_lookup (repo, pr_number, head_sha, status, created_at)) ENGINE=InnoDB`,
`CREATE TABLE IF NOT EXISTS review_runs (id BIGINT AUTO_INCREMENT PRIMARY KEY, job_id BIGINT NOT NULL, status VARCHAR(32) NOT NULL, runner_container_id VARCHAR(128) NULL, result_json JSON NULL, error_message TEXT NULL, started_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), finished_at DATETIME(6) NULL, CONSTRAINT fk_review_runs_job FOREIGN KEY(job_id) REFERENCES review_jobs(id) ON DELETE CASCADE, KEY ix_review_runs_job_status (job_id, status)) ENGINE=InnoDB`,
`CREATE TABLE IF NOT EXISTS bot_comments (id BIGINT AUTO_INCREMENT PRIMARY KEY, repo VARCHAR(255) NOT NULL, pr_number INT NOT NULL, head_sha VARCHAR(64) NOT NULL, gitea_comment_id BIGINT NOT NULL, marker VARCHAR(255) NOT NULL, created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE KEY uq_bot_comments_marker (repo, pr_number, marker), KEY ix_bot_comments_repo_pr (repo, pr_number)) ENGINE=InnoDB`,
}
}
func (s *Store) alterAddTriggerCommentBody() string {
if s.dialect == "sqlite" {
return `ALTER TABLE review_jobs ADD COLUMN trigger_comment_body TEXT`
}
return `ALTER TABLE review_jobs ADD COLUMN trigger_comment_body TEXT NULL`
}
func (s *Store) InsertWebhookEvent(ctx context.Context, event domain.WebhookEvent) (bool, error) {
_, err := s.db.ExecContext(ctx, `INSERT INTO webhook_events(delivery_id,event_name,repo,comment_id,payload_sha256) VALUES(?,?,?,?,?)`, nullable(event.DeliveryID), event.EventName, event.Repo, event.CommentID, event.PayloadSHA256)
if err != nil {
if isConstraint(err) {
return false, nil
}
return false, err
}
return true, nil
}
func (s *Store) CooldownRemaining(ctx context.Context, repo string, pr int, duration time.Duration) (int, error) {
cutoff := time.Now().UTC().Add(-duration)
var created time.Time
err := s.db.QueryRowContext(ctx, `SELECT created_at FROM review_jobs WHERE repo=? AND pr_number=? AND created_at>=? ORDER BY created_at DESC LIMIT 1`, repo, pr, cutoff).Scan(&created)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
remaining := int((duration - time.Since(created)).Seconds())
if remaining < 0 {
return 0, nil
}
return remaining, nil
}
func (s *Store) EnqueueJob(ctx context.Context, event domain.WebhookEvent, command domain.ParsedCommand) (domain.Job, error) {
args, _ := json.Marshal(command.Arguments)
result, err := s.db.ExecContext(ctx, `INSERT INTO review_jobs(repo,pr_number,head_sha,trigger_comment_id,command,command_args,trigger_comment_body,requested_by,status) VALUES(?,?,?,?,?,?,?,?,?)`, event.Repo, event.PRNumber, event.HeadSHA, event.CommentID, command.Name, string(args), event.CommentBody, event.Sender, domain.JobQueued)
if err != nil {
return domain.Job{}, err
}
id, err := result.LastInsertId()
if err != nil {
return domain.Job{}, err
}
return s.getJob(ctx, id)
}
func (s *Store) ClaimNextJob(ctx context.Context, now time.Time, lease time.Duration, maxRetries int) (*domain.Job, *domain.ReviewRun, error) {
if err := s.recoverStale(ctx, now, lease, maxRetries); err != nil {
return nil, nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, nil, err
}
defer tx.Rollback()
query := `SELECT id FROM review_jobs WHERE status=? ORDER BY created_at ASC,id ASC LIMIT 1`
if s.dialect == "mysql" {
query += ` FOR UPDATE SKIP LOCKED`
}
var id int64
if err := tx.QueryRowContext(ctx, query, domain.JobQueued).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, nil
}
return nil, nil, err
}
started := now.UTC()
if _, err := tx.ExecContext(ctx, `UPDATE review_jobs SET status=?, started_at=?, finished_at=NULL, updated_at=? WHERE id=?`, domain.JobRunning, started, started, id); err != nil {
return nil, nil, err
}
runRes, err := tx.ExecContext(ctx, `INSERT INTO review_runs(job_id,status,started_at) VALUES(?,?,?)`, id, domain.RunRunning, started)
if err != nil {
return nil, nil, err
}
runID, err := runRes.LastInsertId()
if err != nil {
return nil, nil, err
}
if err := tx.Commit(); err != nil {
return nil, nil, err
}
job, err := s.getJob(ctx, id)
if err != nil {
return nil, nil, err
}
return &job, &domain.ReviewRun{ID: runID, JobID: id, Status: domain.RunRunning, StartedAt: started}, nil
}
func (s *Store) recoverStale(ctx context.Context, now time.Time, lease time.Duration, maxRetries int) error {
rows, err := s.db.QueryContext(ctx, `SELECT id,started_at FROM review_jobs WHERE status=? AND started_at IS NOT NULL AND started_at<=?`, domain.JobRunning, now.Add(-lease))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var started time.Time
if err := rows.Scan(&id, &started); err != nil {
return err
}
var attempts int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM review_runs WHERE job_id=?`, id).Scan(&attempts); err != nil {
return err
}
message := fmt.Sprintf("Job lease timed out after %ds on attempt %d. Recovered by queue watchdog.", int(lease.Seconds()), attempts)
_, _ = s.db.ExecContext(ctx, `UPDATE review_runs SET status=?,finished_at=?,error_message=? WHERE id=(SELECT id FROM (SELECT id FROM review_runs WHERE job_id=? ORDER BY id DESC LIMIT 1) AS latest) AND status=?`, domain.RunFailed, now, message, id, domain.RunRunning)
if attempts-1 < maxRetries {
_, err = s.db.ExecContext(ctx, `UPDATE review_jobs SET status=?,started_at=NULL,finished_at=NULL,last_error=?,updated_at=? WHERE id=?`, domain.JobQueued, message, now, id)
} else {
_, err = s.db.ExecContext(ctx, `UPDATE review_jobs SET status=?,finished_at=?,last_error=?,updated_at=? WHERE id=?`, domain.JobFailed, now, message, now, id)
}
if err != nil {
return err
}
}
return rows.Err()
}
func (s *Store) FinishJob(ctx context.Context, jobID, runID int64, success, skipped bool, result *domain.ReviewResult, runErr error) error {
now := time.Now().UTC()
status, runStatus := domain.JobFailed, domain.RunFailed
if skipped {
status, runStatus = domain.JobSkipped, domain.RunSkipped
} else if success {
status, runStatus = domain.JobSucceeded, domain.RunSucceeded
} else {
var attempts int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM review_runs WHERE job_id=?`, jobID).Scan(&attempts); err != nil {
return err
}
if attempts <= 3 {
status = domain.JobQueued
}
}
var resultJSON []byte
if result != nil {
resultJSON, _ = json.Marshal(result)
}
errText := ""
if runErr != nil {
errText = runErr.Error()
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if status == domain.JobQueued {
_, err = tx.ExecContext(ctx, `UPDATE review_jobs SET status=?,started_at=NULL,finished_at=NULL,last_error=?,result_json=?,updated_at=? WHERE id=?`, status, nullable(errText), nullableBytes(resultJSON), now, jobID)
} else {
_, err = tx.ExecContext(ctx, `UPDATE review_jobs SET status=?,finished_at=?,last_error=?,result_json=?,updated_at=? WHERE id=?`, status, now, nullable(errText), nullableBytes(resultJSON), now, jobID)
}
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE review_runs SET status=?,finished_at=?,error_message=?,result_json=? WHERE id=?`, runStatus, now, nullable(errText), nullableBytes(resultJSON), runID)
if err != nil {
return err
}
return tx.Commit()
}
func (s *Store) LatestFailedJob(ctx context.Context) (*domain.Job, error) {
return s.latest(ctx, `WHERE status=?`, domain.JobFailed)
}
func (s *Store) LatestJob(ctx context.Context) (*domain.Job, error) { return s.latest(ctx, ``, nil) }
func (s *Store) latest(ctx context.Context, where string, arg any) (*domain.Job, error) {
query := `SELECT id,repo,pr_number,head_sha,trigger_comment_id,COALESCE(trigger_comment_body,''),command,COALESCE(command_args,''),requested_by,status,COALESCE(last_error,''),COALESCE(result_json,''),created_at,updated_at,started_at,finished_at FROM review_jobs ` + where + ` ORDER BY created_at DESC,id DESC LIMIT 1`
var row *sql.Row
if arg == nil {
row = s.db.QueryRowContext(ctx, query)
} else {
row = s.db.QueryRowContext(ctx, query, arg)
}
job, err := scanJob(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &job, err
}
func (s *Store) LatestSuccessfulReview(ctx context.Context, repo string, pr int) (*domain.Job, error) {
return s.latestWith(ctx, `WHERE repo=? AND pr_number=? AND command IN ('review','rerun') AND status=?`, repo, pr, domain.JobSucceeded)
}
func (s *Store) latestWith(ctx context.Context, where string, args ...any) (*domain.Job, error) {
query := `SELECT id,repo,pr_number,head_sha,trigger_comment_id,COALESCE(trigger_comment_body,''),command,COALESCE(command_args,''),requested_by,status,COALESCE(last_error,''),COALESCE(result_json,''),created_at,updated_at,started_at,finished_at FROM review_jobs ` + where + ` ORDER BY id DESC LIMIT 1`
job, err := scanJob(s.db.QueryRowContext(ctx, query, args...))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &job, err
}
func (s *Store) PendingCount(ctx context.Context, repo string, pr int) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM review_jobs WHERE repo=? AND pr_number=? AND status IN (?,?)`, repo, pr, domain.JobQueued, domain.JobRunning).Scan(&n)
return n, err
}
func (s *Store) UpsertBotComment(ctx context.Context, repo string, pr int, marker string, commentID int64, sha string) error {
if s.dialect == "sqlite" {
_, err := s.db.ExecContext(ctx, `INSERT INTO bot_comments(repo,pr_number,head_sha,gitea_comment_id,marker) VALUES(?,?,?,?,?) ON CONFLICT(repo,pr_number,marker) DO UPDATE SET head_sha=excluded.head_sha,gitea_comment_id=excluded.gitea_comment_id,updated_at=CURRENT_TIMESTAMP`, repo, pr, sha, commentID, marker)
return err
}
_, err := s.db.ExecContext(ctx, `INSERT INTO bot_comments(repo,pr_number,head_sha,gitea_comment_id,marker) VALUES(?,?,?,?,?) ON DUPLICATE KEY UPDATE head_sha=VALUES(head_sha),gitea_comment_id=VALUES(gitea_comment_id),updated_at=CURRENT_TIMESTAMP(6)`, repo, pr, sha, commentID, marker)
return err
}
func (s *Store) BotCommentID(ctx context.Context, repo string, pr int, marker string) (int64, error) {
var id int64
err := s.db.QueryRowContext(ctx, `SELECT gitea_comment_id FROM bot_comments WHERE repo=? AND pr_number=? AND marker=?`, repo, pr, marker).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return id, err
}
func (s *Store) getJob(ctx context.Context, id int64) (domain.Job, error) {
return scanJob(s.db.QueryRowContext(ctx, `SELECT id,repo,pr_number,head_sha,trigger_comment_id,COALESCE(trigger_comment_body,''),command,COALESCE(command_args,''),requested_by,status,COALESCE(last_error,''),COALESCE(result_json,''),created_at,updated_at,started_at,finished_at FROM review_jobs WHERE id=?`, id))
}
func scanJob(scanner interface{ Scan(...any) error }) (domain.Job, error) {
var j domain.Job
var status string
var result, body, args, last sql.NullString
var started, finished sql.NullTime
if err := scanner.Scan(&j.ID, &j.Repo, &j.PRNumber, &j.HeadSHA, &j.TriggerCommentID, &body, &j.Command, &args, &j.RequestedBy, &status, &last, &result, &j.CreatedAt, &j.UpdatedAt, &started, &finished); err != nil {
return domain.Job{}, err
}
j.TriggerCommentBody = body.String
j.CommandArgs = args.String
j.LastError = last.String
j.ResultJSON = []byte(result.String)
j.Status = domain.JobStatus(status)
if started.Valid {
j.StartedAt = &started.Time
}
if finished.Valid {
j.FinishedAt = &finished.Time
}
return j, nil
}
func isConstraint(err error) bool {
text := strings.ToLower(err.Error())
return strings.Contains(text, "unique") || strings.Contains(text, "duplicate") || strings.Contains(text, "constraint")
}
func nullable(v string) any {
if v == "" {
return nil
}
return v
}
func nullableBytes(v []byte) any {
if len(v) == 0 {
return nil
}
return string(v)
}
var _ store.Store = (*Store)(nil)
+52
View File
@@ -0,0 +1,52 @@
package sqlstore
import (
"context"
"testing"
"time"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
)
func TestSQLiteMigrationsAndJobLifecycle(t *testing.T) {
settings := config.Settings{DatabaseURL: "sqlite://" + t.TempDir() + "/test.db"}
st, err := Open(settings)
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
event := domain.WebhookEvent{EventName: "issue_comment", DeliveryID: "d1", Repo: "acme/repo", PRNumber: 9, HeadSHA: "abc123", CommentID: 11, CommentBody: "@codex review", Sender: "alice", PayloadSHA256: "digest"}
inserted, err := st.InsertWebhookEvent(ctx, event)
if err != nil || !inserted {
t.Fatalf("insert event: %v %v", inserted, err)
}
duplicate, err := st.InsertWebhookEvent(ctx, event)
if err != nil || duplicate {
t.Fatalf("duplicate event result: %v %v", duplicate, err)
}
job, err := st.EnqueueJob(ctx, event, domain.ParsedCommand{Name: "review", Raw: event.CommentBody, Mode: "summary"})
if err != nil {
t.Fatal(err)
}
claimed, run, err := st.ClaimNextJob(ctx, time.Now().UTC(), 5*time.Minute, 2)
if err != nil || claimed == nil || run == nil {
t.Fatalf("claim: %#v %#v %v", claimed, run, err)
}
result := domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: "ok", Findings: []domain.Finding{}}
if err := st.FinishJob(ctx, job.ID, run.ID, true, false, &result, nil); err != nil {
t.Fatal(err)
}
latest, err := st.LatestJob(ctx)
if err != nil || latest == nil || latest.Status != domain.JobSucceeded {
t.Fatalf("latest: %#v %v", latest, err)
}
remaining, err := st.CooldownRemaining(ctx, event.Repo, event.PRNumber, time.Minute)
if err != nil || remaining <= 0 {
t.Fatalf("cooldown: %d %v", remaining, err)
}
}
+24
View File
@@ -0,0 +1,24 @@
package store
import (
"context"
"time"
"gitea-codex-bot/internal/domain"
)
type Store interface {
Migrate(context.Context) error
InsertWebhookEvent(context.Context, domain.WebhookEvent) (bool, error)
CooldownRemaining(context.Context, string, int, time.Duration) (int, error)
EnqueueJob(context.Context, domain.WebhookEvent, domain.ParsedCommand) (domain.Job, error)
ClaimNextJob(context.Context, time.Time, time.Duration, int) (*domain.Job, *domain.ReviewRun, error)
FinishJob(context.Context, int64, int64, bool, bool, *domain.ReviewResult, error) error
LatestFailedJob(context.Context) (*domain.Job, error)
LatestJob(context.Context) (*domain.Job, error)
LatestSuccessfulReview(context.Context, string, int) (*domain.Job, error)
PendingCount(context.Context, string, int) (int, error)
UpsertBotComment(context.Context, string, int, string, int64, string) error
BotCommentID(context.Context, string, int, string) (int64, error)
Close() error
}
+127
View File
@@ -0,0 +1,127 @@
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"gitea-codex-bot/internal/domain"
)
func VerifySignature(body []byte, secret, supplied string) bool {
supplied = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(supplied), "sha256="))
if supplied == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(supplied)))
}
func Digest(body []byte) string { sum := sha256.Sum256(body); return hex.EncodeToString(sum[:]) }
func ParseEvent(eventName, delivery string, body []byte) (domain.WebhookEvent, error) {
if eventName != "issue_comment" && eventName != "pull_request_comment" {
return domain.WebhookEvent{}, errors.New("event ignored")
}
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return domain.WebhookEvent{}, errors.New("invalid JSON payload")
}
repo := nestedString(raw, "repository", "full_name")
commentID := nestedInt64(raw, "comment", "id")
sender := nestedString(raw, "sender", "username")
commentBody := nestedString(raw, "comment", "body")
if repo == "" || commentID <= 0 {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
prNumber, headSHA := 0, ""
if eventName == "issue_comment" {
if _, ok := raw["pull_request"]; !ok || raw["pull_request"] == nil {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
issue, ok := raw["issue"].(map[string]any)
if !ok || !truthy(issue["pull_request"]) {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
prNumber = number(issue["number"])
headSHA = nestedString(raw, "pull_request", "head", "sha")
} else {
pr, ok := raw["pull_request"].(map[string]any)
if !ok || pr == nil {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
prNumber = number(pr["number"])
headSHA = nestedString(raw, "pull_request", "head", "sha")
}
if prNumber <= 0 {
return domain.WebhookEvent{}, errors.New("not a pull request comment")
}
if headSHA == "" {
headSHA = "unknown"
}
return domain.WebhookEvent{EventName: eventName, DeliveryID: delivery, Repo: repo, PRNumber: prNumber, HeadSHA: headSHA, CommentID: commentID, CommentBody: strings.TrimSpace(commentBody), Sender: sender, PayloadSHA256: Digest(body)}, nil
}
func nestedString(raw map[string]any, path ...string) string {
var current any = raw
for _, key := range path {
obj, ok := current.(map[string]any)
if !ok {
return ""
}
current = obj[key]
}
if value, ok := current.(string); ok {
return value
}
return ""
}
func nestedInt64(raw map[string]any, path ...string) int64 {
var current any = raw
for _, key := range path {
obj, ok := current.(map[string]any)
if !ok {
return 0
}
current = obj[key]
}
return int64(number(current))
}
func number(value any) int {
switch v := value.(type) {
case float64:
return int(v)
case json.Number:
n, _ := strconv.Atoi(string(v))
return n
case int:
return v
case int64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
}
return 0
}
func truthy(value any) bool {
switch v := value.(type) {
case bool:
return v
case map[string]any:
return len(v) > 0
case string:
return strings.TrimSpace(v) != ""
default:
return value != nil
}
}
var _ = fmt.Sprintf
+39
View File
@@ -0,0 +1,39 @@
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"testing"
)
func TestVerifySignatureUsesRawBodyAndPrefix(t *testing.T) {
body := []byte(`{"ok":true}`)
mac := hmac.New(sha256.New, []byte("secret"))
_, _ = mac.Write(body)
signature := hex.EncodeToString(mac.Sum(nil))
if !VerifySignature(body, "secret", "sha256="+signature) {
t.Fatal("valid signature was rejected")
}
if VerifySignature([]byte(`{"ok":false}`), "secret", signature) {
t.Fatal("changed body was accepted")
}
if VerifySignature(body, "wrong", signature) {
t.Fatal("wrong secret was accepted")
}
}
func TestParseEvents(t *testing.T) {
body := []byte(`{"repository":{"full_name":"acme/repo"},"sender":{"username":"alice"},"comment":{"id":11,"body":"@codex review"},"issue":{"number":9,"pull_request":{"url":"x"}},"pull_request":{"head":{"sha":"abc"}}}`)
event, err := ParseEvent("issue_comment", "delivery-1", body)
if err != nil {
t.Fatal(err)
}
if event.Repo != "acme/repo" || event.PRNumber != 9 || event.HeadSHA != "abc" || event.CommentID != 11 {
t.Fatalf("unexpected event: %#v", event)
}
bad := []byte(`{"repository":{"full_name":"acme/repo"},"comment":{"id":1},"issue":{"number":9}}`)
if _, err := ParseEvent("issue_comment", "", bad); err == nil {
t.Fatal("non-PR issue comment was accepted")
}
}
+210
View File
@@ -0,0 +1,210 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"gitea-codex-bot/internal/commands"
"gitea-codex-bot/internal/config"
"gitea-codex-bot/internal/domain"
"gitea-codex-bot/internal/gitea"
"gitea-codex-bot/internal/review"
"gitea-codex-bot/internal/store"
)
type Worker struct {
settings config.Settings
store store.Store
gitea *gitea.Client
runner domain.ReviewRunner
logger *slog.Logger
}
func New(settings config.Settings, st store.Store, client *gitea.Client, runner domain.ReviewRunner, logger *slog.Logger) *Worker {
return &Worker{settings: settings, store: st, gitea: client, runner: runner, logger: logger}
}
func (w *Worker) Run(ctx context.Context) error {
var wg sync.WaitGroup
for i := 0; i < w.settings.Concurrency; i++ {
wg.Add(1)
go func() { defer wg.Done(); w.loop(ctx) }()
}
wg.Wait()
return nil
}
func (w *Worker) loop(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
job, run, err := w.store.ClaimNextJob(ctx, time.Now().UTC(), 5*time.Minute, 2)
if err != nil {
w.logger.Error("claim job", "error", err)
sleep(ctx, time.Second)
continue
}
if job == nil {
sleep(ctx, time.Second)
continue
}
if err := w.process(ctx, *job, *run); err != nil {
w.logger.Error("process job", "job_id", job.ID, "error", err)
}
}
}
func (w *Worker) process(ctx context.Context, job domain.Job, run domain.ReviewRun) error {
cmd := commandFromJob(job, w.settings.Aliases())
if cmd.Name == "help" || cmd.Name == "ignore" || cmd.Name == "explain" {
return w.processNonReview(ctx, job, run, cmd)
}
pr, err := w.gitea.GetPullRequest(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
if pr.IsFork && !w.settings.AllowUntrustedForks {
message := "Skipped review for fork PR because `ALLOW_UNTRUSTED_FORKS=false`."
_, postErr := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, message)
if postErr != nil {
return w.fail(ctx, job, run, postErr)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: message, Findings: []domain.Finding{}}, nil)
}
cfg := review.MissingRepoConfig()
text, configured, cfgErr := w.gitea.GetFileContent(ctx, job.Repo, ".codex-review.yml", pr.HeadSHA)
if cfgErr != nil {
return w.fail(ctx, job, run, cfgErr)
}
if configured {
cfg, err = review.ParseRepoConfig(text)
if err != nil {
return w.fail(ctx, job, run, err)
}
}
if !cfg.Enabled {
_, postErr := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, review.FormatDisabledAck())
if postErr != nil {
return w.fail(ctx, job, run, postErr)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: review.FormatDisabledAck(), Findings: []domain.Finding{}}, nil)
}
review.ResolveMode(&cmd, cfg)
result, err := w.runner.Run(ctx, pr, cmd, cfg)
if err != nil {
return w.fail(ctx, job, run, err)
}
body := review.FormatResultComment(pr.HeadSHA, result, cfg.Configured)
commentID, err := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, body)
if err != nil {
return w.fail(ctx, job, run, err)
}
if err := w.store.UpsertBotComment(ctx, job.Repo, job.PRNumber, "codex-review", commentID, pr.HeadSHA); err != nil {
return w.fail(ctx, job, run, err)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, false, &result, nil)
}
func (w *Worker) processNonReview(ctx context.Context, job domain.Job, run domain.ReviewRun, cmd domain.ParsedCommand) error {
switch cmd.Name {
case "ignore":
result := domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: "Ignore command acknowledged. No review run executed.", Findings: []domain.Finding{}}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &result, nil)
case "explain":
latest, err := w.store.LatestSuccessfulReview(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
message := "## Codex Explain\n\nNo previous result found for this command."
if latest != nil && len(latest.ResultJSON) > 0 {
var result domain.ReviewResult
if json.Unmarshal(latest.ResultJSON, &result) == nil {
message = "## Codex Explain\n\n" + result.Summary
}
}
if _, err := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, message); err != nil {
return w.fail(ctx, job, run, err)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: message, Findings: []domain.Finding{}}, nil)
case "help":
comments, err := w.gitea.GetIssueComments(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
pending, err := w.store.PendingCount(ctx, job.Repo, job.PRNumber)
if err != nil {
return w.fail(ctx, job, run, err)
}
message := helpComment(comments, w.settings.GiteaBotUsername, pending)
if _, err := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, message); err != nil {
return w.fail(ctx, job, run, err)
}
return w.store.FinishJob(ctx, job.ID, run.ID, true, true, &domain.ReviewResult{Verdict: "correct", Confidence: 1, Summary: "Help/status summary posted.", Findings: []domain.Finding{}}, nil)
}
return w.fail(ctx, job, run, fmt.Errorf("unsupported worker command %q", cmd.Name))
}
func (w *Worker) fail(ctx context.Context, job domain.Job, run domain.ReviewRun, err error) error {
errorText := strings.TrimSpace(err.Error())
if errorText == "" {
errorText = "review failed"
}
if _, postErr := w.gitea.PostIssueComment(ctx, job.Repo, job.PRNumber, review.FailureComment(job.HeadSHA, errorText)); postErr != nil {
w.logger.Error("post failure comment", "job_id", job.ID, "error", postErr)
}
return w.store.FinishJob(ctx, job.ID, run.ID, false, false, nil, fmt.Errorf("%s", errorText))
}
func commandFromJob(job domain.Job, aliases map[string]bool) domain.ParsedCommand {
if parsed, ok := commands.Parse(job.TriggerCommentBody, aliases); ok {
return parsed
}
args := strings.Fields(job.CommandArgs)
return domain.ParsedCommand{Name: job.Command, Raw: job.TriggerCommentBody, Arguments: args, Mode: "summary", Full: contains(args, "--full")}
}
func contains(items []string, needle string) bool {
for _, item := range items {
if item == needle {
return true
}
}
return false
}
func helpComment(comments []map[string]any, bot string, pending int) string {
bot = strings.ToLower(strings.TrimSpace(bot))
human, bots := 0, 0
lines := []string{"## Codex Help", "", "Supported commands:", "- `@codex review [security|performance|tests] [--full]`", "- `@codex rerun`", "- `@codex explain`", "- `@codex ignore`", "- `@codex -h` / `@codex --help` / `@codex help`", "", "Status note:", fmt.Sprintf("- Pending jobs on this PR: `%d`", pending), "", fmt.Sprintf("Discussion summary (%d comments):", len(comments))}
for _, c := range comments {
user := "unknown"
if obj, ok := c["user"].(map[string]any); ok {
if v, ok := obj["username"].(string); ok && v != "" {
user = v
} else if v, ok := obj["login"].(string); ok {
user = v
}
}
if strings.ToLower(user) == bot {
bots++
} else {
human++
}
body, _ := c["body"].(string)
body = strings.Join(strings.Fields(body), " ")
if body != "" {
if len(body) > 180 {
body = body[:180] + "..."
}
lines = append(lines, fmt.Sprintf("- @%s: %s", user, body))
}
}
lines[11] = fmt.Sprintf("Discussion summary (%d comments, human `%d`, bot `%d`):", len(comments), human, bots)
return strings.Join(lines, "\n")
}
func sleep(ctx context.Context, duration time.Duration) {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}
+3
View File
@@ -0,0 +1,3 @@
-- The Go migrator creates this logical schema with dialect-specific SQL.
-- This file documents the compatibility baseline: webhook_events, review_jobs,
-- review_runs, and bot_comments with the constraints described in README.md.
+1
View File
@@ -0,0 +1 @@
-- Compatibility migration: add review_jobs.trigger_comment_body when absent.
-42
View File
@@ -1,42 +0,0 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "gitea-codex-bot"
version = "0.1.0"
description = "Webhook-driven Codex review bot for Gitea"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"sqlalchemy>=2.0.30",
"alembic>=1.13.2",
"pymysql>=1.1.1",
"httpx>=0.27.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"python-dotenv>=1.0.1",
"pyyaml>=6.0.2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2.0",
"pytest-asyncio>=0.23.7",
"pytest-cov>=5.0.0",
]
[tool.pytest.ini_options]
addopts = "-q"
testpaths = ["tests"]
markers = [
"no_schema: skip automatic schema setup fixture for migration-focused tests",
]
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
-3
View File
@@ -1,3 +0,0 @@
__all__ = ["__version__"]
__version__ = "0.1.0"
View File
-72
View File
@@ -1,72 +0,0 @@
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
gitea_base_url: str = Field(alias="GITEA_BASE_URL")
gitea_token: SecretStr = Field(alias="GITEA_TOKEN")
gitea_bot_username: str = Field(alias="GITEA_BOT_USERNAME")
gitea_bot_mentions: str = Field(default="", alias="GITEA_BOT_MENTIONS")
gitea_webhook_secret: SecretStr = Field(alias="GITEA_WEBHOOK_SECRET")
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")
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")
webhook_mode: Literal["repo", "global"] = Field(default="repo", alias="WEBHOOK_MODE")
db_host: str = Field(alias="DB_HOST")
db_port: int = Field(default=3306, alias="DB_PORT")
db_name: str = Field(alias="DB_NAME")
db_user: str = Field(alias="DB_USER")
db_password: SecretStr = Field(alias="DB_PASSWORD")
database_url: str | None = Field(default=None, alias="DATABASE_URL")
workdir: str = Field(default="/var/lib/gitea-codex/worktrees", alias="WORKDIR")
max_diff_bytes: int = Field(default=200000, alias="MAX_DIFF_BYTES")
max_review_minutes: int = Field(default=10, alias="MAX_REVIEW_MINUTES")
concurrency: int = Field(default=1, alias="CONCURRENCY")
review_runner_image: str = Field(default="node:22-bookworm-slim", alias="REVIEW_RUNNER_IMAGE")
allow_untrusted_forks: bool = Field(default=False, alias="ALLOW_UNTRUSTED_FORKS")
@field_validator("gitea_base_url")
@classmethod
def normalize_base_url(cls, value: str) -> str:
return value.rstrip("/")
@property
def sqlalchemy_url(self) -> str:
if self.database_url:
return self.database_url
password = self.db_password.get_secret_value()
return f"mysql+pymysql://{self.db_user}:{password}@{self.db_host}:{self.db_port}/{self.db_name}?charset=utf8mb4"
@property
def allowed_repo_set(self) -> set[str]:
values = [item.strip() for item in self.allowed_repos.split(",")]
return {value for value in values if value}
@property
def bot_command_aliases(self) -> set[str]:
configured = [item.strip().lstrip("@").lower() for item in self.gitea_bot_mentions.split(",")]
aliases = {"codex", self.gitea_bot_username.strip().lstrip("@").lower()}
aliases.update(alias for alias in configured if alias)
return aliases
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
-32
View File
@@ -1,32 +0,0 @@
from __future__ import annotations
from collections.abc import Generator
from functools import lru_cache
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from gitea_codex_bot.config import get_settings
class Base(DeclarativeBase):
pass
@lru_cache(maxsize=1)
def get_engine():
settings = get_settings()
return create_engine(settings.sqlalchemy_url, pool_pre_ping=True, future=True)
@lru_cache(maxsize=1)
def get_session_factory():
return sessionmaker(bind=get_engine(), class_=Session, autoflush=False, autocommit=False, expire_on_commit=False)
def get_session() -> Generator[Session, None, None]:
session = get_session_factory()()
try:
yield session
finally:
session.close()
-524
View File
@@ -1,524 +0,0 @@
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
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import HTMLResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from gitea_codex_bot.config import Settings, get_settings
from gitea_codex_bot.db import get_session
from gitea_codex_bot.models import JobStatus, ReviewJob
from gitea_codex_bot.services.commands import detect_prefixed_command, parse_command
from gitea_codex_bot.services.gitea import GiteaClient
from gitea_codex_bot.services.jobs import cooldown_remaining_seconds, enqueue_job, persist_webhook_event
from gitea_codex_bot.services.repo_config import RepoReviewConfig, parse_repo_review_config_text
from gitea_codex_bot.services.review_format import (
format_cooldown_ack,
format_disabled_ack,
format_queue_ack,
format_unsupported_ack,
)
from gitea_codex_bot.services.security import verify_gitea_signature
from gitea_codex_bot.workers.dispatcher import worker_loop
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger(__name__)
def _validate_required_env(settings: Settings) -> None:
webhook_secret = settings.gitea_webhook_secret.get_secret_value()
if not webhook_secret.strip():
raise RuntimeError("GITEA_WEBHOOK_SECRET is required")
gitea_token = settings.gitea_token.get_secret_value()
if not gitea_token.strip():
raise RuntimeError("GITEA_TOKEN is required")
if not settings.allowed_repos.strip():
raise RuntimeError("ALLOWED_REPOS is required")
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")
if not repo:
return None
sender = payload.get("sender", {})
sender_username = sender.get("username", "")
comment = payload.get("comment", {})
comment_id = int(comment.get("id", 0) or 0)
if comment_id <= 0:
return None
if event_name == "issue_comment":
issue = payload.get("issue", {})
if not issue.get("pull_request"):
return None
pr_number = int(issue.get("number", 0) or 0)
head_sha = payload.get("pull_request", {}).get("head", {}).get("sha", "")
elif event_name == "pull_request_comment":
pull_request = payload.get("pull_request", {})
if not pull_request:
return None
pr_number = int(pull_request.get("number", 0) or 0)
head_sha = pull_request.get("head", {}).get("sha", "")
else:
return None
if pr_number <= 0:
return None
if not head_sha:
head_sha = "unknown"
return repo, pr_number, head_sha, comment_id, sender_username
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
_validate_required_env(settings)
_log_startup_identity(settings)
_log_startup_auth_json_status(settings)
stop_event = asyncio.Event()
task = asyncio.create_task(worker_loop(settings, stop_event))
app.state.worker_stop_event = stop_event
app.state.worker_task = task
try:
yield
finally:
stop_event.set()
await task
app = FastAPI(title="Gitea Codex Review Bot", lifespan=lifespan)
def _load_repo_review_config_for_pr(gitea: GiteaClient, repo: str, pr_number: int) -> tuple[RepoReviewConfig, str]:
pr_ctx = gitea.get_pull_request(repo, pr_number)
head_sha = pr_ctx.head_sha
cfg_text = gitea.get_file_content(repo, ".codex-review.yml", ref=head_sha)
if cfg_text is None:
return RepoReviewConfig(configured=False), head_sha
return parse_repo_review_config_text(cfg_text, configured=True), head_sha
def _resolve_pr_head_sha(gitea: GiteaClient, repo: str, pr_number: int, fallback: str) -> str:
try:
return gitea.get_pull_request(repo, pr_number).head_sha
except Exception:
return fallback
def _render_landing_page() -> str:
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gitea Codex Review Bot</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100 antialiased">
<main class="mx-auto flex min-h-screen max-w-3xl items-center px-6 py-16">
<section class="w-full rounded-2xl border border-slate-800 bg-slate-900/70 p-8 shadow-2xl shadow-slate-950/40 backdrop-blur">
<p class="inline-flex rounded-full border border-emerald-400/30 bg-emerald-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-300">Webhook Service</p>
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-white sm:text-4xl">Gitea Codex Review Bot</h1>
<p class="mt-4 text-base leading-7 text-slate-300">This endpoint powers automated pull request review workflows for Gitea. It validates signed webhook events, queues review jobs, and posts structured feedback back to pull requests.</p>
<div class="mt-8 flex flex-wrap gap-3 text-sm">
<button id="health-button" type="button" class="rounded-lg border border-slate-700 bg-slate-800/80 px-3 py-2 text-slate-200 transition hover:border-slate-500 hover:bg-slate-700">Health: <code>/healthz</code></button>
<button id="failure-button" type="button" class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-amber-200 transition hover:border-amber-400 hover:bg-amber-500/20">Latest failure: <code>/healthz/latest-failure</code></button>
<button id="job-button" type="button" class="rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-3 py-2 text-cyan-200 transition hover:border-cyan-400 hover:bg-cyan-500/20">Latest job: <code>/healthz/latest-job</code></button>
<span class="rounded-lg border border-slate-700 bg-slate-800/80 px-3 py-2 text-slate-200">Webhook: <code>POST /webhook/gitea</code></span>
</div>
</section>
</main>
<div id="health-modal" class="fixed inset-0 z-10 hidden items-center justify-center bg-slate-950/70 px-6">
<section class="w-full max-w-md rounded-xl border border-slate-800 bg-slate-900 p-6 shadow-2xl shadow-slate-950/40">
<div class="flex items-start justify-between gap-4">
<h2 class="text-lg font-semibold text-white">Health Check</h2>
<button id="close-modal" type="button" class="rounded-md border border-slate-700 px-2 py-1 text-xs text-slate-300 transition hover:border-slate-500 hover:bg-slate-800">Close</button>
</div>
<p id="health-result" class="mt-4 text-sm leading-6 text-slate-300">Loading...</p>
</section>
</div>
<script>
const healthButton = document.getElementById("health-button");
const failureButton = document.getElementById("failure-button");
const jobButton = document.getElementById("job-button");
const healthModal = document.getElementById("health-modal");
const closeModal = document.getElementById("close-modal");
const healthResult = document.getElementById("health-result");
async function loadHealth() {
healthResult.textContent = "Loading...";
try {
const response = await fetch("/healthz", { headers: { Accept: "application/json" } });
const payload = await response.json();
const statusValue = typeof payload.status === "string" ? payload.status.toLowerCase() : "unknown";
const parsedStatus = statusValue === "ok" ? "Healthy" : "Unexpected";
healthResult.textContent = "Parsed status: " + parsedStatus + " (raw: " + JSON.stringify(payload) + ")";
} catch (_error) {
healthResult.textContent = "Could not load health check output.";
}
}
async function loadLatestFailure() {
healthResult.textContent = "Loading...";
try {
const response = await fetch("/healthz/latest-failure", { headers: { Accept: "application/json" } });
const payload = await response.json();
if (!payload.has_failed_job) {
healthResult.textContent = "No failed jobs found.";
return;
}
const failedAt = payload.failed_at ? payload.failed_at : "unknown";
const errorText = payload.error ? payload.error : "unknown";
healthResult.textContent =
"Latest failed job #" + payload.job_id +
" | " + payload.repo + "#" + payload.pr_number +
" | command=" + payload.command +
" | commit=" + payload.head_sha.slice(0, 7) +
" | failed_at=" + failedAt +
" | error=" + errorText;
} catch (_error) {
healthResult.textContent = "Could not load latest failure output.";
}
}
async function loadLatestJob() {
healthResult.textContent = "Loading...";
try {
const response = await fetch("/healthz/latest-job", { headers: { Accept: "application/json" } });
const payload = await response.json();
if (!payload.has_job) {
healthResult.textContent = "No jobs found yet.";
return;
}
const startedAt = payload.started_at ? payload.started_at : "not started";
const finishedAt = payload.finished_at ? payload.finished_at : "not finished";
const errorText = payload.error ? payload.error : "none";
const summary = payload.result_summary ? payload.result_summary : "none";
healthResult.textContent =
"Latest job #" + payload.job_id +
" | " + payload.repo + "#" + payload.pr_number +
" | command=" + payload.command +
" | status=" + payload.job_status +
" | commit=" + payload.head_sha.slice(0, 7) +
" | started_at=" + startedAt +
" | finished_at=" + finishedAt +
" | error=" + errorText +
" | summary=" + summary;
} catch (_error) {
healthResult.textContent = "Could not load latest job output.";
}
}
function showModal() {
healthModal.classList.remove("hidden");
healthModal.classList.add("flex");
}
function hideModal() {
healthModal.classList.add("hidden");
healthModal.classList.remove("flex");
}
healthButton.addEventListener("click", async function () {
showModal();
await loadHealth();
});
failureButton.addEventListener("click", async function () {
showModal();
await loadLatestFailure();
});
jobButton.addEventListener("click", async function () {
showModal();
await loadLatestJob();
});
closeModal.addEventListener("click", hideModal);
healthModal.addEventListener("click", function (event) {
if (event.target === healthModal) {
hideModal();
}
});
</script>
</body>
</html>"""
def _render_browser_404_page() -> str:
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Not Found</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100 antialiased">
<main class="mx-auto flex min-h-screen max-w-2xl items-center px-6 py-16">
<section class="w-full rounded-2xl border border-slate-800 bg-slate-900/70 p-8 text-center shadow-2xl shadow-slate-950/40 backdrop-blur">
<p class="text-sm font-medium uppercase tracking-[0.2em] text-slate-400">Error 404</p>
<h1 class="mt-3 text-3xl font-semibold text-white">Page not found</h1>
<p class="mt-4 text-slate-300">This service exposes only a small set of routes. Head back to the home page for a quick overview.</p>
<a href="/" class="mt-8 inline-flex rounded-lg border border-slate-700 bg-slate-800 px-4 py-2 text-sm font-medium text-slate-100 transition hover:border-slate-500 hover:bg-slate-700">Go to home</a>
</section>
</main>
</body>
</html>"""
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
if exc.status_code == status.HTTP_404_NOT_FOUND:
accept = request.headers.get("accept", "")
if "text/html" in accept.lower():
return HTMLResponse(content=_render_browser_404_page(), status_code=status.HTTP_404_NOT_FOUND)
return await http_exception_handler(request, exc)
@app.get("/", response_class=HTMLResponse)
def root() -> str:
return _render_landing_page()
@app.get("/healthz")
def healthz(settings: Settings = Depends(get_settings)) -> dict[str, str]:
_ = settings.gitea_base_url
return {"status": "ok"}
@app.get("/healthz/latest-failure")
def healthz_latest_failure(session: Session = Depends(get_session)) -> dict[str, Any]:
failed_job = session.execute(
select(ReviewJob).where(ReviewJob.status == JobStatus.failed).order_by(ReviewJob.created_at.desc(), ReviewJob.id.desc()).limit(1)
).scalar_one_or_none()
if not failed_job:
return {"status": "ok", "has_failed_job": False}
return {
"status": "ok",
"has_failed_job": True,
"job_id": failed_job.id,
"repo": failed_job.repo,
"pr_number": failed_job.pr_number,
"command": failed_job.command,
"head_sha": failed_job.head_sha,
"error": failed_job.last_error or "",
"failed_at": failed_job.finished_at.isoformat() if failed_job.finished_at else None,
}
@app.get("/healthz/latest-job")
def healthz_latest_job(session: Session = Depends(get_session)) -> dict[str, Any]:
latest_job = session.execute(select(ReviewJob).order_by(ReviewJob.created_at.desc(), ReviewJob.id.desc()).limit(1)).scalar_one_or_none()
if not latest_job:
return {"status": "ok", "has_job": False}
result_summary = ""
if isinstance(latest_job.result_json, dict):
summary = latest_job.result_json.get("summary")
if isinstance(summary, str):
result_summary = summary
return {
"status": "ok",
"has_job": True,
"job_id": latest_job.id,
"repo": latest_job.repo,
"pr_number": latest_job.pr_number,
"command": latest_job.command,
"head_sha": latest_job.head_sha,
"job_status": latest_job.status.value if hasattr(latest_job.status, "value") else str(latest_job.status),
"error": latest_job.last_error or "",
"result_summary": result_summary,
"created_at": latest_job.created_at.isoformat() if latest_job.created_at else None,
"started_at": latest_job.started_at.isoformat() if latest_job.started_at else None,
"finished_at": latest_job.finished_at.isoformat() if latest_job.finished_at else None,
}
@app.post("/webhook/gitea")
async def gitea_webhook(
request: Request,
x_gitea_event: str | None = Header(default=None),
x_gitea_delivery: str | None = Header(default=None),
x_gitea_signature: str | None = Header(default=None),
session: Session = Depends(get_session),
settings: Settings = Depends(get_settings),
) -> dict[str, Any]:
payload_bytes = await request.body()
if not verify_gitea_signature(payload_bytes, settings.gitea_webhook_secret.get_secret_value(), x_gitea_signature):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid signature")
event_name = (x_gitea_event or "").strip()
if event_name not in {"issue_comment", "pull_request_comment"}:
return {"accepted": False, "reason": "event ignored"}
payload = await request.json()
extracted = _extract_pr_event(payload, event_name)
if not extracted:
return {"accepted": False, "reason": "not a pull request comment"}
repo, pr_number, head_sha, comment_id, sender_username = extracted
if sender_username == settings.gitea_bot_username:
return {"accepted": False, "reason": "bot comment ignored"}
if repo not in settings.allowed_repo_set:
logger.info(
"Webhook ignored: repo not in ALLOWED_REPOS repo=%s pr=%s comment_id=%s sender=%s",
repo,
pr_number,
comment_id,
sender_username,
)
return {"accepted": False, "reason": "repo not allowed"}
comment_body = str(payload.get("comment", {}).get("body", "")).strip()
parsed_command = parse_command(comment_body, aliases=settings.bot_command_aliases)
if not parsed_command:
attempted_command = detect_prefixed_command(comment_body, aliases=settings.bot_command_aliases)
if attempted_command:
gitea = GiteaClient(settings)
if attempted_command == "fix":
gitea.post_issue_comment(repo, pr_number, "⚠️ `@codex fix` is no longer supported on this bot.")
return {"accepted": False, "reason": "unsupported command", "command": attempted_command}
gitea.post_issue_comment(
repo,
pr_number,
f"⚠️ Command `@codex {attempted_command}` is not supported. Try `@codex -h`.",
)
return {"accepted": False, "reason": "unsupported command", "command": attempted_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,
)
inserted = persist_webhook_event(
session,
delivery_id=x_gitea_delivery,
event_name=event_name,
repo=repo,
comment_id=comment_id,
payload=payload_bytes,
)
if not inserted:
return {"accepted": True, "reason": "duplicate event"}
gitea = GiteaClient(settings)
if parsed_command.name in {"review", "rerun"}:
head_sha = _resolve_pr_head_sha(gitea, repo, pr_number, head_sha)
repo_cfg: RepoReviewConfig | None = None
try:
repo_cfg, resolved_head_sha = _load_repo_review_config_for_pr(gitea, repo, pr_number)
head_sha = resolved_head_sha
except Exception:
repo_cfg = None
if head_sha == "unknown":
head_sha = _resolve_pr_head_sha(gitea, repo, pr_number, head_sha)
if repo_cfg and not repo_cfg.enabled:
gitea.post_issue_comment(repo, pr_number, format_disabled_ack())
return {"accepted": True, "reason": "review disabled by repo config"}
if parsed_command.name != "rerun":
remaining = cooldown_remaining_seconds(session, repo, pr_number, settings.cooldown_seconds)
if remaining > 0:
gitea.post_issue_comment(repo, pr_number, format_cooldown_ack(remaining))
return {"accepted": True, "reason": "cooldown active", "cooldown_seconds_remaining": remaining}
job = enqueue_job(
session,
repo=repo,
pr_number=pr_number,
head_sha=head_sha,
trigger_comment_id=comment_id,
trigger_comment_body=comment_body,
requested_by=sender_username,
command=parsed_command,
)
gitea.post_issue_comment(repo, pr_number, format_queue_ack(head_sha))
return {"accepted": True, "job_id": job.id, "status": "queued"}
if parsed_command.name in {"explain", "ignore", "help"}:
job = enqueue_job(
session,
repo=repo,
pr_number=pr_number,
head_sha=head_sha,
trigger_comment_id=comment_id,
trigger_comment_body=comment_body,
requested_by=sender_username,
command=parsed_command,
)
return {"accepted": True, "job_id": job.id, "status": "queued"}
gitea.post_issue_comment(repo, pr_number, format_unsupported_ack(parsed_command))
return {"accepted": False, "reason": "unsupported command"}
-114
View File
@@ -1,114 +0,0 @@
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from gitea_codex_bot.db import Base
class JobStatus(str, enum.Enum):
queued = "queued"
running = "running"
succeeded = "succeeded"
failed = "failed"
skipped = "skipped"
class RunStatus(str, enum.Enum):
running = "running"
succeeded = "succeeded"
failed = "failed"
skipped = "skipped"
class WebhookEvent(Base):
__tablename__ = "webhook_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
delivery_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
event_name: Mapped[str] = mapped_column(String(128), nullable=False)
repo: Mapped[str] = mapped_column(String(255), nullable=False)
comment_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint("delivery_id", name="uq_webhook_events_delivery_id"),
UniqueConstraint("repo", "comment_id", name="uq_webhook_events_repo_comment"),
)
class ReviewJob(Base):
__tablename__ = "review_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
repo: Mapped[str] = mapped_column(String(255), nullable=False)
pr_number: Mapped[int] = mapped_column(Integer, nullable=False)
head_sha: Mapped[str] = mapped_column(String(64), nullable=False)
trigger_comment_id: Mapped[int] = mapped_column(Integer, nullable=False)
trigger_comment_body: Mapped[str | None] = mapped_column(Text, nullable=True)
command: Mapped[str] = mapped_column(String(64), nullable=False, default="review")
command_args: Mapped[str | None] = mapped_column(Text, nullable=True)
requested_by: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[JobStatus] = mapped_column(Enum(JobStatus), nullable=False, default=JobStatus.queued)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
runs: Mapped[list["ReviewRun"]] = relationship(back_populates="job", cascade="all, delete-orphan")
__table_args__ = (
Index("ix_review_jobs_lookup", "repo", "pr_number", "head_sha", "status", "created_at"),
UniqueConstraint("repo", "trigger_comment_id", name="uq_review_jobs_repo_trigger_comment"),
)
class ReviewRun(Base):
__tablename__ = "review_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
job_id: Mapped[int] = mapped_column(ForeignKey("review_jobs.id", ondelete="CASCADE"), nullable=False)
status: Mapped[RunStatus] = mapped_column(Enum(RunStatus), nullable=False, default=RunStatus.running)
runner_container_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
job: Mapped["ReviewJob"] = relationship(back_populates="runs")
__table_args__ = (Index("ix_review_runs_job_status", "job_id", "status"),)
class BotComment(Base):
__tablename__ = "bot_comments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
repo: Mapped[str] = mapped_column(String(255), nullable=False)
pr_number: Mapped[int] = mapped_column(Integer, nullable=False)
head_sha: Mapped[str] = mapped_column(String(64), nullable=False)
gitea_comment_id: Mapped[int] = mapped_column(Integer, nullable=False)
marker: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
__table_args__ = (
UniqueConstraint("repo", "pr_number", "marker", name="uq_bot_comments_marker"),
Index("ix_bot_comments_repo_pr", "repo", "pr_number"),
)
-62
View File
@@ -1,62 +0,0 @@
from __future__ import annotations
import re
from collections.abc import Iterable
from gitea_codex_bot.types import ParsedCommand
PREFIX_RE = re.compile(r"^@([^\s]+)\s+(.+)$", re.IGNORECASE | re.DOTALL)
HELP_ALIASES = {"-h", "--help", "help"}
SUPPORTED_COMMANDS = {"review", "explain", "ignore", "rerun"}
def detect_prefixed_command(body: str, aliases: Iterable[str] | None = None) -> str | None:
stripped = body.strip()
match = PREFIX_RE.match(stripped)
if not match:
return None
command_alias = match.group(1).lstrip("@").lower()
allowed_aliases = {alias.lstrip("@").lower() for alias in (aliases or {"codex"})}
if command_alias not in allowed_aliases:
return None
remainder = match.group(2).strip()
if not remainder:
return None
return remainder.split(maxsplit=1)[0].lower()
def parse_command(body: str, aliases: Iterable[str] | None = None) -> ParsedCommand | None:
stripped = body.strip()
match = PREFIX_RE.match(stripped)
if not match:
return None
command_alias = match.group(1).lstrip("@").lower()
allowed_aliases = {alias.lstrip("@").lower() for alias in (aliases or {"codex"})}
if command_alias not in allowed_aliases:
return None
remainder = match.group(2).strip()
if not remainder:
return None
parts = remainder.split(maxsplit=1)
raw_name = parts[0].lower()
rest = parts[1].strip() if len(parts) > 1 else ""
if raw_name in HELP_ALIASES:
return ParsedCommand(name="help", raw=stripped, arguments=[token for token in rest.split() if token])
if raw_name not in SUPPORTED_COMMANDS:
return None
name = raw_name
tokens = [token for token in rest.split() if token]
parsed = ParsedCommand(name=name, raw=stripped, arguments=tokens)
if name == "review":
if "--full" in tokens:
parsed.full = True
parsed.mode = "full"
parsed.mode_explicit = True
for mode in ("security", "performance", "tests"):
if mode in tokens:
parsed.mode = mode
parsed.mode_explicit = True
break
return parsed
-40
View File
@@ -1,40 +0,0 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from gitea_codex_bot.models import BotComment
REVIEW_MARKER = "codex-review"
def get_persistent_review_comment_id(session: Session, repo: str, pr_number: int) -> int | None:
row = session.execute(
select(BotComment)
.where(BotComment.repo == repo, BotComment.pr_number == pr_number, BotComment.marker == REVIEW_MARKER)
.limit(1)
).scalar_one_or_none()
return row.gitea_comment_id if row else None
def upsert_persistent_review_comment_id(
session: Session,
*,
repo: str,
pr_number: int,
head_sha: str,
comment_id: int,
) -> None:
row = session.execute(
select(BotComment)
.where(BotComment.repo == repo, BotComment.pr_number == pr_number, BotComment.marker == REVIEW_MARKER)
.limit(1)
).scalar_one_or_none()
if not row:
row = BotComment(repo=repo, pr_number=pr_number, head_sha=head_sha, gitea_comment_id=comment_id, marker=REVIEW_MARKER)
session.add(row)
else:
row.head_sha = head_sha
row.gitea_comment_id = comment_id
session.commit()
-135
View File
@@ -1,135 +0,0 @@
from __future__ import annotations
import base64
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import httpx
from gitea_codex_bot.config import Settings
@dataclass(slots=True)
class PullRequestContext:
repo: str
pr_number: int
base_ref: str
base_sha: str
head_ref: str
head_sha: str
clone_url: str
html_url: str
is_fork: bool
base_clone_url: str | None = None
head_clone_url: str | None = None
class GiteaClient:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.base_url = settings.gitea_base_url
self.headers = {
"Authorization": f"token {settings.gitea_token.get_secret_value()}",
"Accept": "application/json",
"Content-Type": "application/json",
}
def _request(self, method: str, path: str, *, json_body: dict[str, Any] | None = None) -> Any:
with httpx.Client(timeout=20.0) as client:
response = client.request(
method,
f"{self.base_url}{path}",
headers=self.headers,
json=json_body,
)
response.raise_for_status()
if response.status_code == 204:
return None
return response.json()
@staticmethod
def split_repo(repo: str) -> tuple[str, str]:
owner, name = repo.split("/", 1)
return owner, name
def get_pull_request(self, repo: str, pr_number: int) -> PullRequestContext:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
payload = self._request("GET", f"/api/v1/repos/{encoded_owner}/{encoded_name}/pulls/{pr_number}")
base_clone_url = payload["base"]["repo"]["clone_url"]
head_clone_url = payload["head"]["repo"]["clone_url"]
return PullRequestContext(
repo=repo,
pr_number=pr_number,
base_ref=payload["base"]["ref"],
base_sha=payload["base"]["sha"],
head_ref=payload["head"]["ref"],
head_sha=payload["head"]["sha"],
clone_url=head_clone_url,
base_clone_url=base_clone_url,
head_clone_url=head_clone_url,
html_url=payload["html_url"],
is_fork=bool(payload["head"]["repo"]["full_name"] != payload["base"]["repo"]["full_name"]),
)
def post_issue_comment(self, repo: str, pr_number: int, body: str) -> int:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
payload = self._request(
"POST",
f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/{pr_number}/comments",
json_body={"body": body},
)
return int(payload["id"])
def edit_issue_comment(self, repo: str, comment_id: int, body: str) -> int:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
payload = self._request(
"PATCH",
f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/comments/{comment_id}",
json_body={"body": body},
)
return int(payload["id"])
def get_issue_comment(self, repo: str, comment_id: int) -> dict[str, Any]:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
payload = self._request(
"GET",
f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/comments/{comment_id}",
)
return dict(payload)
def list_issue_comments(self, repo: str, pr_number: int) -> list[dict[str, Any]]:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
payload = self._request("GET", f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/{pr_number}/comments")
return list(payload)
def get_file_content(self, repo: str, path: str, *, ref: str) -> str | None:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
encoded_path = quote(path, safe="")
try:
payload = self._request(
"GET",
f"/api/v1/repos/{encoded_owner}/{encoded_name}/contents/{encoded_path}?ref={quote(ref, safe='')}",
)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return None
raise
content = payload.get("content")
encoding = payload.get("encoding")
if not isinstance(content, str) or encoding != "base64":
return None
decoded = base64.b64decode(content.encode("ascii"))
return decoded.decode("utf-8", errors="ignore")
-245
View File
@@ -1,245 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from gitea_codex_bot.models import JobStatus, ReviewJob, ReviewRun, RunStatus, WebhookEvent
from gitea_codex_bot.services.security import payload_digest
from gitea_codex_bot.types import ParsedCommand
logger = logging.getLogger(__name__)
def persist_webhook_event(
session: Session,
*,
delivery_id: str | None,
event_name: str,
repo: str,
comment_id: int | None,
payload: bytes,
) -> bool:
event = WebhookEvent(
delivery_id=delivery_id,
event_name=event_name,
repo=repo,
comment_id=comment_id,
payload_sha256=payload_digest(payload),
)
session.add(event)
try:
session.commit()
return True
except IntegrityError:
session.rollback()
return False
def cooldown_remaining_seconds(session: Session, repo: str, pr_number: int, cooldown_seconds: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(seconds=cooldown_seconds)
row = session.execute(
select(ReviewJob)
.where(ReviewJob.repo == repo, ReviewJob.pr_number == pr_number, ReviewJob.created_at >= cutoff)
.order_by(ReviewJob.created_at.desc())
.limit(1)
).scalar_one_or_none()
if not row:
return 0
created_at = row.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - created_at).total_seconds()
remaining = int(max(cooldown_seconds - age, 0))
return remaining
def enqueue_job(
session: Session,
*,
repo: str,
pr_number: int,
head_sha: str,
trigger_comment_id: int,
trigger_comment_body: str | None,
requested_by: str,
command: ParsedCommand,
) -> ReviewJob:
job = ReviewJob(
repo=repo,
pr_number=pr_number,
head_sha=head_sha,
trigger_comment_id=trigger_comment_id,
trigger_comment_body=trigger_comment_body,
command=command.name,
command_args=" ".join(command.arguments) if command.arguments else None,
requested_by=requested_by,
status=JobStatus.queued,
)
session.add(job)
session.commit()
session.refresh(job)
logger.info(
"Job enqueued id=%s repo=%s pr=%s command=%s head_sha=%s trigger_comment_id=%s requested_by=%s",
job.id,
job.repo,
job.pr_number,
job.command,
job.head_sha,
job.trigger_comment_id,
job.requested_by,
)
return job
def claim_next_job(session: Session) -> ReviewJob | None:
recover_stuck_running_jobs(session, lease_timeout_seconds=300, max_retries=2)
job = session.execute(
select(ReviewJob).where(ReviewJob.status == JobStatus.queued).order_by(ReviewJob.created_at.asc()).limit(1).with_for_update(skip_locked=True)
).scalar_one_or_none()
if not job:
session.rollback()
return None
job.status = JobStatus.running
job.started_at = datetime.now(timezone.utc)
run = ReviewRun(job_id=job.id, status=RunStatus.running)
session.add(run)
session.commit()
session.refresh(job)
logger.info(
"Job claimed id=%s repo=%s pr=%s command=%s head_sha=%s status=%s",
job.id,
job.repo,
job.pr_number,
job.command,
job.head_sha,
job.status.value if hasattr(job.status, "value") else job.status,
)
return job
def recover_stuck_running_jobs(session: Session, *, lease_timeout_seconds: int, max_retries: int) -> int:
now = datetime.now(timezone.utc)
lease_cutoff = now - timedelta(seconds=lease_timeout_seconds)
stale_running_jobs = session.execute(
select(ReviewJob)
.where(
ReviewJob.status == JobStatus.running,
ReviewJob.started_at.is_not(None),
ReviewJob.started_at <= lease_cutoff,
)
.with_for_update(skip_locked=True)
).scalars().all()
if not stale_running_jobs:
return 0
recovered = 0
for job in stale_running_jobs:
attempt_count = _count_job_attempts(session, job.id)
timeout_error = (
f"Job lease timed out after {lease_timeout_seconds}s on attempt {attempt_count}. "
"Recovered by queue watchdog."
)
latest_run = (
session.execute(select(ReviewRun).where(ReviewRun.job_id == job.id).order_by(ReviewRun.id.desc()).limit(1)).scalar_one_or_none()
)
if latest_run and latest_run.status == RunStatus.running:
latest_run.status = RunStatus.failed
latest_run.finished_at = now
latest_run.error_message = timeout_error
retries_used = max(attempt_count - 1, 0)
if retries_used < max_retries:
job.status = JobStatus.queued
job.started_at = None
job.finished_at = None
job.last_error = timeout_error
logger.warning(
"Recovered timed-out running job id=%s by requeueing attempt=%s retries_used=%s/%s",
job.id,
attempt_count,
retries_used,
max_retries,
)
else:
job.status = JobStatus.failed
job.finished_at = now
job.last_error = timeout_error
logger.error(
"Recovered timed-out running job id=%s by failing permanently attempt=%s retries_used=%s/%s",
job.id,
attempt_count,
retries_used,
max_retries,
)
recovered += 1
session.commit()
return recovered
def finish_job(
session: Session,
*,
job_id: int,
success: bool,
skipped: bool,
result: dict | None,
error_message: str | None,
) -> None:
job = session.get(ReviewJob, job_id)
if not job:
return
latest_run = (
session.execute(select(ReviewRun).where(ReviewRun.job_id == job_id).order_by(ReviewRun.id.desc()).limit(1)).scalar_one_or_none()
)
if skipped:
job.status = JobStatus.skipped
run_status = RunStatus.skipped
elif success:
job.status = JobStatus.succeeded
run_status = RunStatus.succeeded
else:
attempt_count = _count_job_attempts(session, job_id)
retries_used = max(attempt_count - 1, 0)
if retries_used < 2:
job.status = JobStatus.queued
else:
job.status = JobStatus.failed
run_status = RunStatus.failed
now = datetime.now(timezone.utc)
if job.status == JobStatus.queued:
job.started_at = None
job.finished_at = None
else:
job.finished_at = now
job.last_error = error_message
if result is not None:
job.result_json = result
if latest_run:
latest_run.status = run_status
latest_run.finished_at = now
latest_run.result_json = result
latest_run.error_message = error_message
session.commit()
logger.info(
"Job finished id=%s repo=%s pr=%s status=%s run_status=%s skipped=%s error_present=%s",
job.id,
job.repo,
job.pr_number,
job.status.value if hasattr(job.status, "value") else job.status,
run_status.value if hasattr(run_status, "value") else run_status,
skipped,
bool(error_message),
)
def _count_job_attempts(session: Session, job_id: int) -> int:
attempts = session.execute(select(func.count(ReviewRun.id)).where(ReviewRun.job_id == job_id)).scalar_one()
return int(attempts or 0)
@@ -1,39 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import yaml
@dataclass(slots=True)
class RepoReviewConfig:
configured: bool = True
enabled: bool = True
default_mode: str = "full"
max_diff_bytes: int = 200000
include_tests: bool = False
focus: list[str] = field(default_factory=lambda: ["correctness", "security", "maintainability"])
ignore: list[str] = field(default_factory=list)
def load_repo_review_config(repo_root: Path) -> RepoReviewConfig:
path = repo_root / ".codex-review.yml"
if not path.exists():
return RepoReviewConfig(configured=False)
return parse_repo_review_config_text(path.read_text(encoding="utf-8"), configured=True)
def parse_repo_review_config_text(text: str, *, configured: bool) -> RepoReviewConfig:
raw = yaml.safe_load(text) or {}
review = raw.get("review", {}) or {}
default_mode = str(review.get("default_mode", "full")).strip().lower() or "full"
return RepoReviewConfig(
configured=configured,
enabled=bool(raw.get("enabled", True)),
default_mode=default_mode,
max_diff_bytes=int(review.get("max_diff_bytes", 200000)),
include_tests=bool(review.get("include_tests", False)),
focus=list(review.get("focus", ["correctness", "security", "maintainability"])),
ignore=list(raw.get("ignore", [])),
)
@@ -1,151 +0,0 @@
from __future__ import annotations
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:
short_sha = head_sha[:7]
return f"👀 Codex review queued for commit `{short_sha}`."
def format_cooldown_ack(seconds: int) -> str:
return f"⏳ Cooldown active. Please wait {seconds}s before requesting another review on this PR."
def format_disabled_ack() -> str:
return "🚫 Review is disabled by `.codex-review.yml` for this repository."
def format_unsupported_ack(command: ParsedCommand) -> str:
return f"⚠️ Command `@codex {command.name}` is not enabled on this repository."
def format_result_comment(head_sha: str, result: dict, *, repo_configured: bool = True) -> str:
usage_note = _format_usage_note(result)
missing_config_note = _format_missing_config_note(repo_configured)
markdown_comment = result.get("markdown_comment")
if isinstance(markdown_comment, str) and markdown_comment.strip():
body = markdown_comment.strip()
details = _format_structured_details(result)
if details:
body = f"{body}\n\n---\n\n{details}"
if usage_note:
body = f"{body}\n\n{usage_note}"
if missing_config_note:
body = f"{body}\n\n{missing_config_note}"
return _inject_head_sha_marker(head_sha, body)
verdict = result.get("verdict", "has_issues")
confidence = float(result.get("confidence", 0.0))
summary = str(result.get("summary", "No summary returned."))
findings = result.get("findings", []) or []
lines = [f"<!-- codex-review:head_sha={head_sha} -->", "## Codex Review", "", f"Verdict: `{verdict}`", f"Confidence: `{confidence:.2f}`", "", summary, ""]
if not findings:
lines.append("No blocking issues found.")
else:
lines.append("Findings:")
for idx, finding in enumerate(findings, start=1):
severity = finding.get("severity", "unknown")
file_path = finding.get("file", "unknown")
line_start = finding.get("line_start", "?")
line_end = finding.get("line_end", line_start)
title = finding.get("title", "Issue")
body = finding.get("body", "")
suggestion = finding.get("suggestion", "")
lines.extend(
[
f"{idx}. `{file_path}:{line_start}-{line_end}` ({severity})",
f" {title}",
f" {body}",
f" Suggestion: {suggestion}" if suggestion else " Suggestion: n/a",
]
)
body = "\n".join(lines).strip()
if usage_note:
body = f"{body}\n\n{usage_note}"
if missing_config_note:
body = f"{body}\n\n{missing_config_note}"
return _inject_head_sha_marker(head_sha, body)
def _format_usage_note(result: dict) -> str:
meta = result.get("_meta")
if not isinstance(meta, dict):
return ""
model = meta.get("model")
model_text = model.strip() if isinstance(model, str) and model.strip() else "unknown"
usage = meta.get("usage")
if not isinstance(usage, dict):
return f"_Note: model `{model_text}`._"
input_tokens = usage.get("input_tokens")
output_tokens = usage.get("output_tokens")
total_tokens = usage.get("total_tokens")
parts = [f"model `{model_text}`"]
if isinstance(input_tokens, int):
parts.append(f"input `{input_tokens}`")
if isinstance(output_tokens, int):
parts.append(f"output `{output_tokens}`")
if isinstance(total_tokens, int):
parts.append(f"total `{total_tokens}`")
return f"_Note: {', '.join(parts)} tokens used._"
def _format_missing_config_note(repo_configured: bool) -> str:
if repo_configured:
return ""
return "> ️.codex-review.yml is not configured"
def _format_structured_details(result: dict) -> str:
verdict = str(result.get("verdict", "has_issues"))
summary = str(result.get("summary", "No summary returned."))
confidence_raw = result.get("confidence", 0.0)
try:
confidence = float(confidence_raw)
except (TypeError, ValueError):
confidence = 0.0
findings = result.get("findings", []) or []
lines = ["### Structured Findings", "", f"Verdict: `{verdict}`", f"Confidence: `{confidence:.2f}`", "", summary, ""]
if not findings:
lines.append("No blocking issues found.")
return "\n".join(lines).strip()
lines.append("Findings:")
for idx, finding in enumerate(findings, start=1):
if not isinstance(finding, dict):
lines.extend([f"{idx}. `unknown` (unknown)", " Issue", f" {finding}", " Suggestion: n/a"])
continue
severity = finding.get("severity", "unknown")
file_path = finding.get("file", "unknown")
line_start = finding.get("line_start", "?")
line_end = finding.get("line_end", line_start)
title = finding.get("title", "Issue")
body = finding.get("body", "")
suggestion = finding.get("suggestion", "")
lines.extend(
[
f"{idx}. `{file_path}:{line_start}-{line_end}` ({severity})",
f" {title}",
f" {body}",
f" Suggestion: {suggestion}" if suggestion else " Suggestion: n/a",
]
)
return "\n".join(lines).strip()
-21
View File
@@ -1,21 +0,0 @@
from __future__ import annotations
from typing import Any
class ReviewError(RuntimeError):
pass
def normalize_review_result(result: Any) -> dict[str, Any]:
if not isinstance(result, dict):
raise ReviewError(f"Invalid review result type: {type(result)!r}")
if "findings" not in result:
result["findings"] = []
if "summary" not in result:
result["summary"] = "No summary returned."
if "verdict" not in result:
result["verdict"] = "has_issues"
if "confidence" not in result:
result["confidence"] = 0.5
return result
-16
View File
@@ -1,16 +0,0 @@
from __future__ import annotations
import hashlib
import hmac
def verify_gitea_signature(payload: bytes, secret: str, received_signature: str | None) -> bool:
if not received_signature:
return False
expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
normalized = received_signature.removeprefix("sha256=").strip()
return hmac.compare_digest(expected, normalized)
def payload_digest(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
-17
View File
@@ -1,17 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
CommandName = Literal["review", "explain", "ignore", "rerun", "help"]
@dataclass(slots=True)
class ParsedCommand:
name: CommandName
raw: str
mode: str = "summary"
mode_explicit: bool = False
full: bool = False
arguments: list[str] = field(default_factory=list)
@@ -1,500 +0,0 @@
from __future__ import annotations
import base64
import json
import logging
import os
import re
import shlex
import subprocess
import uuid
from pathlib import Path
from typing import Any
from gitea_codex_bot.config import Settings
from gitea_codex_bot.services.gitea import GiteaClient, PullRequestContext
from gitea_codex_bot.services.repo_config import RepoReviewConfig, parse_repo_review_config_text
from gitea_codex_bot.services.reviewer import normalize_review_result
from gitea_codex_bot.types import ParsedCommand
CONTAINER_CODEX_HOME = "/root/.codex"
REVIEW_OUTPUT_FILE = "/tmp/codex-review-result.json"
REVIEW_SCHEMA_FILE = "/tmp/codex-review-schema.json"
REVIEW_EMITTED_FILE = "/tmp/codex-review-emitted.flag"
RESULT_START_MARKER = "__CODEX_REVIEW_RESULT_BEGIN__"
RESULT_END_MARKER = "__CODEX_REVIEW_RESULT_END__"
logger = logging.getLogger(__name__)
REVIEW_RESULT_SCHEMA: dict[str, Any] = {
"type": "object",
"additionalProperties": False,
"required": ["verdict", "confidence", "summary", "findings", "markdown_comment"],
"properties": {
"verdict": {"type": "string", "enum": ["correct", "has_issues"]},
"confidence": {"type": "number"},
"summary": {"type": "string"},
"markdown_comment": {"type": "string"},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["severity", "file", "line_start", "line_end", "title", "body", "suggestion"],
"properties": {
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
"file": {"type": "string"},
"line_start": {"type": "integer"},
"line_end": {"type": "integer"},
"title": {"type": "string"},
"body": {"type": "string"},
"suggestion": {"type": ["string", "null"]},
},
},
},
},
}
def run_review_ephemeral(
settings: Settings,
*,
repo: str,
pr_number: int,
command: ParsedCommand,
) -> tuple[dict[str, Any], RepoReviewConfig]:
gitea = GiteaClient(settings)
pr = gitea.get_pull_request(repo, pr_number)
repo_cfg = _load_repo_review_config_from_gitea(gitea, repo, pr.head_sha)
_apply_repo_default_review_mode(command, repo_cfg)
review_prompt = _build_exec_review_prompt(command, repo_cfg, pr)
container_name = f"codex-review-{uuid.uuid4().hex[:12]}"
marker_nonce = uuid.uuid4().hex
result_start_marker = f"{RESULT_START_MARKER}_{marker_nonce}"
result_end_marker = f"{RESULT_END_MARKER}_{marker_nonce}"
extra_env: dict[str, str] = {
"GITEA_TOKEN": settings.gitea_token.get_secret_value(),
"GITEA_GIT_USERNAME": settings.gitea_bot_username,
}
if settings.openai_api_key:
extra_env["OPENAI_API_KEY"] = settings.openai_api_key.get_secret_value()
if settings.openai_org_id:
extra_env["OPENAI_ORG_ID"] = settings.openai_org_id
if settings.openai_project_id:
extra_env["OPENAI_PROJECT_ID"] = settings.openai_project_id
if settings.codex_auth_mode == "chatgpt":
extra_env["CODEX_AUTH_JSON_B64"] = _load_codex_auth_json_b64(settings)
try:
completed = _run_ephemeral_container(
settings,
pr=pr,
container_name=container_name,
review_prompt=review_prompt,
result_start_marker=result_start_marker,
result_end_marker=result_end_marker,
extra_env=extra_env,
)
if completed.returncode != 0:
raise RuntimeError(_format_runner_failure(completed))
parsed = _parse_review_result_from_stdout_artifact(
completed.stdout,
result_start_marker=result_start_marker,
result_end_marker=result_end_marker,
)
parsed["_meta"] = _extract_result_meta_from_codex_stdout(completed.stdout, settings)
return normalize_review_result(parsed), repo_cfg
except Exception as exc:
logger.warning("Ephemeral runner failed without host fallback: %s", exc)
return _ephemeral_runner_failure_result(exc, settings.codex_auth_mode), repo_cfg
def _run_ephemeral_container(
settings: Settings,
*,
pr: PullRequestContext,
container_name: str,
review_prompt: str,
result_start_marker: str,
result_end_marker: str,
extra_env: dict[str, str],
) -> subprocess.CompletedProcess[str]:
install_and_run = _build_install_and_run_command(
settings,
pr=pr,
review_prompt=review_prompt,
result_start_marker=result_start_marker,
result_end_marker=result_end_marker,
)
cmd = _build_docker_command(settings, container_name=container_name, install_and_run=install_and_run)
return subprocess.run(
cmd,
text=True,
check=False,
capture_output=True,
timeout=settings.max_review_minutes * 60,
env={**os.environ, **extra_env},
)
def _build_install_and_run_command(
settings: Settings,
*,
pr: PullRequestContext,
review_prompt: str,
result_start_marker: str,
result_end_marker: str,
) -> str:
runner_fallback_json = json.dumps(
{
"verdict": "has_issues",
"confidence": 0.67,
"summary": "Ephemeral codex execution failed before producing a review result.",
"markdown_comment": "Ephemeral codex execution failed before producing a review result.",
"findings": [
{
"severity": "high",
"file": "runner",
"line_start": 1,
"line_end": 1,
"title": "Ephemeral review runner failed",
"body": "codex exec failed before emitting a valid structured artifact.",
"suggestion": "Check ephemeral runner logs for auth/model/network issues and rerun @codex review.",
}
],
},
separators=(",", ":"),
)
steps = [
"set -euo pipefail",
f"rm -f {shlex.quote(REVIEW_EMITTED_FILE)}",
"emit_review_artifact() { "
"rc=\"$1\"; "
f"if [ ! -s {shlex.quote(REVIEW_OUTPUT_FILE)} ]; then "
f"cat > {shlex.quote(REVIEW_OUTPUT_FILE)} <<'JSON'\n{runner_fallback_json}\nJSON\n"
"fi; "
f'if [ ! -f {shlex.quote(REVIEW_EMITTED_FILE)} ]; then echo "{result_start_marker}"; cat {shlex.quote(REVIEW_OUTPUT_FILE)}; echo "{result_end_marker}"; touch {shlex.quote(REVIEW_EMITTED_FILE)}; fi; '
"return \"$rc\"; "
"}",
"trap 'rc=$?; set +e; emit_review_artifact \"$rc\"; exit \"$rc\"' EXIT",
]
if settings.codex_auth_mode != "chatgpt":
steps.extend(
[
'if [ -z "${OPENAI_API_KEY:-}" ]; then echo "OPENAI_API_KEY missing in runner env" >&2; exit 8; fi',
]
)
steps.extend(
[
'if [ -z "${GITEA_TOKEN:-}" ]; then echo "GITEA_TOKEN missing in runner env" >&2; exit 8; fi',
'if [ -z "${GITEA_GIT_USERNAME:-}" ]; then echo "GITEA_GIT_USERNAME missing in runner env" >&2; exit 8; fi',
]
)
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 git >/tmp/apt-install.log 2>&1 || { rc=$?; echo 'ca-certificates/git 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@latest >/tmp/codex-install.log 2>&1 || { rc=$?; echo 'codex install failed'; tail -n 200 /tmp/codex-install.log || true; exit $rc; }",
"codex --version >/tmp/codex-version.log 2>&1 || { rc=$?; echo 'codex version check failed'; tail -n 40 /tmp/codex-version.log || true; exit $rc; }",
]
)
schema_json = json.dumps(REVIEW_RESULT_SCHEMA, separators=(",", ":"))
steps.extend(
[
f"cat > {REVIEW_SCHEMA_FILE} <<'JSON'\n{schema_json}\nJSON",
'auth_b64="$(printf "%s" "${GITEA_GIT_USERNAME}:${GITEA_TOKEN}" | base64 | tr -d \'\\n\')"',
f'git -c http.extraHeader="Authorization: Basic $auth_b64" clone --no-tags --depth 80 {shlex.quote(pr.clone_url)} /work/repo',
"cd /work/repo",
"fetch_required() { "
"remote=\"$1\"; ref=\"$2\"; sha=\"$3\"; label=\"$4\"; "
"if git -c http.extraHeader=\"Authorization: Basic $auth_b64\" fetch --no-tags \"$remote\" \"$ref\"; then return 0; fi; "
"if git -c http.extraHeader=\"Authorization: Basic $auth_b64\" fetch --no-tags \"$remote\" \"$sha\"; then return 0; fi; "
"echo \"Failed to fetch $label from remote '$remote' using ref '$ref' or sha '$sha'\" >&2; "
"return 7; "
"}",
f"base_remote={'upstream' if pr.base_clone_url and pr.base_clone_url != pr.clone_url else 'origin'}",
f"if [ \"$base_remote\" = \"upstream\" ]; then git remote add upstream {shlex.quote(pr.base_clone_url or '')}; fi",
f"fetch_required origin {shlex.quote(pr.head_ref)} {shlex.quote(pr.head_sha)} head",
f"fetch_required \"$base_remote\" {shlex.quote(pr.base_ref)} {shlex.quote(pr.base_sha)} base",
f"git checkout --detach {shlex.quote(pr.head_sha)}",
'resolved_head="$(git rev-parse HEAD)"',
f'if [ "$resolved_head" != {shlex.quote(pr.head_sha)} ]; then echo "Checked out SHA mismatch: expected {pr.head_sha}, got $resolved_head" >&2; exit 9; fi',
"unset GITEA_TOKEN auth_b64",
"git config --global --unset-all http.extraHeader >/dev/null 2>&1 || true",
]
)
model = settings.openai_review_model.strip()
codex_exec_parts = [
"codex exec",
"--sandbox",
"danger-full-access",
"--json",
"--output-schema",
shlex.quote(REVIEW_SCHEMA_FILE),
"-o",
shlex.quote(REVIEW_OUTPUT_FILE),
]
if model:
codex_exec_parts.append(f"-m {shlex.quote(model)}")
codex_exec_parts.append(shlex.quote(review_prompt))
steps.extend(
[
"set +e",
"codex_rc=0",
" ".join(codex_exec_parts) + ' || codex_rc="$?"',
"set -e",
f'if [ "$codex_rc" -ne 0 ] || [ ! -s {shlex.quote(REVIEW_OUTPUT_FILE)} ]; then cat > {REVIEW_OUTPUT_FILE} <<\'JSON\'\n{runner_fallback_json}\nJSON\nfi',
"emit_review_artifact 0",
]
)
return "\n".join(steps)
def _apply_repo_default_review_mode(command: ParsedCommand, repo_cfg: RepoReviewConfig) -> None:
if command.name != "review" or command.mode_explicit:
return
configured_mode = repo_cfg.default_mode
command.mode = configured_mode if configured_mode in {"summary", "security", "performance", "tests", "full"} else "summary"
def _build_exec_review_prompt(command: ParsedCommand, repo_cfg: RepoReviewConfig, pr: PullRequestContext) -> str:
raw = (command.raw or "").strip()
remainder = raw
match = re.match(r"^@[^\s]+\s+\S+\s*(.*)$", raw, flags=re.IGNORECASE | re.DOTALL)
if match:
remainder = match.group(1).strip()
intent = remainder or "review this pull request and report introduced issues."
focus = ", ".join(repo_cfg.focus) if repo_cfg.focus else "correctness, security, maintainability"
ignore = ", ".join(repo_cfg.ignore) if repo_cfg.ignore else "(none)"
mode = command.mode if command.name in {"review", "rerun"} else "summary"
allow_test_execution = command.mode == "tests" or repo_cfg.include_tests
tests_policy = (
"Tests may be executed for this run because tests mode/include_tests is explicitly enabled."
if allow_test_execution
else "Do not run tests, benchmarks, or other executables. Review changes statically unless explicitly asked."
)
return "\n".join(
[
f"review: {intent}",
"Review only issues introduced by this PR.",
f"Compare exactly these commits: base `{pr.base_sha}` ... head `{pr.head_sha}`.",
"Use local git data from this checkout; do not review unrelated history.",
f"Requested mode: {mode}.",
f"Focus areas: {focus}.",
f"Ignore patterns: {ignore}.",
f"Include tests setting: {repo_cfg.include_tests}.",
tests_policy,
f"Full review requested: {command.full}.",
"Return strict JSON matching the provided output schema.",
]
)
def _build_docker_command(settings: Settings, *, container_name: str, install_and_run: str) -> list[str]:
cmd = [
"docker",
"run",
"--rm",
"-i",
"--name",
container_name,
"-e",
"CODEX_DISABLE_TELEMETRY=1",
"-e",
"CODEX_SANDBOX_MODE=danger-full-access",
]
if settings.codex_auth_mode == "chatgpt":
cmd.extend(
[
"-e",
f"CODEX_HOME={CONTAINER_CODEX_HOME}",
"-e",
"CODEX_AUTH_JSON_B64",
]
)
else:
cmd.extend(
[
"-e",
"OPENAI_API_KEY",
"-e",
"OPENAI_ORG_ID",
"-e",
"OPENAI_PROJECT_ID",
]
)
cmd.extend(
[
"-e",
"GITEA_TOKEN",
"-e",
"GITEA_GIT_USERNAME",
]
)
cmd.extend([settings.review_runner_image, "bash", "-lc", install_and_run])
return cmd
def _ephemeral_runner_failure_result(exc: Exception, auth_mode: str) -> dict[str, Any]:
message = str(exc).strip() or exc.__class__.__name__
mode_label = "ChatGPT auth" if auth_mode == "chatgpt" else "API-key auth"
summary = f"{mode_label} runner failed before review execution. Error: {message}"
return {
"verdict": "has_issues",
"confidence": 0.67,
"summary": summary,
"findings": [
{
"severity": "high",
"file": "runner",
"line_start": 1,
"line_end": 1,
"title": "Ephemeral 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:
target = Path(path)
target.mkdir(parents=True, exist_ok=True)
return target
def _load_repo_review_config_from_gitea(gitea: GiteaClient, repo: str, head_sha: str) -> RepoReviewConfig:
content = gitea.get_file_content(repo, ".codex-review.yml", ref=head_sha)
if content is None:
return RepoReviewConfig(configured=False)
return parse_repo_review_config_text(content, configured=True)
def _parse_review_result_from_stdout_artifact(
stdout: str,
*,
result_start_marker: str,
result_end_marker: str,
) -> dict[str, Any]:
start_pos = stdout.find(result_start_marker)
if start_pos == -1:
raise RuntimeError("Runner output did not include final review artifact markers.")
artifact_start = start_pos + len(result_start_marker)
# Prefer the last end marker so marker-like text inside JSON does not
# truncate the payload when earlier incidental matches exist.
end_pos = stdout.rfind(result_end_marker)
if end_pos == -1 or end_pos <= artifact_start:
raise RuntimeError("Runner output did not include final review artifact markers.")
artifact = stdout[artifact_start:end_pos].strip()
if not artifact:
raise RuntimeError("Runner output contained empty final review artifact.")
try:
payload = json.loads(artifact)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Final review artifact was not valid JSON: {exc}") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"Final review artifact JSON must be an object, got {type(payload)!r}.")
return payload
def _extract_result_meta_from_codex_stdout(stdout: str, settings: Settings) -> dict[str, Any]:
model = settings.openai_review_model
usage: dict[str, int] = {}
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
discovered_model = _find_first_string_for_key(payload, "model")
if discovered_model:
model = discovered_model
discovered_usage = _find_first_dict_for_key(payload, "usage")
if isinstance(discovered_usage, dict):
for output_key, source_key in (
("input_tokens", "input_tokens"),
("output_tokens", "output_tokens"),
("total_tokens", "total_tokens"),
):
value = discovered_usage.get(source_key)
if isinstance(value, int):
usage[output_key] = value
return {"source": "ephemeral_runner", "model": model, "usage": usage}
def _find_first_string_for_key(payload: Any, key: str) -> str | None:
if isinstance(payload, dict):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value
for nested in payload.values():
found = _find_first_string_for_key(nested, key)
if found:
return found
if isinstance(payload, list):
for item in payload:
found = _find_first_string_for_key(item, key)
if found:
return found
return None
def _find_first_dict_for_key(payload: Any, key: str) -> dict[str, Any] | None:
if isinstance(payload, dict):
value = payload.get(key)
if isinstance(value, dict):
return value
for nested in payload.values():
found = _find_first_dict_for_key(nested, key)
if found:
return found
if isinstance(payload, list):
for item in payload:
found = _find_first_dict_for_key(item, key)
if found:
return found
return None
-275
View File
@@ -1,275 +0,0 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from gitea_codex_bot.config import Settings
from gitea_codex_bot.db import get_session_factory
from gitea_codex_bot.models import JobStatus, ReviewJob
from gitea_codex_bot.services.comments import upsert_persistent_review_comment_id
from gitea_codex_bot.services.gitea import GiteaClient
from gitea_codex_bot.services.jobs import claim_next_job, finish_job
from gitea_codex_bot.services.review_format import format_disabled_ack, format_result_comment
from gitea_codex_bot.types import ParsedCommand
from gitea_codex_bot.workers.container_runner import run_review_ephemeral
logger = logging.getLogger(__name__)
def _command_from_job(job: ReviewJob) -> ParsedCommand:
args = job.command_args.split() if job.command_args else []
raw = (job.trigger_comment_body or "").strip() or f"@codex {job.command}"
return ParsedCommand(name=job.command, raw=raw, arguments=args, full="--full" in args)
def _handle_non_review_command(
settings: Settings,
session: Session,
gitea: GiteaClient,
job: ReviewJob,
command: ParsedCommand,
) -> tuple[bool, bool, dict[str, Any] | None, str | None]:
if command.name == "help":
try:
message = _build_help_comment(settings, session, gitea, job)
gitea.post_issue_comment(job.repo, job.pr_number, message)
return True, True, {"summary": "Help/status summary posted."}, None
except Exception as exc:
return True, False, None, f"Failed to post help summary: {exc}"
if command.name == "ignore":
return True, True, {"summary": "Ignore command acknowledged. No review run executed."}, None
if command.name == "explain":
latest_review_job = session.execute(
select(ReviewJob)
.where(
ReviewJob.repo == job.repo,
ReviewJob.pr_number == job.pr_number,
ReviewJob.command.in_(["review", "rerun"]),
ReviewJob.status == "succeeded",
)
.order_by(ReviewJob.id.desc())
.limit(1)
).scalar_one_or_none()
if latest_review_job and latest_review_job.result_json:
message = f"## Codex Explain\n\n{latest_review_job.result_json.get('summary', 'No previous summary available.')}"
else:
message = "## Codex Explain\n\nNo previous result found for this command."
gitea.post_issue_comment(job.repo, job.pr_number, message)
return True, True, {"summary": message}, None
if str(command.name).lower() == "fix":
message = "⚠️ `@codex fix` is no longer supported on this bot."
gitea.post_issue_comment(job.repo, job.pr_number, message)
return True, True, {"summary": message}, None
return False, False, None, None
def _build_help_comment(settings: Settings, session: Session, gitea: GiteaClient, job: ReviewJob) -> str:
comments = gitea.list_issue_comments(job.repo, job.pr_number)
comment_summaries = _summarize_comments(comments, settings.gitea_bot_username)
latest_review = session.execute(
select(ReviewJob)
.where(
ReviewJob.repo == job.repo,
ReviewJob.pr_number == job.pr_number,
ReviewJob.command.in_(["review", "rerun"]),
)
.order_by(ReviewJob.id.desc())
.limit(1)
).scalar_one_or_none()
pending_count = session.execute(
select(func.count(ReviewJob.id)).where(
ReviewJob.repo == job.repo,
ReviewJob.pr_number == job.pr_number,
ReviewJob.status.in_([JobStatus.queued, JobStatus.running]),
)
).scalar_one()
latest_status_line = "No previous review run."
if latest_review is not None:
latest_status = latest_review.status.value if hasattr(latest_review.status, "value") else str(latest_review.status)
latest_summary = ""
if isinstance(latest_review.result_json, dict):
summary_raw = latest_review.result_json.get("summary")
if isinstance(summary_raw, str):
latest_summary = " ".join(summary_raw.split())
latest_status_line = f"Latest review command: `{latest_review.command}` status `{latest_status}`."
if latest_summary:
latest_status_line = f"{latest_status_line} Summary: {latest_summary[:180]}"
lines = [
"## Codex Help",
"",
"Supported commands:",
"- `@codex review [security|performance|tests] [--full]`",
"- `@codex rerun`",
"- `@codex explain`",
"- `@codex ignore`",
"- `@codex -h` / `@codex --help` / `@codex help`",
"",
"Status note:",
f"- Pending jobs on this PR: `{pending_count}`",
f"- {latest_status_line}",
"",
f"Discussion summary ({comment_summaries['total']} comments, human `{comment_summaries['human']}`, bot `{comment_summaries['bot']}`):",
]
if comment_summaries["items"]:
lines.extend(comment_summaries["items"])
else:
lines.append("- No comments available to summarize.")
return "\n".join(lines).strip()
def _summarize_comments(comments: list[dict[str, Any]], bot_username: str) -> dict[str, Any]:
normalized_bot = (bot_username or "").strip().lower()
bot_count = 0
summarized: list[str] = []
recent = comments[-8:] if comments else []
for row in comments:
user = row.get("user")
username = ""
if isinstance(user, dict):
username = str(user.get("username") or user.get("login") or "").strip().lower()
if username and username == normalized_bot:
bot_count += 1
for row in recent:
body_raw = str(row.get("body") or "").strip()
if not body_raw:
continue
one_line = " ".join(body_raw.split())
preview = one_line if len(one_line) <= 180 else f"{one_line[:180]}..."
user = row.get("user")
username = "unknown"
if isinstance(user, dict):
username = str(user.get("username") or user.get("login") or "unknown").strip() or "unknown"
summarized.append(f"- @{username}: {preview}")
total = len(comments)
human_count = max(total - bot_count, 0)
return {"total": total, "human": human_count, "bot": bot_count, "items": summarized}
def _post_review_failure_comment(gitea: GiteaClient, job: ReviewJob, error_message: str) -> None:
message = (
"⚠️ Codex review run failed after queueing.\n\n"
f"- Commit: `{job.head_sha[:7]}`\n"
f"- Error: `{error_message[:500]}`\n\n"
"Please rerun `@codex rerun` after checking worker logs."
)
gitea.post_issue_comment(job.repo, job.pr_number, message)
def process_one_job(settings: Settings) -> bool:
session_factory = get_session_factory()
with session_factory() as session:
job = claim_next_job(session)
if not job:
return False
command = _command_from_job(job)
gitea = GiteaClient(settings)
logger.info(
"Processing job id=%s repo=%s pr=%s command=%s args=%s head_sha=%s",
job.id,
job.repo,
job.pr_number,
command.name,
command.arguments,
job.head_sha,
)
with session_factory() as session:
db_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
handled, skipped, result, error = _handle_non_review_command(settings, session, gitea, db_job, command)
if handled:
logger.info(
"Non-review command handled job id=%s command=%s skipped=%s error_present=%s",
db_job.id,
command.name,
skipped,
bool(error),
)
finish_job(session, job_id=db_job.id, success=error is None, skipped=skipped, result=result, error_message=error)
return True
try:
pr_ctx = gitea.get_pull_request(job.repo, job.pr_number)
if pr_ctx.is_fork and not settings.allow_untrusted_forks:
with session_factory() as session:
skip_message = "Skipped review for fork PR because `ALLOW_UNTRUSTED_FORKS=false`."
gitea.post_issue_comment(job.repo, job.pr_number, skip_message)
finish_job(
session,
job_id=job.id,
success=True,
skipped=True,
result={"summary": skip_message},
error_message=None,
)
return True
result, repo_cfg = run_review_ephemeral(settings, repo=job.repo, pr_number=job.pr_number, command=command)
logger.info(
"Runner returned job id=%s repo=%s pr=%s repo_cfg_enabled=%s repo_cfg_configured=%s result_keys=%s",
job.id,
job.repo,
job.pr_number,
repo_cfg.enabled,
repo_cfg.configured,
sorted(result.keys()),
)
if not repo_cfg.enabled:
with session_factory() as session:
gitea.post_issue_comment(job.repo, job.pr_number, format_disabled_ack())
finish_job(
session,
job_id=job.id,
success=True,
skipped=True,
result={"summary": "Review disabled by `.codex-review.yml` for this repository."},
error_message=None,
)
return True
comment_body = format_result_comment(job.head_sha, result, repo_configured=repo_cfg.configured)
with session_factory() as session:
comment_id = gitea.post_issue_comment(job.repo, job.pr_number, comment_body)
logger.info(
"Posted review comment job id=%s repo=%s pr=%s comment_id=%s",
job.id,
job.repo,
job.pr_number,
comment_id,
)
upsert_persistent_review_comment_id(
session,
repo=job.repo,
pr_number=job.pr_number,
head_sha=job.head_sha,
comment_id=comment_id,
)
logger.info(
"Persistent comment mapping upserted job id=%s repo=%s pr=%s comment_id=%s head_sha=%s",
job.id,
job.repo,
job.pr_number,
comment_id,
job.head_sha,
)
finish_job(session, job_id=job.id, success=True, skipped=False, result=result, error_message=None)
except Exception as exc:
logger.exception("Review job failed id=%s", job.id)
error_text = str(exc).strip() or exc.__class__.__name__
try:
_post_review_failure_comment(gitea, job, error_text)
except Exception:
logger.exception("Failed to post review failure comment id=%s", job.id)
with session_factory() as session:
finish_job(session, job_id=job.id, success=False, skipped=False, result=None, error_message=error_text)
return True
async def worker_loop(settings: Settings, stop_event: asyncio.Event) -> None:
while not stop_event.is_set():
processed = await asyncio.to_thread(process_one_job, settings)
if not processed:
await asyncio.sleep(1.0)
@@ -1,33 +0,0 @@
from __future__ import annotations
import json
import sys
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.types import ParsedCommand
from gitea_codex_bot.workers.container_runner import run_review_ephemeral
def main() -> int:
settings = get_settings()
payload = json.loads(sys.stdin.read())
command_payload = payload["command"]
command = ParsedCommand(
name=command_payload["name"],
raw=f"@codex {command_payload['name']}",
mode=command_payload.get("mode", "summary"),
full=bool(command_payload.get("full", False)),
arguments=list(command_payload.get("arguments", [])),
)
result, _repo_cfg = run_review_ephemeral(
settings,
repo=payload["repo"],
pr_number=int(payload["pr_number"]),
command=command,
)
print(json.dumps(result))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-46
View File
@@ -1,46 +0,0 @@
from __future__ import annotations
from collections.abc import Generator
import os
import pytest
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.db import Base, get_engine, get_session_factory
@pytest.fixture(autouse=True)
def _env_defaults(monkeypatch: pytest.MonkeyPatch, tmp_path, request: pytest.FixtureRequest) -> Generator[None, None, None]:
monkeypatch.setenv("GITEA_BASE_URL", "https://gitea.test")
monkeypatch.setenv("GITEA_TOKEN", "token")
monkeypatch.setenv("GITEA_BOT_USERNAME", "codex-bot")
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "secret")
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("COOLDOWN_SECONDS", "60")
monkeypatch.setenv("WEBHOOK_MODE", "repo")
monkeypatch.setenv("DB_HOST", "localhost")
monkeypatch.setenv("DB_PORT", "3306")
monkeypatch.setenv("DB_NAME", "ignored")
monkeypatch.setenv("DB_USER", "ignored")
monkeypatch.setenv("DB_PASSWORD", "ignored")
database_url = os.getenv("TEST_DATABASE_URL", "").strip() or f"sqlite+pysqlite:///{tmp_path / 'test.db'}"
monkeypatch.setenv("DATABASE_URL", database_url)
monkeypatch.setenv("WORKDIR", str(tmp_path / "work"))
get_settings.cache_clear()
get_engine.cache_clear()
get_session_factory.cache_clear()
engine = get_engine()
skip_schema = request.node.get_closest_marker("no_schema") is not None
if not skip_schema:
Base.metadata.create_all(bind=engine)
yield
if not skip_schema:
Base.metadata.drop_all(bind=engine)
get_settings.cache_clear()
get_engine.cache_clear()
get_session_factory.cache_clear()
-59
View File
@@ -1,59 +0,0 @@
from gitea_codex_bot.services.commands import detect_prefixed_command, parse_command
def test_parse_review_command_modes() -> None:
cmd = parse_command("@codex review security --full")
assert cmd is not None
assert cmd.name == "review"
assert cmd.mode == "security"
assert cmd.full is True
assert cmd.mode_explicit is True
def test_parse_review_command_defaults_to_non_explicit_summary_mode() -> None:
cmd = parse_command("@codex review")
assert cmd is not None
assert cmd.mode == "summary"
assert cmd.mode_explicit is False
def test_parse_fix_command_returns_none() -> None:
assert parse_command("@codex fix --branch finding 2") is None
def test_invalid_command_returns_none() -> None:
assert parse_command("hello") is None
def test_parse_review_command_for_bot_username_alias() -> None:
cmd = parse_command("@codex-bot review", aliases={"codex", "codex-bot"})
assert cmd is not None
assert cmd.name == "review"
def test_parse_review_command_for_custom_alias() -> None:
cmd = parse_command("@review-buddy review tests", aliases={"codex", "review-buddy"})
assert cmd is not None
assert cmd.name == "review"
assert cmd.mode == "tests"
def test_parse_help_short_flag() -> None:
cmd = parse_command("@codex -h")
assert cmd is not None
assert cmd.name == "help"
def test_parse_help_long_flag_and_arguments() -> None:
cmd = parse_command("@codex --help status quick", aliases={"codex"})
assert cmd is not None
assert cmd.name == "help"
assert cmd.arguments == ["status", "quick"]
def test_detect_prefixed_command_for_unsupported_name() -> None:
assert detect_prefixed_command("@codex shipit now", aliases={"codex"}) == "shipit"
def test_detect_prefixed_command_returns_none_for_non_alias() -> None:
assert detect_prefixed_command("@someone review", aliases={"codex"}) is None
-26
View File
@@ -1,26 +0,0 @@
from gitea_codex_bot.config import get_settings
def test_openai_api_key_from_env() -> None:
settings = get_settings()
assert settings.openai_api_key is not None
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
def test_bot_command_aliases_include_codex_and_username() -> None:
settings = get_settings()
assert settings.bot_command_aliases == {"codex", "codex-bot"}
def test_bot_command_aliases_include_custom_mentions(monkeypatch) -> None:
monkeypatch.setenv("GITEA_BOT_MENTIONS", "@review-buddy,helper-bot")
get_settings.cache_clear()
settings = get_settings()
assert settings.bot_command_aliases == {"codex", "codex-bot", "review-buddy", "helper-bot"}
-445
View File
@@ -1,445 +0,0 @@
from __future__ import annotations
from pathlib import Path
import pytest
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.services.gitea import PullRequestContext
from gitea_codex_bot.services.repo_config import RepoReviewConfig
from gitea_codex_bot.types import ParsedCommand
from gitea_codex_bot.workers.container_runner import (
CONTAINER_CODEX_HOME,
RESULT_END_MARKER,
RESULT_START_MARKER,
_apply_repo_default_review_mode,
_build_docker_command,
_build_exec_review_prompt,
_build_install_and_run_command,
_extract_result_meta_from_codex_stdout,
_load_codex_auth_json_b64,
_load_repo_review_config_from_gitea,
_parse_review_result_from_stdout_artifact,
_resolve_codex_auth_json_path,
run_review_ephemeral,
)
def _sample_pr() -> PullRequestContext:
return PullRequestContext(
repo="acme/repo",
pr_number=1,
base_ref="main",
base_sha="b" * 40,
head_ref="feature",
head_sha="a" * 40,
clone_url="https://gitea.test/acme/repo.git",
html_url="https://gitea.test/acme/repo/pulls/1",
is_fork=False,
)
def _sample_fork_pr() -> PullRequestContext:
return PullRequestContext(
repo="acme/repo",
pr_number=2,
base_ref="main",
base_sha="c" * 40,
head_ref="feature",
head_sha="d" * 40,
clone_url="https://gitea.test/fork/repo.git",
base_clone_url="https://gitea.test/acme/repo.git",
head_clone_url="https://gitea.test/fork/repo.git",
html_url="https://gitea.test/acme/repo/pulls/2",
is_fork=True,
)
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 "GITEA_TOKEN" in cmd
assert "GITEA_GIT_USERNAME" 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
assert "GITEA_TOKEN" in env_items
assert "GITEA_GIT_USERNAME" in env_items
def test_build_install_command_chatgpt_mode_sets_git_checkout_and_review(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()
pr = _sample_pr()
command = _build_install_and_run_command(
settings,
pr=pr,
review_prompt="review: security --full",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
assert 'printf "%s" "$CODEX_AUTH_JSON_B64" | base64 -d > /root/.codex/auth.json' in command
assert "git -c http.extraHeader=" in command
assert f"clone --no-tags --depth 80 {pr.clone_url} /work/repo" in command
assert "fetch_required() {" in command
assert f"fetch_required origin {pr.head_ref} {pr.head_sha} head" in command
assert f"fetch_required \"$base_remote\" {pr.base_ref} {pr.base_sha} base" in command
assert "base_remote=origin" in command
assert f"git checkout --detach {pr.head_sha}" in command
assert "resolved_head=\"$(git rev-parse HEAD)\"" in command
assert "unset GITEA_TOKEN auth_b64" in command
assert (
"codex exec --sandbox danger-full-access --json --output-schema /tmp/codex-review-schema.json "
"-o /tmp/codex-review-result.json"
) in command
assert "review: security --full" in command
assert "--output-schema /tmp/codex-review-schema.json" in command
assert "-o /tmp/codex-review-result.json" in command
assert "npm install -g @openai/codex@latest" in command
assert "codex --version >/tmp/codex-version.log" in command
assert " - " not in command
assert f'echo "{RESULT_START_MARKER}_x"' in command
assert f'echo "{RESULT_END_MARKER}_x"' in command
def test_build_install_command_does_not_include_reasoning_effort_flag() -> None:
settings = get_settings()
pr = _sample_pr()
command = _build_install_and_run_command(
settings,
pr=pr,
review_prompt="review: tests",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
assert "--reasoning-effort" not in command
def test_build_install_command_uses_upstream_remote_for_fork_pr_base_fetch() -> None:
settings = get_settings()
pr = _sample_fork_pr()
command = _build_install_and_run_command(
settings,
pr=pr,
review_prompt="review: tests",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
assert "base_remote=upstream" in command
assert f"git remote add upstream {pr.base_clone_url}" in command
assert f"fetch_required origin {pr.head_ref} {pr.head_sha} head" in command
assert f"fetch_required \"$base_remote\" {pr.base_ref} {pr.base_sha} base" 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_load_repo_review_config_from_gitea_when_missing() -> None:
class _Gitea:
def get_file_content(self, *_args, **_kwargs):
return None
cfg = _load_repo_review_config_from_gitea(_Gitea(), "acme/repo", "a" * 40)
assert cfg.configured is False
assert cfg.enabled is True
def test_load_repo_review_config_from_gitea_when_present() -> None:
class _Gitea:
def get_file_content(self, *_args, **_kwargs):
return "enabled: false\nreview:\n default_mode: tests\n"
cfg = _load_repo_review_config_from_gitea(_Gitea(), "acme/repo", "a" * 40)
assert cfg.configured is True
assert cfg.enabled is False
assert cfg.default_mode == "tests"
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()
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, *_args, **_kwargs):
return _sample_pr()
def get_file_content(self, *_args, **_kwargs):
return None
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", _FakeGiteaClient)
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.subprocess.run",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("docker unavailable")),
)
result, _repo_cfg = 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_run_review_ephemeral_api_key_mode_does_not_fallback_to_host(monkeypatch: pytest.MonkeyPatch) -> None:
get_settings.cache_clear()
settings = get_settings()
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, *_args, **_kwargs):
return _sample_pr()
def get_file_content(self, *_args, **_kwargs):
return None
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", _FakeGiteaClient)
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.subprocess.run",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("docker unavailable")),
)
result, _repo_cfg = run_review_ephemeral(
settings,
repo="acme/repo",
pr_number=1,
command=ParsedCommand(name="review", raw="@codex review"),
)
assert result["verdict"] == "has_issues"
assert "API-key auth runner failed" in result["summary"]
def test_run_review_ephemeral_single_attempt_success(monkeypatch: pytest.MonkeyPatch) -> None:
get_settings.cache_clear()
settings = get_settings()
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, *_args, **_kwargs):
return _sample_pr()
def get_file_content(self, *_args, **_kwargs):
return None
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", _FakeGiteaClient)
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.uuid.uuid4",
lambda: type("U", (), {"hex": "abc123def4567890abc123def4567890"})(),
)
calls: list[list[str]] = []
def _fake_run(cmd, *args, **kwargs):
calls.append(cmd)
return type(
"Completed",
(),
{
"returncode": 0,
"stdout": (
'{"type":"response.started","model":"gpt-5.3-codex"}\n'
f"{RESULT_START_MARKER}_abc123def4567890abc123def4567890\n"
'{"verdict":"correct","confidence":0.9,"summary":"ok","findings":[],"markdown_comment":"ok"}\n'
f"{RESULT_END_MARKER}_abc123def4567890abc123def4567890\n"
),
"stderr": "",
},
)()
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.subprocess.run", _fake_run)
result, _repo_cfg = run_review_ephemeral(
settings,
repo="acme/repo",
pr_number=1,
command=ParsedCommand(name="review", raw="@codex review"),
)
assert result["verdict"] == "correct"
assert len(calls) == 1
first_shell = calls[0][-1]
assert "--reasoning-effort" not in first_shell
def test_parse_review_result_from_stdout_artifact() -> None:
stdout = (
"noise\n"
f"{RESULT_START_MARKER}_test\n"
'{"verdict":"correct","confidence":0.9,"summary":"ok","findings":[],"markdown_comment":"ok"}\n'
f"{RESULT_END_MARKER}_test\n"
)
parsed = _parse_review_result_from_stdout_artifact(
stdout,
result_start_marker=f"{RESULT_START_MARKER}_test",
result_end_marker=f"{RESULT_END_MARKER}_test",
)
assert parsed["verdict"] == "correct"
assert parsed["summary"] == "ok"
def test_parse_review_result_from_stdout_artifact_fails_without_markers() -> None:
with pytest.raises(RuntimeError):
_parse_review_result_from_stdout_artifact(
"no markers here",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
def test_build_exec_review_prompt_strips_mention_and_command() -> None:
prompt = _build_exec_review_prompt(
ParsedCommand(name="review", raw="@codex review security --full\nfocus session handling"),
RepoReviewConfig(),
_sample_pr(),
)
assert prompt.startswith("review: security --full\nfocus session handling")
assert "Compare exactly these commits:" in prompt
def test_build_exec_review_prompt_falls_back_when_no_extra_text() -> None:
prompt = _build_exec_review_prompt(ParsedCommand(name="rerun", raw="@codex rerun"), RepoReviewConfig(), _sample_pr())
assert prompt.startswith("review: review this pull request and report introduced issues.")
def test_build_exec_review_prompt_disables_test_execution_by_default() -> None:
prompt = _build_exec_review_prompt(ParsedCommand(name="review", raw="@codex review"), RepoReviewConfig(), _sample_pr())
assert "Do not run tests, benchmarks, or other executables." in prompt
def test_build_exec_review_prompt_allows_test_execution_for_tests_mode() -> None:
prompt = _build_exec_review_prompt(
ParsedCommand(name="review", raw="@codex review tests", mode="tests", mode_explicit=True),
RepoReviewConfig(),
_sample_pr(),
)
assert "Tests may be executed for this run" in prompt
def test_apply_repo_default_review_mode_uses_full_when_not_configured() -> None:
command = ParsedCommand(name="review", raw="@codex review")
cfg = RepoReviewConfig()
_apply_repo_default_review_mode(command, cfg)
assert command.mode == "full"
def test_apply_repo_default_review_mode_for_review_command() -> None:
command = ParsedCommand(name="review", raw="@codex review")
cfg = RepoReviewConfig(default_mode="tests")
_apply_repo_default_review_mode(command, cfg)
assert command.mode == "tests"
def test_parse_review_result_from_stdout_artifact_uses_end_marker_after_start() -> None:
stdout = (
f"{RESULT_START_MARKER}_a\n"
'{"verdict":"correct","confidence":0.9,"summary":"contains marker text __CODEX_REVIEW_RESULT_END___a","findings":[],"markdown_comment":"ok"}\n'
f"{RESULT_END_MARKER}_a\n"
)
parsed = _parse_review_result_from_stdout_artifact(
stdout,
result_start_marker=f"{RESULT_START_MARKER}_a",
result_end_marker=f"{RESULT_END_MARKER}_a",
)
assert parsed["verdict"] == "correct"
def test_parse_review_result_from_stdout_artifact_handles_inline_end_marker() -> None:
stdout = (
"noise\n"
f"{RESULT_START_MARKER}_a\n"
'{"verdict":"correct","confidence":0.9,"summary":"ok","findings":[],"markdown_comment":"ok"}'
f"{RESULT_END_MARKER}_a\n"
)
parsed = _parse_review_result_from_stdout_artifact(
stdout,
result_start_marker=f"{RESULT_START_MARKER}_a",
result_end_marker=f"{RESULT_END_MARKER}_a",
)
assert parsed["verdict"] == "correct"
assert parsed["summary"] == "ok"
def test_extract_result_meta_from_codex_stdout_collects_model_and_usage() -> None:
settings = get_settings()
stdout = "\n".join(
[
'{"type":"response.started","model":"gpt-5.3-codex"}',
'{"type":"response.completed","response":{"usage":{"input_tokens":101,"output_tokens":22,"total_tokens":123}}}',
]
)
meta = _extract_result_meta_from_codex_stdout(stdout, settings)
assert meta["model"] == "gpt-5.3-codex"
assert meta["usage"]["input_tokens"] == 101
assert meta["usage"]["output_tokens"] == 22
assert meta["usage"]["total_tokens"] == 123
-189
View File
@@ -1,189 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
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.services.repo_config import RepoReviewConfig
from gitea_codex_bot.types import ParsedCommand
from gitea_codex_bot.workers.dispatcher import process_one_job
def test_process_one_job_always_posts_new_review_comment(monkeypatch) -> None:
posted_ids: list[int] = []
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,
trigger_comment_body="@codex review",
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": []},
RepoReviewConfig(configured=True, enabled=True),
),
)
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, _repo: str, _pr_number: int):
return SimpleNamespace(is_fork=False)
def post_issue_comment(self, _repo: str, _pr_number: int, _body: str) -> int:
new_id = 990
posted_ids.append(new_id)
return new_id
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient)
assert process_one_job(get_settings()) is True
assert posted_ids == [990]
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"
def test_process_one_job_passes_full_trigger_message_to_runner(monkeypatch) -> None:
captured: dict[str, str] = {}
session_factory = get_session_factory()
with session_factory() as session:
enqueue_job(
session,
repo="acme/repo",
pr_number=10,
head_sha="cafebabe",
trigger_comment_id=112,
trigger_comment_body="@codex review security --full\nFocus auth/session handling.",
requested_by="alice",
command=ParsedCommand(name="review", raw="@codex review security --full", arguments=["security", "--full"]),
)
def _fake_run_review_ephemeral(_settings, *, repo: str, pr_number: int, command: ParsedCommand):
captured["raw"] = command.raw
return {"verdict": "correct", "confidence": 0.9, "summary": "ok", "findings": []}, RepoReviewConfig(configured=True, enabled=True)
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, _repo: str, _pr_number: int):
return SimpleNamespace(is_fork=False)
def post_issue_comment(self, _repo: str, _pr_number: int, _body: str) -> int:
return 901
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.run_review_ephemeral", _fake_run_review_ephemeral)
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient)
assert process_one_job(get_settings()) is True
assert captured["raw"] == "@codex review security --full\nFocus auth/session handling."
def test_process_one_job_skips_review_when_repo_config_disabled(monkeypatch) -> None:
posted_comments: list[str] = []
session_factory = get_session_factory()
with session_factory() as session:
job = enqueue_job(
session,
repo="acme/repo",
pr_number=11,
head_sha="badc0de",
trigger_comment_id=113,
trigger_comment_body="@codex review",
requested_by="alice",
command=ParsedCommand(name="review", raw="@codex review"),
)
monkeypatch.setattr(
"gitea_codex_bot.workers.dispatcher.run_review_ephemeral",
lambda *_args, **_kwargs: (
{"verdict": "correct", "confidence": 1.0, "summary": "ok", "findings": []},
RepoReviewConfig(configured=True, enabled=False),
),
)
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, _repo: str, _pr_number: int):
return SimpleNamespace(is_fork=False)
def post_issue_comment(self, _repo: str, _pr_number: int, body: str) -> int:
posted_comments.append(body)
return 902
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient)
assert process_one_job(get_settings()) is True
assert any("Review is disabled" in body for body in posted_comments)
with session_factory() as session:
stored_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
assert stored_job.status.value == "skipped"
def test_process_one_job_help_command_posts_summary(monkeypatch) -> None:
posted_comments: list[str] = []
session_factory = get_session_factory()
with session_factory() as session:
enqueue_job(
session,
repo="acme/repo",
pr_number=12,
head_sha="abc12345",
trigger_comment_id=114,
trigger_comment_body="@codex -h",
requested_by="alice",
command=ParsedCommand(name="help", raw="@codex -h"),
)
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def list_issue_comments(self, _repo: str, _pr_number: int):
return [
{"body": "Please check auth edge cases", "user": {"username": "alice"}},
{"body": "On it, running review now.", "user": {"username": "codex-bot"}},
]
def post_issue_comment(self, _repo: str, _pr_number: int, body: str) -> int:
posted_comments.append(body)
return 903
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient)
assert process_one_job(get_settings()) is True
assert posted_comments
body = posted_comments[0]
assert "## Codex Help" in body
assert "@codex -h" in body
assert "@codex fix" not in body
assert "Discussion summary" in body
assert "@alice: Please check auth edge cases" in body
-85
View File
@@ -1,85 +0,0 @@
from __future__ import annotations
from sqlalchemy.exc import IntegrityError
from gitea_codex_bot.db import get_session_factory
from gitea_codex_bot.models import ReviewJob
from gitea_codex_bot.services.jobs import cooldown_remaining_seconds, enqueue_job, persist_webhook_event
from gitea_codex_bot.types import ParsedCommand
def test_persist_webhook_dedupe() -> None:
session_factory = get_session_factory()
with session_factory() as session:
first = persist_webhook_event(session, delivery_id="d1", event_name="issue_comment", repo="acme/repo", comment_id=1, payload=b"{}")
second = persist_webhook_event(session, delivery_id="d1", event_name="issue_comment", repo="acme/repo", comment_id=1, payload=b"{}")
assert first is True
assert second is False
def test_enqueue_and_cooldown() -> None:
session_factory = get_session_factory()
with session_factory() as session:
cmd = ParsedCommand(name="review", raw="@codex review")
enqueue_job(
session,
repo="acme/repo",
pr_number=42,
head_sha="abc",
trigger_comment_id=100,
trigger_comment_body="@codex review",
requested_by="user",
command=cmd,
)
remaining = cooldown_remaining_seconds(session, "acme/repo", 42, 60)
assert remaining >= 0
def test_trigger_comment_unique() -> None:
session_factory = get_session_factory()
with session_factory() as session:
cmd = ParsedCommand(name="review", raw="@codex review")
enqueue_job(
session,
repo="acme/repo",
pr_number=7,
head_sha="x",
trigger_comment_id=321,
trigger_comment_body="@codex review",
requested_by="user",
command=cmd,
)
try:
enqueue_job(
session,
repo="acme/repo",
pr_number=7,
head_sha="x",
trigger_comment_id=321,
trigger_comment_body="@codex review",
requested_by="user",
command=cmd,
)
duplicate_raised = False
except IntegrityError:
duplicate_raised = True
session.rollback()
assert duplicate_raised is True
def test_enqueue_persists_full_trigger_comment_body() -> None:
session_factory = get_session_factory()
with session_factory() as session:
cmd = ParsedCommand(name="review", raw="@codex review security\nplease focus auth")
job = enqueue_job(
session,
repo="acme/repo",
pr_number=55,
head_sha="abc123",
trigger_comment_id=9191,
trigger_comment_body=cmd.raw,
requested_by="alice",
command=cmd,
)
stored = session.get(ReviewJob, job.id)
assert stored is not None
assert stored.trigger_comment_body == "@codex review security\nplease focus auth"
-44
View File
@@ -1,44 +0,0 @@
from __future__ import annotations
import pytest
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.main import _validate_required_env
@pytest.mark.parametrize(
("env_name", "env_value", "error_text"),
[
("GITEA_WEBHOOK_SECRET", " ", "GITEA_WEBHOOK_SECRET is required"),
("GITEA_TOKEN", " ", "GITEA_TOKEN is required"),
("ALLOWED_REPOS", " ", "ALLOWED_REPOS is required"),
],
)
def test_validate_required_env_fails_on_blank_required_settings(
monkeypatch: pytest.MonkeyPatch, env_name: str, env_value: str, error_text: str
) -> None:
monkeypatch.setenv(env_name, env_value)
get_settings.cache_clear()
settings = get_settings()
with pytest.raises(RuntimeError, match=error_text):
_validate_required_env(settings)
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)
-31
View File
@@ -1,31 +0,0 @@
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
-145
View File
@@ -1,145 +0,0 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from gitea_codex_bot.db import get_session_factory
from gitea_codex_bot.main import app
from gitea_codex_bot.models import JobStatus, ReviewJob
def test_root_returns_tailwind_landing_page() -> None:
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert "Gitea Codex Review Bot" in response.text
assert "cdn.tailwindcss.com" in response.text
assert 'id="health-button"' in response.text
assert 'id="failure-button"' in response.text
assert 'id="health-modal"' in response.text
assert 'fetch("/healthz"' in response.text
assert 'fetch("/healthz/latest-failure"' in response.text
def test_404_returns_tailwind_page_for_browser_requests() -> None:
client = TestClient(app)
response = client.get("/missing", headers={"Accept": "text/html"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("text/html")
assert "Error 404" in response.text
assert "cdn.tailwindcss.com" in response.text
def test_404_returns_json_for_non_browser_requests() -> None:
client = TestClient(app)
response = client.get("/missing", headers={"Accept": "application/json"})
assert response.status_code == 404
assert response.headers["content-type"].startswith("application/json")
assert response.json() == {"detail": "Not Found"}
def test_healthz_latest_failure_returns_empty_when_no_failed_jobs() -> None:
client = TestClient(app)
response = client.get("/healthz/latest-failure")
assert response.status_code == 200
assert response.json() == {"status": "ok", "has_failed_job": False}
def test_healthz_latest_failure_returns_latest_failed_job() -> None:
session_factory = get_session_factory()
with session_factory() as session:
first = ReviewJob(
repo="acme/repo",
pr_number=1,
head_sha="1111111",
trigger_comment_id=3001,
trigger_comment_body="@codex review",
command="review",
requested_by="alice",
status=JobStatus.failed,
last_error="first error",
)
second = ReviewJob(
repo="acme/repo",
pr_number=2,
head_sha="2222222",
trigger_comment_id=3002,
trigger_comment_body="@codex rerun",
command="rerun",
requested_by="bob",
status=JobStatus.failed,
last_error="second error",
)
session.add(first)
session.add(second)
session.commit()
client = TestClient(app)
response = client.get("/healthz/latest-failure")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["has_failed_job"] is True
assert payload["repo"] == "acme/repo"
assert payload["pr_number"] == 2
assert payload["command"] == "rerun"
assert payload["head_sha"] == "2222222"
assert payload["error"] == "second error"
def test_healthz_latest_job_returns_empty_when_no_jobs() -> None:
client = TestClient(app)
response = client.get("/healthz/latest-job")
assert response.status_code == 200
assert response.json() == {"status": "ok", "has_job": False}
def test_healthz_latest_job_returns_latest_job_details() -> None:
session_factory = get_session_factory()
with session_factory() as session:
first = ReviewJob(
repo="acme/repo",
pr_number=3,
head_sha="3333333",
trigger_comment_id=3003,
trigger_comment_body="@codex review",
command="review",
requested_by="alice",
status=JobStatus.succeeded,
result_json={"summary": "first summary"},
)
second = ReviewJob(
repo="acme/repo",
pr_number=4,
head_sha="4444444",
trigger_comment_id=3004,
trigger_comment_body="@codex rerun",
command="rerun",
requested_by="bob",
status=JobStatus.failed,
last_error="failed later",
result_json={"summary": "second summary"},
)
session.add(first)
session.add(second)
session.commit()
client = TestClient(app)
response = client.get("/healthz/latest-job")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["has_job"] is True
assert payload["repo"] == "acme/repo"
assert payload["pr_number"] == 4
assert payload["command"] == "rerun"
assert payload["head_sha"] == "4444444"
assert payload["job_status"] == "failed"
assert payload["error"] == "failed later"
assert payload["result_summary"] == "second summary"
-15
View File
@@ -1,15 +0,0 @@
from __future__ import annotations
from alembic import command
from alembic.config import Config
import pytest
@pytest.mark.no_schema
def test_alembic_upgrade_and_downgrade() -> None:
cfg = Config("alembic.ini")
command.upgrade(cfg, "head")
command.downgrade(cfg, "base")
command.upgrade(cfg, "head")
-8
View File
@@ -1,8 +0,0 @@
from gitea_codex_bot.services.repo_config import parse_repo_review_config_text
def test_parse_repo_review_config_defaults_to_full_and_no_tests() -> None:
cfg = parse_repo_review_config_text("enabled: true\n", configured=True)
assert cfg.default_mode == "full"
assert cfg.include_tests is False
-96
View File
@@ -1,96 +0,0 @@
from __future__ import annotations
from gitea_codex_bot.services.review_format import format_result_comment
def test_format_result_comment_appends_structured_details_to_markdown_comment() -> None:
body = format_result_comment(
"abc1234",
{
"verdict": "has_issues",
"confidence": 0.9,
"summary": "2 issues detected.",
"findings": [
{
"severity": "high",
"file": "src/app.py",
"line_start": 20,
"line_end": 22,
"title": "Unsafe command execution",
"body": "User input is passed directly into shell=True.",
"suggestion": "Use a fixed argument list and avoid shell=True.",
}
],
"markdown_comment": "## Codex Review\n\nShort agent message only.",
},
)
assert body.startswith("<!-- codex-review:head_sha=abc1234 -->\n## Codex Review")
assert "Short agent message only." in body
assert "### Structured Findings" in body
assert "2 issues detected." in body
assert "`src/app.py:20-22` (high)" in body
assert "Unsafe command execution" 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]
def test_format_result_comment_appends_usage_note_for_markdown_comment() -> None:
body = format_result_comment(
"ff0011",
{
"markdown_comment": "## Codex Review\n\nLooks fine.",
"_meta": {
"model": "gpt-5.3-codex",
"usage": {"input_tokens": 120, "output_tokens": 45, "total_tokens": 165},
},
},
)
assert "_Note: model `gpt-5.3-codex`, input `120`, output `45`, total `165` tokens used._" in body
def test_format_result_comment_appends_usage_note_for_fallback_layout() -> None:
body = format_result_comment(
"ff0011",
{
"verdict": "correct",
"confidence": 0.8,
"summary": "No issues.",
"findings": [],
"_meta": {"model": "gpt-5.3-codex", "usage": {"total_tokens": 88}},
},
)
assert body.endswith("_Note: model `gpt-5.3-codex`, total `88` tokens used._")
def test_format_result_comment_appends_missing_config_note_for_system_layout() -> None:
body = format_result_comment(
"ff0011",
{
"verdict": "correct",
"confidence": 0.8,
"summary": "No issues.",
"findings": [],
},
repo_configured=False,
)
assert body.endswith("> ️.codex-review.yml is not configured")
def test_format_result_comment_appends_missing_config_note_to_agent_markdown() -> None:
body = format_result_comment(
"ff0011",
{
"markdown_comment": "## Codex Review\n\nLooks fine.",
},
repo_configured=False,
)
assert body.endswith("> ️.codex-review.yml is not configured")
-15
View File
@@ -1,15 +0,0 @@
import hmac
import hashlib
from gitea_codex_bot.services.security import verify_gitea_signature
def test_verify_signature_success() -> None:
payload = b'{"a":1}'
secret = "abc"
signature = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
assert verify_gitea_signature(payload, secret, signature)
def test_verify_signature_failure() -> None:
assert not verify_gitea_signature(b"x", "abc", "deadbeef")
-158
View File
@@ -1,158 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from gitea_codex_bot.db import get_session_factory
from gitea_codex_bot.models import JobStatus, ReviewJob, ReviewRun
from gitea_codex_bot.services.jobs import claim_next_job, enqueue_job, finish_job, recover_stuck_running_jobs
from gitea_codex_bot.types import ParsedCommand
def test_claim_and_transition() -> None:
session_factory = get_session_factory()
with session_factory() as session:
job = enqueue_job(
session,
repo="acme/repo",
pr_number=314,
head_sha="deadbeef",
trigger_comment_id=9901,
trigger_comment_body="@codex review",
requested_by="alice",
command=ParsedCommand(name="review", raw="@codex review"),
)
with session_factory() as session:
claimed = claim_next_job(session)
assert claimed is not None
assert claimed.id == job.id
assert claimed.status == JobStatus.running
with session_factory() as session:
finish_job(session, job_id=job.id, success=True, skipped=False, result={"summary": "ok"}, error_message=None)
with session_factory() as session:
loaded = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
assert loaded.status == JobStatus.succeeded
assert loaded.result_json is not None
def test_failed_job_retries_then_fails_terminally() -> None:
session_factory = get_session_factory()
with session_factory() as session:
job = enqueue_job(
session,
repo="acme/repo",
pr_number=271,
head_sha="f00dbabe",
trigger_comment_id=9902,
trigger_comment_body="@codex review",
requested_by="alice",
command=ParsedCommand(name="review", raw="@codex review"),
)
# First attempt fails => requeue.
with session_factory() as session:
claimed = claim_next_job(session)
assert claimed is not None
finish_job(session, job_id=job.id, success=False, skipped=False, result=None, error_message="boom-1")
with session_factory() as session:
loaded = session.get(ReviewJob, job.id)
assert loaded is not None
assert loaded.status == JobStatus.queued
assert loaded.started_at is None
assert loaded.finished_at is None
# Second attempt fails => requeue.
with session_factory() as session:
claimed = claim_next_job(session)
assert claimed is not None
finish_job(session, job_id=job.id, success=False, skipped=False, result=None, error_message="boom-2")
with session_factory() as session:
loaded = session.get(ReviewJob, job.id)
assert loaded is not None
assert loaded.status == JobStatus.queued
# Third attempt fails => terminal failed (max 2 retries exhausted).
with session_factory() as session:
claimed = claim_next_job(session)
assert claimed is not None
finish_job(session, job_id=job.id, success=False, skipped=False, result=None, error_message="boom-3")
with session_factory() as session:
loaded = session.get(ReviewJob, job.id)
assert loaded is not None
assert loaded.status == JobStatus.failed
assert loaded.finished_at is not None
def test_recover_stuck_running_job_requeues_before_retry_limit() -> None:
session_factory = get_session_factory()
with session_factory() as session:
job = enqueue_job(
session,
repo="acme/repo",
pr_number=272,
head_sha="feedface",
trigger_comment_id=9903,
trigger_comment_body="@codex review",
requested_by="alice",
command=ParsedCommand(name="review", raw="@codex review"),
)
with session_factory() as session:
claimed = claim_next_job(session)
assert claimed is not None
stale_start = datetime.now(timezone.utc) - timedelta(minutes=6)
db_job = session.get(ReviewJob, job.id)
assert db_job is not None
db_job.started_at = stale_start
session.commit()
with session_factory() as session:
recovered = recover_stuck_running_jobs(session, lease_timeout_seconds=300, max_retries=2)
assert recovered == 1
with session_factory() as session:
db_job = session.get(ReviewJob, job.id)
assert db_job is not None
assert db_job.status == JobStatus.queued
assert db_job.started_at is None
latest_run = session.execute(select(ReviewRun).where(ReviewRun.job_id == job.id).order_by(ReviewRun.id.desc()).limit(1)).scalar_one()
assert latest_run.status.value == "failed"
assert latest_run.error_message is not None
assert "timed out" in latest_run.error_message
def test_recover_stuck_running_job_fails_after_retry_limit() -> None:
session_factory = get_session_factory()
with session_factory() as session:
job = enqueue_job(
session,
repo="acme/repo",
pr_number=273,
head_sha="deadc0de",
trigger_comment_id=9904,
trigger_comment_body="@codex review",
requested_by="alice",
command=ParsedCommand(name="review", raw="@codex review"),
)
# Build up to third running attempt to hit retry limit when it times out.
for _ in range(3):
with session_factory() as session:
claimed = claim_next_job(session)
assert claimed is not None
db_job = session.get(ReviewJob, job.id)
assert db_job is not None
db_job.started_at = datetime.now(timezone.utc) - timedelta(minutes=6)
session.commit()
with session_factory() as session:
recover_stuck_running_jobs(session, lease_timeout_seconds=300, max_retries=2)
with session_factory() as session:
db_job = session.get(ReviewJob, job.id)
assert db_job is not None
assert db_job.status == JobStatus.failed
assert db_job.finished_at is not None
-377
View File
@@ -1,377 +0,0 @@
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any
from fastapi.testclient import TestClient
from sqlalchemy import select
from gitea_codex_bot.main import app
from gitea_codex_bot.db import get_session_factory
from gitea_codex_bot.models import ReviewJob
def _sign(payload: bytes) -> str:
return hmac.new(b"secret", payload, hashlib.sha256).hexdigest()
def _payload(comment_body: str, *, username: str = "alice", comment_id: int = 11) -> dict[str, Any]:
return {
"repository": {"full_name": "acme/repo"},
"sender": {"username": username},
"comment": {"id": comment_id, "body": comment_body},
"issue": {"number": 9, "pull_request": {"url": "x"}},
"pull_request": {"head": {"sha": "abcdef123"}},
}
def test_webhook_rejects_bad_signature() -> None:
client = TestClient(app)
payload = b"{}"
response = client.post(
"/webhook/gitea",
content=payload,
headers={"X-Gitea-Event": "issue_comment", "X-Gitea-Signature": "bad"},
)
assert response.status_code == 401
def test_webhook_ignores_bot_comment(monkeypatch) -> None:
client = TestClient(app)
payload = _payload("@codex review", username="codex-bot")
raw = json.dumps(payload).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-1",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "bot comment ignored"
def test_webhook_accepts_review_and_queues(monkeypatch) -> None:
posted_comments: list[str] = []
def _post_issue_comment(self, repo: str, pr_number: int, body: str) -> int:
posted_comments.append(body)
return 100
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment", _post_issue_comment)
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
)
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.get_file_content", lambda *_args, **_kwargs: None)
client = TestClient(app)
payload_obj = _payload("@codex review security", username="alice", comment_id=111)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-2",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert posted_comments
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 111)).scalar_one()
assert queued.trigger_comment_body == "@codex review security"
def test_webhook_accepts_review_for_bot_username_alias(monkeypatch) -> None:
posted_comments: list[str] = []
def _post_issue_comment(self, repo: str, pr_number: int, body: str) -> int:
posted_comments.append(body)
return 100
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment", _post_issue_comment)
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
)
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.get_file_content", lambda *_args, **_kwargs: None)
client = TestClient(app)
payload_obj = _payload("@codex-bot review security", username="alice", comment_id=311)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-2-alias",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert posted_comments
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 311)).scalar_one()
assert queued.trigger_comment_body == "@codex-bot review security"
def test_webhook_uses_latest_pr_head_sha_when_config_lookup_fails(monkeypatch) -> None:
posted_comments: list[str] = []
def _post_issue_comment(self, repo: str, pr_number: int, body: str) -> int:
posted_comments.append(body)
return 100
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment", _post_issue_comment)
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "newsha123"})(),
)
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_file_content",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("config unavailable")),
)
client = TestClient(app)
payload_obj = _payload("@codex review", username="alice", comment_id=112)
payload_obj["pull_request"]["head"]["sha"] = "oldsha999"
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-2b",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert any("`newsha1`" in body for body in posted_comments)
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 112)).scalar_one()
assert queued.head_sha == "newsha123"
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)
def test_webhook_accepts_help_short_flag_and_queues(monkeypatch) -> None:
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
)
client = TestClient(app)
payload_obj = _payload("@codex -h", username="alice", comment_id=333)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-help-1",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 333)).scalar_one()
assert queued.command == "help"
def test_webhook_replies_fix_is_no_longer_supported(monkeypatch) -> None:
posted_comments: list[str] = []
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment",
lambda _self, _repo, _pr, body: posted_comments.append(body) or 100,
)
client = TestClient(app)
payload_obj = _payload("@codex fix --branch", username="alice", comment_id=444)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-fix-unsupported",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "unsupported command"
assert response.json()["command"] == "fix"
assert any("no longer supported" in body for body in posted_comments)
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 444)).scalar_one_or_none()
assert queued is None
def test_webhook_replies_for_unknown_prefixed_command(monkeypatch) -> None:
posted_comments: list[str] = []
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment",
lambda _self, _repo, _pr, body: posted_comments.append(body) or 100,
)
client = TestClient(app)
payload_obj = _payload("@codex deploy", username="alice", comment_id=445)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-unknown-unsupported",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "unsupported command"
assert response.json()["command"] == "deploy"
assert any("not supported" in body for body in posted_comments)
def test_webhook_logs_when_repo_not_allowed(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 review", username="alice", comment_id=225)
payload_obj["repository"]["full_name"] = "acme/not-allowed"
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-6",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "repo not allowed"
assert any("Webhook ignored: repo not in ALLOWED_REPOS" in item for item in messages)
def test_webhook_rejects_review_when_repo_config_disabled(monkeypatch) -> None:
posted_comments: list[str] = []
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
)
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_file_content",
lambda *_args, **_kwargs: "enabled: false\n",
)
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment",
lambda _self, _repo, _pr, body: posted_comments.append(body) or 100,
)
client = TestClient(app)
payload_obj = _payload("@codex review", username="alice", comment_id=224)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-5",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "review disabled by repo config"
assert any("Review is disabled" in body for body in posted_comments)