276 lines
11 KiB
Python
276 lines
11 KiB
Python
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)
|