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)