feat. Enforce repo review config
This commit is contained in:
@@ -18,8 +18,10 @@ 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.repo_config import RepoReviewConfig, parse_repo_review_config_text
|
||||
from gitea_codex_bot.services.review_format import (
|
||||
format_cooldown_ack,
|
||||
format_disabled_ack,
|
||||
format_queue_ack,
|
||||
format_unsupported_ack,
|
||||
)
|
||||
@@ -137,6 +139,15 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(title="Gitea Codex Review Bot", lifespan=lifespan)
|
||||
|
||||
|
||||
def _load_repo_review_config_for_pr(gitea: GiteaClient, repo: str, pr_number: int) -> tuple[RepoReviewConfig, str]:
|
||||
pr_ctx = gitea.get_pull_request(repo, pr_number)
|
||||
head_sha = pr_ctx.head_sha
|
||||
cfg_text = gitea.get_file_content(repo, ".codex-review.yml", ref=head_sha)
|
||||
if cfg_text is None:
|
||||
return RepoReviewConfig(configured=False), head_sha
|
||||
return parse_repo_review_config_text(cfg_text, configured=True), head_sha
|
||||
|
||||
|
||||
def _render_landing_page() -> str:
|
||||
return """<!doctype html>
|
||||
<html lang="en">
|
||||
@@ -317,11 +328,20 @@ async def gitea_webhook(
|
||||
|
||||
gitea = GiteaClient(settings)
|
||||
if parsed_command.name in {"review", "rerun"}:
|
||||
repo_cfg: RepoReviewConfig | None = None
|
||||
try:
|
||||
repo_cfg, resolved_head_sha = _load_repo_review_config_for_pr(gitea, repo, pr_number)
|
||||
head_sha = resolved_head_sha
|
||||
except Exception:
|
||||
repo_cfg = None
|
||||
if head_sha == "unknown":
|
||||
try:
|
||||
head_sha = gitea.get_pull_request(repo, pr_number).head_sha
|
||||
except Exception:
|
||||
pass
|
||||
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"}
|
||||
if parsed_command.name != "rerun":
|
||||
remaining = cooldown_remaining_seconds(session, repo, pr_number, settings.cooldown_seconds)
|
||||
if remaining > 0:
|
||||
|
||||
@@ -21,9 +21,11 @@ def parse_command(body: str) -> ParsedCommand | None:
|
||||
if "--full" in tokens:
|
||||
parsed.full = True
|
||||
parsed.mode = "full"
|
||||
parsed.mode_explicit = True
|
||||
for mode in ("security", "performance", "tests"):
|
||||
if mode in tokens:
|
||||
parsed.mode = mode
|
||||
parsed.mode_explicit = True
|
||||
break
|
||||
elif name == "fix":
|
||||
parsed.branch_fix = "--branch" in tokens
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
@@ -95,3 +96,24 @@ class GiteaClient:
|
||||
encoded_name = quote(name, safe="")
|
||||
payload = self._request("GET", f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/{pr_number}/comments")
|
||||
return list(payload)
|
||||
|
||||
def get_file_content(self, repo: str, path: str, *, ref: str) -> str | None:
|
||||
owner, name = self.split_repo(repo)
|
||||
encoded_owner = quote(owner, safe="")
|
||||
encoded_name = quote(name, safe="")
|
||||
encoded_path = quote(path, safe="")
|
||||
try:
|
||||
payload = self._request(
|
||||
"GET",
|
||||
f"/api/v1/repos/{encoded_owner}/{encoded_name}/contents/{encoded_path}?ref={quote(ref, safe='')}",
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
content = payload.get("content")
|
||||
encoding = payload.get("encoding")
|
||||
if not isinstance(content, str) or encoding != "base64":
|
||||
return None
|
||||
decoded = base64.b64decode(content.encode("ascii"))
|
||||
return decoded.decode("utf-8", errors="ignore")
|
||||
|
||||
@@ -8,28 +8,32 @@ import yaml
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RepoReviewConfig:
|
||||
configured: bool = True
|
||||
enabled: bool = True
|
||||
default_mode: str = "summary"
|
||||
max_diff_bytes: int = 200000
|
||||
include_tests: bool = True
|
||||
focus: list[str] = field(default_factory=lambda: ["correctness", "security", "maintainability"])
|
||||
ignore: list[str] = field(default_factory=list)
|
||||
allow_fix: bool = False
|
||||
|
||||
|
||||
def load_repo_review_config(repo_root: Path) -> RepoReviewConfig:
|
||||
path = repo_root / ".codex-review.yml"
|
||||
if not path.exists():
|
||||
return RepoReviewConfig()
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return RepoReviewConfig(configured=False)
|
||||
return parse_repo_review_config_text(path.read_text(encoding="utf-8"), configured=True)
|
||||
|
||||
|
||||
def parse_repo_review_config_text(text: str, *, configured: bool) -> RepoReviewConfig:
|
||||
raw = yaml.safe_load(text) or {}
|
||||
review = raw.get("review", {}) or {}
|
||||
commands = raw.get("commands", {}) or {}
|
||||
default_mode = str(review.get("default_mode", "summary")).strip().lower() or "summary"
|
||||
return RepoReviewConfig(
|
||||
configured=configured,
|
||||
enabled=bool(raw.get("enabled", True)),
|
||||
default_mode=str(review.get("default_mode", "summary")),
|
||||
default_mode=default_mode,
|
||||
max_diff_bytes=int(review.get("max_diff_bytes", 200000)),
|
||||
include_tests=bool(review.get("include_tests", True)),
|
||||
focus=list(review.get("focus", ["correctness", "security", "maintainability"])),
|
||||
ignore=list(raw.get("ignore", [])),
|
||||
allow_fix=bool(commands.get("allow_fix", False)),
|
||||
)
|
||||
|
||||
@@ -33,8 +33,9 @@ def format_unsupported_ack(command: ParsedCommand) -> str:
|
||||
return f"⚠️ Command `@codex {command.name}` is not enabled on this repository."
|
||||
|
||||
|
||||
def format_result_comment(head_sha: str, result: dict) -> str:
|
||||
def format_result_comment(head_sha: str, result: dict, *, repo_configured: bool = True) -> str:
|
||||
usage_note = _format_usage_note(result)
|
||||
missing_config_note = _format_missing_config_note(repo_configured)
|
||||
markdown_comment = result.get("markdown_comment")
|
||||
if isinstance(markdown_comment, str) and markdown_comment.strip():
|
||||
body = markdown_comment.strip()
|
||||
@@ -71,6 +72,8 @@ def format_result_comment(head_sha: str, result: dict) -> str:
|
||||
body = "\n".join(lines).strip()
|
||||
if usage_note:
|
||||
body = f"{body}\n\n{usage_note}"
|
||||
if missing_config_note:
|
||||
body = f"{body}\n\n{missing_config_note}"
|
||||
return _inject_head_sha_marker(head_sha, body)
|
||||
|
||||
|
||||
@@ -97,3 +100,9 @@ def _format_usage_note(result: dict) -> str:
|
||||
if isinstance(total_tokens, int):
|
||||
parts.append(f"total `{total_tokens}`")
|
||||
return f"_Note: {', '.join(parts)} tokens used._"
|
||||
|
||||
|
||||
def _format_missing_config_note(repo_configured: bool) -> str:
|
||||
if repo_configured:
|
||||
return ""
|
||||
return "ℹ️.codex-review.yml is not configured"
|
||||
|
||||
@@ -275,6 +275,9 @@ def prepare_review_prompt(
|
||||
tmpdir = Path(tmp)
|
||||
repo_dir = checkout_pr(tmpdir, pr)
|
||||
repo_cfg = load_repo_review_config(repo_dir)
|
||||
if command.name == "review" and not command.mode_explicit:
|
||||
configured_mode = repo_cfg.default_mode
|
||||
command.mode = configured_mode if configured_mode in {"summary", "security", "performance", "tests", "full"} else "summary"
|
||||
diff_context = collect_diff_context(repo_dir, pr, min(settings.max_diff_bytes, repo_cfg.max_diff_bytes))
|
||||
diff_context["changed_files"] = _apply_ignore_patterns(diff_context["changed_files"], repo_cfg.ignore)
|
||||
diff_context["diff"] = _redact_secrets_from_diff(diff_context["diff"])
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
|
||||
from gitea_codex_bot.config import Settings
|
||||
from gitea_codex_bot.services.gitea import GiteaClient
|
||||
from gitea_codex_bot.services.repo_config import RepoReviewConfig
|
||||
from gitea_codex_bot.services.reviewer import normalize_review_result, prepare_review_prompt, run_review_for_pr
|
||||
from gitea_codex_bot.types import ParsedCommand
|
||||
|
||||
@@ -26,9 +27,9 @@ def run_review_ephemeral(
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
command: ParsedCommand,
|
||||
) -> dict[str, Any]:
|
||||
) -> tuple[dict[str, Any], RepoReviewConfig]:
|
||||
gitea = GiteaClient(settings)
|
||||
prompt, _diff_context, _repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command)
|
||||
prompt, _diff_context, repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command)
|
||||
container_name = f"codex-review-{uuid.uuid4().hex[:12]}"
|
||||
install_and_run = _build_install_and_run_command(settings)
|
||||
extra_env: dict[str, str] = {}
|
||||
@@ -49,13 +50,13 @@ def run_review_ephemeral(
|
||||
raise RuntimeError(_format_runner_failure(completed))
|
||||
parsed = _parse_codex_exec_stdout(completed.stdout)
|
||||
parsed["_meta"] = _extract_result_meta_from_codex_stdout(completed.stdout, settings)
|
||||
return normalize_review_result(parsed)
|
||||
return normalize_review_result(parsed), repo_cfg
|
||||
except Exception as exc:
|
||||
if settings.codex_auth_mode == "chatgpt":
|
||||
logger.warning("Ephemeral chatgpt runner failed, skipping API-key fallback: %s", exc)
|
||||
return _chatgpt_runner_failure_result(exc)
|
||||
return _chatgpt_runner_failure_result(exc), repo_cfg
|
||||
result, _repo_cfg = run_review_for_pr(settings, gitea, repo, pr_number, command)
|
||||
return result
|
||||
return result, _repo_cfg
|
||||
|
||||
|
||||
def _build_install_and_run_command(settings: Settings) -> str:
|
||||
|
||||
@@ -14,7 +14,7 @@ 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.gitea import GiteaClient
|
||||
from gitea_codex_bot.services.jobs import claim_next_job, finish_job
|
||||
from gitea_codex_bot.services.review_format import format_result_comment
|
||||
from gitea_codex_bot.services.review_format import format_disabled_ack, format_result_comment
|
||||
from gitea_codex_bot.services.reviewer import create_fix_branch, create_fix_patch_note
|
||||
from gitea_codex_bot.types import ParsedCommand
|
||||
from gitea_codex_bot.workers.container_runner import run_review_ephemeral
|
||||
@@ -107,8 +107,20 @@ def process_one_job(settings: Settings) -> bool:
|
||||
error_message=None,
|
||||
)
|
||||
return True
|
||||
result = run_review_ephemeral(settings, repo=job.repo, pr_number=job.pr_number, command=command)
|
||||
comment_body = format_result_comment(job.head_sha, result)
|
||||
result, repo_cfg = run_review_ephemeral(settings, repo=job.repo, pr_number=job.pr_number, command=command)
|
||||
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 = get_persistent_review_comment_id(session, job.repo, job.pr_number)
|
||||
if comment_id:
|
||||
|
||||
Reference in New Issue
Block a user