First MVP

This commit is contained in:
Space-Banane
2026-05-22 19:25:57 +02:00
parent 673f70b32a
commit 860ccb731d
40 changed files with 2336 additions and 0 deletions

175
src/gitea_codex_bot/main.py Normal file
View File

@@ -0,0 +1,175 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
from sqlalchemy.orm import Session
from gitea_codex_bot.config import Settings, get_settings
from gitea_codex_bot.db import Base, get_engine, get_session
from gitea_codex_bot.services.commands import 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.review_format import (
format_cooldown_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:
if not settings.openai_api_key.get_secret_value().strip():
raise RuntimeError("OPENAI_API_KEY is required")
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)
Base.metadata.create_all(bind=get_engine())
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)
@app.get("/healthz")
def healthz(settings: Settings = Depends(get_settings)) -> dict[str, str]:
_ = settings.gitea_base_url
return {"status": "ok"}
@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"}
comment_body = str(payload.get("comment", {}).get("body", "")).strip()
parsed_command = parse_command(comment_body)
if not parsed_command:
return {"accepted": False, "reason": "no codex command"}
if repo not in settings.allowed_repo_set:
return {"accepted": False, "reason": "repo not allowed"}
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"}:
if head_sha == "unknown":
try:
head_sha = gitea.get_pull_request(repo, pr_number).head_sha
except Exception:
pass
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,
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 {"fix", "explain", "ignore"}:
job = enqueue_job(
session,
repo=repo,
pr_number=pr_number,
head_sha=head_sha,
trigger_comment_id=comment_id,
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"}