from __future__ import annotations from datetime import datetime, timedelta, timezone from sqlalchemy import 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 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, requested_by: str, command: ParsedCommand, ) -> ReviewJob: job = ReviewJob( repo=repo, pr_number=pr_number, head_sha=head_sha, trigger_comment_id=trigger_comment_id, 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) return job def claim_next_job(session: Session) -> ReviewJob | None: 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) return job 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: job.status = JobStatus.failed run_status = RunStatus.failed now = datetime.now(timezone.utc) 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()