First MVP
This commit is contained in:
175
src/gitea_codex_bot/main.py
Normal file
175
src/gitea_codex_bot/main.py
Normal 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"}
|
||||
Reference in New Issue
Block a user