Fixed a few stupid mistakes
All checks were successful
ci / test (push) Successful in 30s
ci / publish (push) Successful in 53s

This commit is contained in:
Space-Banane
2026-05-22 23:48:24 +02:00
parent a440931f7b
commit d9e7dce4e6
7 changed files with 379 additions and 51 deletions

View File

@@ -11,10 +11,12 @@ from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import HTMLResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from gitea_codex_bot.config import Settings, get_settings
from gitea_codex_bot.db import get_session
from gitea_codex_bot.models import JobStatus, ReviewJob
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
@@ -147,6 +149,13 @@ def _load_repo_review_config_for_pr(gitea: GiteaClient, repo: str, pr_number: in
return parse_repo_review_config_text(cfg_text, configured=True), head_sha
def _resolve_pr_head_sha(gitea: GiteaClient, repo: str, pr_number: int, fallback: str) -> str:
try:
return gitea.get_pull_request(repo, pr_number).head_sha
except Exception:
return fallback
def _render_landing_page() -> str:
return """<!doctype html>
<html lang="en">
@@ -164,6 +173,8 @@ def _render_landing_page() -> str:
<p class="mt-4 text-base leading-7 text-slate-300">This endpoint powers automated pull request review workflows for Gitea. It validates signed webhook events, queues review jobs, and posts structured feedback back to pull requests.</p>
<div class="mt-8 flex flex-wrap gap-3 text-sm">
<button id="health-button" type="button" class="rounded-lg border border-slate-700 bg-slate-800/80 px-3 py-2 text-slate-200 transition hover:border-slate-500 hover:bg-slate-700">Health: <code>/healthz</code></button>
<button id="failure-button" type="button" class="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-amber-200 transition hover:border-amber-400 hover:bg-amber-500/20">Latest failure: <code>/healthz/latest-failure</code></button>
<button id="job-button" type="button" class="rounded-lg border border-cyan-500/40 bg-cyan-500/10 px-3 py-2 text-cyan-200 transition hover:border-cyan-400 hover:bg-cyan-500/20">Latest job: <code>/healthz/latest-job</code></button>
<span class="rounded-lg border border-slate-700 bg-slate-800/80 px-3 py-2 text-slate-200">Webhook: <code>POST /webhook/gitea</code></span>
</div>
</section>
@@ -179,6 +190,8 @@ def _render_landing_page() -> str:
</div>
<script>
const healthButton = document.getElementById("health-button");
const failureButton = document.getElementById("failure-button");
const jobButton = document.getElementById("job-button");
const healthModal = document.getElementById("health-modal");
const closeModal = document.getElementById("close-modal");
const healthResult = document.getElementById("health-result");
@@ -196,6 +209,57 @@ def _render_landing_page() -> str:
}
}
async function loadLatestFailure() {
healthResult.textContent = "Loading...";
try {
const response = await fetch("/healthz/latest-failure", { headers: { Accept: "application/json" } });
const payload = await response.json();
if (!payload.has_failed_job) {
healthResult.textContent = "No failed jobs found.";
return;
}
const failedAt = payload.failed_at ? payload.failed_at : "unknown";
const errorText = payload.error ? payload.error : "unknown";
healthResult.textContent =
"Latest failed job #" + payload.job_id +
" | " + payload.repo + "#" + payload.pr_number +
" | command=" + payload.command +
" | commit=" + payload.head_sha.slice(0, 7) +
" | failed_at=" + failedAt +
" | error=" + errorText;
} catch (_error) {
healthResult.textContent = "Could not load latest failure output.";
}
}
async function loadLatestJob() {
healthResult.textContent = "Loading...";
try {
const response = await fetch("/healthz/latest-job", { headers: { Accept: "application/json" } });
const payload = await response.json();
if (!payload.has_job) {
healthResult.textContent = "No jobs found yet.";
return;
}
const startedAt = payload.started_at ? payload.started_at : "not started";
const finishedAt = payload.finished_at ? payload.finished_at : "not finished";
const errorText = payload.error ? payload.error : "none";
const summary = payload.result_summary ? payload.result_summary : "none";
healthResult.textContent =
"Latest job #" + payload.job_id +
" | " + payload.repo + "#" + payload.pr_number +
" | command=" + payload.command +
" | status=" + payload.job_status +
" | commit=" + payload.head_sha.slice(0, 7) +
" | started_at=" + startedAt +
" | finished_at=" + finishedAt +
" | error=" + errorText +
" | summary=" + summary;
} catch (_error) {
healthResult.textContent = "Could not load latest job output.";
}
}
function showModal() {
healthModal.classList.remove("hidden");
healthModal.classList.add("flex");
@@ -210,6 +274,14 @@ def _render_landing_page() -> str:
showModal();
await loadHealth();
});
failureButton.addEventListener("click", async function () {
showModal();
await loadLatestFailure();
});
jobButton.addEventListener("click", async function () {
showModal();
await loadLatestJob();
});
closeModal.addEventListener("click", hideModal);
healthModal.addEventListener("click", function (event) {
@@ -264,6 +336,54 @@ def healthz(settings: Settings = Depends(get_settings)) -> dict[str, str]:
return {"status": "ok"}
@app.get("/healthz/latest-failure")
def healthz_latest_failure(session: Session = Depends(get_session)) -> dict[str, Any]:
failed_job = session.execute(
select(ReviewJob).where(ReviewJob.status == JobStatus.failed).order_by(ReviewJob.created_at.desc(), ReviewJob.id.desc()).limit(1)
).scalar_one_or_none()
if not failed_job:
return {"status": "ok", "has_failed_job": False}
return {
"status": "ok",
"has_failed_job": True,
"job_id": failed_job.id,
"repo": failed_job.repo,
"pr_number": failed_job.pr_number,
"command": failed_job.command,
"head_sha": failed_job.head_sha,
"error": failed_job.last_error or "",
"failed_at": failed_job.finished_at.isoformat() if failed_job.finished_at else None,
}
@app.get("/healthz/latest-job")
def healthz_latest_job(session: Session = Depends(get_session)) -> dict[str, Any]:
latest_job = session.execute(select(ReviewJob).order_by(ReviewJob.created_at.desc(), ReviewJob.id.desc()).limit(1)).scalar_one_or_none()
if not latest_job:
return {"status": "ok", "has_job": False}
result_summary = ""
if isinstance(latest_job.result_json, dict):
summary = latest_job.result_json.get("summary")
if isinstance(summary, str):
result_summary = summary
return {
"status": "ok",
"has_job": True,
"job_id": latest_job.id,
"repo": latest_job.repo,
"pr_number": latest_job.pr_number,
"command": latest_job.command,
"head_sha": latest_job.head_sha,
"job_status": latest_job.status.value if hasattr(latest_job.status, "value") else str(latest_job.status),
"error": latest_job.last_error or "",
"result_summary": result_summary,
"created_at": latest_job.created_at.isoformat() if latest_job.created_at else None,
"started_at": latest_job.started_at.isoformat() if latest_job.started_at else None,
"finished_at": latest_job.finished_at.isoformat() if latest_job.finished_at else None,
}
@app.post("/webhook/gitea")
async def gitea_webhook(
request: Request,
@@ -334,6 +454,7 @@ async def gitea_webhook(
gitea = GiteaClient(settings)
if parsed_command.name in {"review", "rerun"}:
head_sha = _resolve_pr_head_sha(gitea, repo, pr_number, head_sha)
repo_cfg: RepoReviewConfig | None = None
try:
repo_cfg, resolved_head_sha = _load_repo_review_config_for_pr(gitea, repo, pr_number)
@@ -341,10 +462,7 @@ async def gitea_webhook(
except Exception:
repo_cfg = None
if head_sha == "unknown":
try:
head_sha = gitea.get_pull_request(repo, pr_number).head_sha
except Exception:
pass
head_sha = _resolve_pr_head_sha(gitea, repo, pr_number, head_sha)
if repo_cfg and not repo_cfg.enabled:
gitea.post_issue_comment(repo, pr_number, format_disabled_ack())
return {"accepted": True, "reason": "review disabled by repo config"}

View File

@@ -90,6 +90,16 @@ class GiteaClient:
)
return int(payload["id"])
def get_issue_comment(self, repo: str, comment_id: int) -> dict[str, Any]:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
payload = self._request(
"GET",
f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/comments/{comment_id}",
)
return dict(payload)
def list_issue_comments(self, repo: str, pr_number: int) -> list[dict[str, Any]]:
owner, name = self.split_repo(repo)
encoded_owner = quote(owner, safe="")

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
@@ -10,6 +11,8 @@ from gitea_codex_bot.models import JobStatus, ReviewJob, ReviewRun, RunStatus, W
from gitea_codex_bot.services.security import payload_digest
from gitea_codex_bot.types import ParsedCommand
logger = logging.getLogger(__name__)
def persist_webhook_event(
session: Session,
@@ -79,6 +82,16 @@ def enqueue_job(
session.add(job)
session.commit()
session.refresh(job)
logger.info(
"Job enqueued id=%s repo=%s pr=%s command=%s head_sha=%s trigger_comment_id=%s requested_by=%s",
job.id,
job.repo,
job.pr_number,
job.command,
job.head_sha,
job.trigger_comment_id,
job.requested_by,
)
return job
@@ -95,6 +108,15 @@ def claim_next_job(session: Session) -> ReviewJob | None:
session.add(run)
session.commit()
session.refresh(job)
logger.info(
"Job claimed id=%s repo=%s pr=%s command=%s head_sha=%s status=%s",
job.id,
job.repo,
job.pr_number,
job.command,
job.head_sha,
job.status.value if hasattr(job.status, "value") else job.status,
)
return job
@@ -136,3 +158,13 @@ def finish_job(
latest_run.error_message = error_message
session.commit()
logger.info(
"Job finished id=%s repo=%s pr=%s status=%s run_status=%s skipped=%s error_present=%s",
job.id,
job.repo,
job.pr_number,
job.status.value if hasattr(job.status, "value") else job.status,
run_status.value if hasattr(run_status, "value") else run_status,
skipped,
bool(error_message),
)

View File

@@ -4,14 +4,13 @@ import asyncio
import logging
from typing import Any
import httpx
from sqlalchemy import 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 ReviewJob
from gitea_codex_bot.services.comments import get_persistent_review_comment_id, upsert_persistent_review_comment_id
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
@@ -75,6 +74,16 @@ def _handle_non_review_command(
return False, False, None, None
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:
@@ -84,11 +93,27 @@ def process_one_job(settings: Settings) -> bool:
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
@@ -108,6 +133,15 @@ def process_one_job(settings: Settings) -> bool:
)
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())
@@ -122,22 +156,14 @@ def process_one_job(settings: Settings) -> bool:
return True
comment_body = format_result_comment(job.head_sha, result, repo_configured=repo_cfg.configured)
with session_factory() as session:
comment_id = get_persistent_review_comment_id(session, job.repo, job.pr_number)
if comment_id:
try:
gitea.edit_issue_comment(job.repo, comment_id, comment_body)
except httpx.HTTPStatusError as exc:
if exc.response.status_code != 404:
raise
logger.warning(
"Persistent review comment not found; posting a new one repo=%s pr=%s old_comment_id=%s",
job.repo,
job.pr_number,
comment_id,
)
comment_id = gitea.post_issue_comment(job.repo, job.pr_number, comment_body)
else:
comment_id = gitea.post_issue_comment(job.repo, job.pr_number, comment_body)
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,
@@ -145,11 +171,24 @@ def process_one_job(settings: Settings) -> bool:
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=str(exc))
finish_job(session, job_id=job.id, success=False, skipped=False, result=None, error_message=error_text)
return True