feat. Enforce repo review config
This commit is contained in:
6
TODO.md
6
TODO.md
@@ -5,8 +5,8 @@
|
|||||||
### P0 (Critical)
|
### P0 (Critical)
|
||||||
- [ ] `BUG`: True isolated runner flow: clone/fetch/checkout PR branch inside the ephemeral container itself, not on host before prompt generation.
|
- [ ] `BUG`: True isolated runner flow: clone/fetch/checkout PR branch inside the ephemeral container itself, not on host before prompt generation.
|
||||||
- [ ] `BUG`: Remove host-side fallback path for review execution, or gate it behind explicit `ALLOW_HOST_FALLBACK=false` by default so isolation cannot be bypassed silently.
|
- [ ] `BUG`: Remove host-side fallback path for review execution, or gate it behind explicit `ALLOW_HOST_FALLBACK=false` by default so isolation cannot be bypassed silently.
|
||||||
- [ ] `BUG`: Enforce `.codex-review.yml` `enabled=false` at runtime (currently loaded but not enforced).
|
- [x] `BUG`: Enforce `.codex-review.yml` `enabled=false` at runtime (currently loaded but not enforced).
|
||||||
- [ ] `BUG`: Enforce `.codex-review.yml` fix policy (`commands.allow_fix`) for `@codex fix` (currently only global `ENABLE_FIX_COMMANDS` is checked).
|
- [x] `BUG`: Remove `.codex-review.yml` fix policy (`commands.allow_fix`) and rely on global `ENABLE_FIX_COMMANDS`.
|
||||||
- [ ] `BUG`: Add stuck-job recovery for `running` jobs (lease timeout + requeue/fail) so one crashed worker does not deadlock the queue.
|
- [ ] `BUG`: Add stuck-job recovery for `running` jobs (lease timeout + requeue/fail) so one crashed worker does not deadlock the queue.
|
||||||
- [ ] `BUG`: Validate required secrets/settings are non-empty at startup (`GITEA_WEBHOOK_SECRET`, `GITEA_TOKEN`, `ALLOWED_REPOS`) and fail fast if blank.
|
- [ ] `BUG`: Validate required secrets/settings are non-empty at startup (`GITEA_WEBHOOK_SECRET`, `GITEA_TOKEN`, `ALLOWED_REPOS`) and fail fast if blank.
|
||||||
- [ ] `TEST`: Add integration test proving the runner executes the exact PR head SHA in isolated mode and does not rely on host checkout.
|
- [ ] `TEST`: Add integration test proving the runner executes the exact PR head SHA in isolated mode and does not rely on host checkout.
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
### P2 (Nice to Have)
|
### P2 (Nice to Have)
|
||||||
- [x] `FEATURE`: Add a note line at the end of comments to show model tokens used and such.
|
- [x] `FEATURE`: Add a note line at the end of comments to show model tokens used and such.
|
||||||
- [x] `FEATURE`: Little static tailwind cdn styled page for any http endpoint that just shows what this is, incase this gets discovered by some random lad. Other routes than "/" should return a 404 with if a browser accessed it a again, tailwind cdn themed 404 page. Both should be nicely designed and minimalistic.
|
- [x] `FEATURE`: Little static tailwind cdn styled page for any http endpoint that just shows what this is, incase this gets discovered by some random lad. Other routes than "/" should return a 404 with if a browser accessed it a again, tailwind cdn themed 404 page. Both should be nicely designed and minimalistic.
|
||||||
- [ ] `FEATURE`: Apply `.codex-review.yml` `review.default_mode` when `@codex review` is issued without explicit mode.
|
- [x] `FEATURE`: Apply `.codex-review.yml` `review.default_mode` when `@codex review` is issued without explicit mode.
|
||||||
- [ ] `FEATURE`: Add per-repo command policy in `.codex-review.yml` for enabling/disabling `review`, `fix`, `explain`, and `rerun` independently.
|
- [ ] `FEATURE`: Add per-repo command policy in `.codex-review.yml` for enabling/disabling `review`, `fix`, `explain`, and `rerun` independently.
|
||||||
- [ ] `TEST`: Add structured log redaction tests to ensure PAT/keys never appear in logs/comments.
|
- [ ] `TEST`: Add structured log redaction tests to ensure PAT/keys never appear in logs/comments.
|
||||||
|
|
||||||
|
|||||||
@@ -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.commands import parse_command
|
||||||
from gitea_codex_bot.services.gitea import GiteaClient
|
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.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 (
|
from gitea_codex_bot.services.review_format import (
|
||||||
format_cooldown_ack,
|
format_cooldown_ack,
|
||||||
|
format_disabled_ack,
|
||||||
format_queue_ack,
|
format_queue_ack,
|
||||||
format_unsupported_ack,
|
format_unsupported_ack,
|
||||||
)
|
)
|
||||||
@@ -137,6 +139,15 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="Gitea Codex Review Bot", lifespan=lifespan)
|
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:
|
def _render_landing_page() -> str:
|
||||||
return """<!doctype html>
|
return """<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -317,11 +328,20 @@ async def gitea_webhook(
|
|||||||
|
|
||||||
gitea = GiteaClient(settings)
|
gitea = GiteaClient(settings)
|
||||||
if parsed_command.name in {"review", "rerun"}:
|
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":
|
if head_sha == "unknown":
|
||||||
try:
|
try:
|
||||||
head_sha = gitea.get_pull_request(repo, pr_number).head_sha
|
head_sha = gitea.get_pull_request(repo, pr_number).head_sha
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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":
|
if parsed_command.name != "rerun":
|
||||||
remaining = cooldown_remaining_seconds(session, repo, pr_number, settings.cooldown_seconds)
|
remaining = cooldown_remaining_seconds(session, repo, pr_number, settings.cooldown_seconds)
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ def parse_command(body: str) -> ParsedCommand | None:
|
|||||||
if "--full" in tokens:
|
if "--full" in tokens:
|
||||||
parsed.full = True
|
parsed.full = True
|
||||||
parsed.mode = "full"
|
parsed.mode = "full"
|
||||||
|
parsed.mode_explicit = True
|
||||||
for mode in ("security", "performance", "tests"):
|
for mode in ("security", "performance", "tests"):
|
||||||
if mode in tokens:
|
if mode in tokens:
|
||||||
parsed.mode = mode
|
parsed.mode = mode
|
||||||
|
parsed.mode_explicit = True
|
||||||
break
|
break
|
||||||
elif name == "fix":
|
elif name == "fix":
|
||||||
parsed.branch_fix = "--branch" in tokens
|
parsed.branch_fix = "--branch" in tokens
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -95,3 +96,24 @@ class GiteaClient:
|
|||||||
encoded_name = quote(name, safe="")
|
encoded_name = quote(name, safe="")
|
||||||
payload = self._request("GET", f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/{pr_number}/comments")
|
payload = self._request("GET", f"/api/v1/repos/{encoded_owner}/{encoded_name}/issues/{pr_number}/comments")
|
||||||
return list(payload)
|
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)
|
@dataclass(slots=True)
|
||||||
class RepoReviewConfig:
|
class RepoReviewConfig:
|
||||||
|
configured: bool = True
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
default_mode: str = "summary"
|
default_mode: str = "summary"
|
||||||
max_diff_bytes: int = 200000
|
max_diff_bytes: int = 200000
|
||||||
include_tests: bool = True
|
include_tests: bool = True
|
||||||
focus: list[str] = field(default_factory=lambda: ["correctness", "security", "maintainability"])
|
focus: list[str] = field(default_factory=lambda: ["correctness", "security", "maintainability"])
|
||||||
ignore: list[str] = field(default_factory=list)
|
ignore: list[str] = field(default_factory=list)
|
||||||
allow_fix: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def load_repo_review_config(repo_root: Path) -> RepoReviewConfig:
|
def load_repo_review_config(repo_root: Path) -> RepoReviewConfig:
|
||||||
path = repo_root / ".codex-review.yml"
|
path = repo_root / ".codex-review.yml"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return RepoReviewConfig()
|
return RepoReviewConfig(configured=False)
|
||||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
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 {}
|
review = raw.get("review", {}) or {}
|
||||||
commands = raw.get("commands", {}) or {}
|
default_mode = str(review.get("default_mode", "summary")).strip().lower() or "summary"
|
||||||
return RepoReviewConfig(
|
return RepoReviewConfig(
|
||||||
|
configured=configured,
|
||||||
enabled=bool(raw.get("enabled", True)),
|
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)),
|
max_diff_bytes=int(review.get("max_diff_bytes", 200000)),
|
||||||
include_tests=bool(review.get("include_tests", True)),
|
include_tests=bool(review.get("include_tests", True)),
|
||||||
focus=list(review.get("focus", ["correctness", "security", "maintainability"])),
|
focus=list(review.get("focus", ["correctness", "security", "maintainability"])),
|
||||||
ignore=list(raw.get("ignore", [])),
|
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."
|
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)
|
usage_note = _format_usage_note(result)
|
||||||
|
missing_config_note = _format_missing_config_note(repo_configured)
|
||||||
markdown_comment = result.get("markdown_comment")
|
markdown_comment = result.get("markdown_comment")
|
||||||
if isinstance(markdown_comment, str) and markdown_comment.strip():
|
if isinstance(markdown_comment, str) and markdown_comment.strip():
|
||||||
body = 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()
|
body = "\n".join(lines).strip()
|
||||||
if usage_note:
|
if usage_note:
|
||||||
body = f"{body}\n\n{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)
|
return _inject_head_sha_marker(head_sha, body)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,3 +100,9 @@ def _format_usage_note(result: dict) -> str:
|
|||||||
if isinstance(total_tokens, int):
|
if isinstance(total_tokens, int):
|
||||||
parts.append(f"total `{total_tokens}`")
|
parts.append(f"total `{total_tokens}`")
|
||||||
return f"_Note: {', '.join(parts)} tokens used._"
|
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)
|
tmpdir = Path(tmp)
|
||||||
repo_dir = checkout_pr(tmpdir, pr)
|
repo_dir = checkout_pr(tmpdir, pr)
|
||||||
repo_cfg = load_repo_review_config(repo_dir)
|
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 = 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["changed_files"] = _apply_ignore_patterns(diff_context["changed_files"], repo_cfg.ignore)
|
||||||
diff_context["diff"] = _redact_secrets_from_diff(diff_context["diff"])
|
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.config import Settings
|
||||||
from gitea_codex_bot.services.gitea import GiteaClient
|
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.services.reviewer import normalize_review_result, prepare_review_prompt, run_review_for_pr
|
||||||
from gitea_codex_bot.types import ParsedCommand
|
from gitea_codex_bot.types import ParsedCommand
|
||||||
|
|
||||||
@@ -26,9 +27,9 @@ def run_review_ephemeral(
|
|||||||
repo: str,
|
repo: str,
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
command: ParsedCommand,
|
command: ParsedCommand,
|
||||||
) -> dict[str, Any]:
|
) -> tuple[dict[str, Any], RepoReviewConfig]:
|
||||||
gitea = GiteaClient(settings)
|
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]}"
|
container_name = f"codex-review-{uuid.uuid4().hex[:12]}"
|
||||||
install_and_run = _build_install_and_run_command(settings)
|
install_and_run = _build_install_and_run_command(settings)
|
||||||
extra_env: dict[str, str] = {}
|
extra_env: dict[str, str] = {}
|
||||||
@@ -49,13 +50,13 @@ def run_review_ephemeral(
|
|||||||
raise RuntimeError(_format_runner_failure(completed))
|
raise RuntimeError(_format_runner_failure(completed))
|
||||||
parsed = _parse_codex_exec_stdout(completed.stdout)
|
parsed = _parse_codex_exec_stdout(completed.stdout)
|
||||||
parsed["_meta"] = _extract_result_meta_from_codex_stdout(completed.stdout, settings)
|
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:
|
except Exception as exc:
|
||||||
if settings.codex_auth_mode == "chatgpt":
|
if settings.codex_auth_mode == "chatgpt":
|
||||||
logger.warning("Ephemeral chatgpt runner failed, skipping API-key fallback: %s", exc)
|
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)
|
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:
|
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.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.gitea import GiteaClient
|
||||||
from gitea_codex_bot.services.jobs import claim_next_job, finish_job
|
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.services.reviewer import create_fix_branch, create_fix_patch_note
|
||||||
from gitea_codex_bot.types import ParsedCommand
|
from gitea_codex_bot.types import ParsedCommand
|
||||||
from gitea_codex_bot.workers.container_runner import run_review_ephemeral
|
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,
|
error_message=None,
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
result = run_review_ephemeral(settings, repo=job.repo, pr_number=job.pr_number, command=command)
|
result, repo_cfg = run_review_ephemeral(settings, repo=job.repo, pr_number=job.pr_number, command=command)
|
||||||
comment_body = format_result_comment(job.head_sha, result)
|
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:
|
with session_factory() as session:
|
||||||
comment_id = get_persistent_review_comment_id(session, job.repo, job.pr_number)
|
comment_id = get_persistent_review_comment_id(session, job.repo, job.pr_number)
|
||||||
if comment_id:
|
if comment_id:
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ def test_parse_review_command_modes() -> None:
|
|||||||
assert cmd.name == "review"
|
assert cmd.name == "review"
|
||||||
assert cmd.mode == "security"
|
assert cmd.mode == "security"
|
||||||
assert cmd.full is True
|
assert cmd.full is True
|
||||||
|
assert cmd.mode_explicit is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_review_command_defaults_to_non_explicit_summary_mode() -> None:
|
||||||
|
cmd = parse_command("@codex review")
|
||||||
|
assert cmd is not None
|
||||||
|
assert cmd.mode == "summary"
|
||||||
|
assert cmd.mode_explicit is False
|
||||||
|
|
||||||
|
|
||||||
def test_parse_fix_branch() -> None:
|
def test_parse_fix_branch() -> None:
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ def test_run_review_ephemeral_chatgpt_does_not_fallback_to_api_key_path(
|
|||||||
|
|
||||||
from gitea_codex_bot.types import ParsedCommand
|
from gitea_codex_bot.types import ParsedCommand
|
||||||
|
|
||||||
result = run_review_ephemeral(
|
result, _repo_cfg = run_review_ephemeral(
|
||||||
settings,
|
settings,
|
||||||
repo="acme/repo",
|
repo="acme/repo",
|
||||||
pr_number=1,
|
pr_number=1,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from gitea_codex_bot.db import get_session_factory
|
|||||||
from gitea_codex_bot.models import ReviewJob
|
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 get_persistent_review_comment_id, upsert_persistent_review_comment_id
|
||||||
from gitea_codex_bot.services.jobs import enqueue_job
|
from gitea_codex_bot.services.jobs import enqueue_job
|
||||||
|
from gitea_codex_bot.services.repo_config import RepoReviewConfig
|
||||||
from gitea_codex_bot.types import ParsedCommand
|
from gitea_codex_bot.types import ParsedCommand
|
||||||
from gitea_codex_bot.workers.dispatcher import process_one_job
|
from gitea_codex_bot.workers.dispatcher import process_one_job
|
||||||
|
|
||||||
@@ -37,12 +38,15 @@ def test_process_one_job_recreates_persistent_comment_when_edit_returns_404(monk
|
|||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"gitea_codex_bot.workers.dispatcher.run_review_ephemeral",
|
"gitea_codex_bot.workers.dispatcher.run_review_ephemeral",
|
||||||
lambda *_args, **_kwargs: {
|
lambda *_args, **_kwargs: (
|
||||||
|
{
|
||||||
"verdict": "has_issues",
|
"verdict": "has_issues",
|
||||||
"confidence": 0.7,
|
"confidence": 0.7,
|
||||||
"summary": "runner error",
|
"summary": "runner error",
|
||||||
"findings": [],
|
"findings": [],
|
||||||
},
|
},
|
||||||
|
RepoReviewConfig(configured=True, enabled=True),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
class _FakeGiteaClient:
|
class _FakeGiteaClient:
|
||||||
@@ -91,7 +95,7 @@ def test_process_one_job_passes_full_trigger_message_to_runner(monkeypatch) -> N
|
|||||||
|
|
||||||
def _fake_run_review_ephemeral(_settings, *, repo: str, pr_number: int, command: ParsedCommand):
|
def _fake_run_review_ephemeral(_settings, *, repo: str, pr_number: int, command: ParsedCommand):
|
||||||
captured["raw"] = command.raw
|
captured["raw"] = command.raw
|
||||||
return {"verdict": "correct", "confidence": 0.9, "summary": "ok", "findings": []}
|
return {"verdict": "correct", "confidence": 0.9, "summary": "ok", "findings": []}, RepoReviewConfig(configured=True, enabled=True)
|
||||||
|
|
||||||
class _FakeGiteaClient:
|
class _FakeGiteaClient:
|
||||||
def __init__(self, _settings) -> None:
|
def __init__(self, _settings) -> None:
|
||||||
@@ -113,3 +117,49 @@ def test_process_one_job_passes_full_trigger_message_to_runner(monkeypatch) -> N
|
|||||||
processed = process_one_job(settings)
|
processed = process_one_job(settings)
|
||||||
assert processed is True
|
assert processed is True
|
||||||
assert captured["raw"] == "@codex review security --full\nFocus auth/session handling."
|
assert captured["raw"] == "@codex review security --full\nFocus auth/session handling."
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_one_job_skips_review_when_repo_config_disabled(monkeypatch) -> None:
|
||||||
|
posted_comments: list[str] = []
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
with session_factory() as session:
|
||||||
|
job = enqueue_job(
|
||||||
|
session,
|
||||||
|
repo="acme/repo",
|
||||||
|
pr_number=11,
|
||||||
|
head_sha="badc0de",
|
||||||
|
trigger_comment_id=113,
|
||||||
|
trigger_comment_body="@codex review",
|
||||||
|
requested_by="alice",
|
||||||
|
command=ParsedCommand(name="review", raw="@codex review"),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gitea_codex_bot.workers.dispatcher.run_review_ephemeral",
|
||||||
|
lambda *_args, **_kwargs: (
|
||||||
|
{"verdict": "correct", "confidence": 1.0, "summary": "ok", "findings": []},
|
||||||
|
RepoReviewConfig(configured=True, enabled=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class _FakeGiteaClient:
|
||||||
|
def __init__(self, _settings) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_pull_request(self, _repo: str, _pr_number: int):
|
||||||
|
return SimpleNamespace(is_fork=False)
|
||||||
|
|
||||||
|
def post_issue_comment(self, _repo: str, _pr_number: int, body: str) -> int:
|
||||||
|
posted_comments.append(body)
|
||||||
|
return 902
|
||||||
|
|
||||||
|
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
processed = process_one_job(settings)
|
||||||
|
assert processed is True
|
||||||
|
assert any("Review is disabled" in body for body in posted_comments)
|
||||||
|
|
||||||
|
with session_factory() as session:
|
||||||
|
stored_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
|
||||||
|
assert stored_job.status.value == "skipped"
|
||||||
|
|||||||
@@ -55,3 +55,28 @@ def test_format_result_comment_appends_usage_note_for_fallback_layout() -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert body.endswith("_Note: model `gpt-5.3-codex`, total `88` tokens used._")
|
assert body.endswith("_Note: model `gpt-5.3-codex`, total `88` tokens used._")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_result_comment_appends_missing_config_note_for_system_layout() -> None:
|
||||||
|
body = format_result_comment(
|
||||||
|
"ff0011",
|
||||||
|
{
|
||||||
|
"verdict": "correct",
|
||||||
|
"confidence": 0.8,
|
||||||
|
"summary": "No issues.",
|
||||||
|
"findings": [],
|
||||||
|
},
|
||||||
|
repo_configured=False,
|
||||||
|
)
|
||||||
|
assert body.endswith("ℹ️.codex-review.yml is not configured")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_result_comment_does_not_append_missing_config_note_to_agent_markdown() -> None:
|
||||||
|
body = format_result_comment(
|
||||||
|
"ff0011",
|
||||||
|
{
|
||||||
|
"markdown_comment": "## Codex Review\n\nLooks fine.",
|
||||||
|
},
|
||||||
|
repo_configured=False,
|
||||||
|
)
|
||||||
|
assert "ℹ️.codex-review.yml is not configured" not in body
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import httpx
|
|||||||
|
|
||||||
from gitea_codex_bot.config import get_settings
|
from gitea_codex_bot.config import get_settings
|
||||||
from gitea_codex_bot.services.repo_config import RepoReviewConfig
|
from gitea_codex_bot.services.repo_config import RepoReviewConfig
|
||||||
from gitea_codex_bot.services.reviewer import _build_prompt, _fallback_review, run_review_for_pr
|
from gitea_codex_bot.services.reviewer import _build_prompt, _fallback_review, prepare_review_prompt, run_review_for_pr
|
||||||
from gitea_codex_bot.types import ParsedCommand
|
from gitea_codex_bot.types import ParsedCommand
|
||||||
|
|
||||||
|
|
||||||
@@ -55,3 +55,33 @@ def test_build_prompt_includes_trigger_message() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "Trigger message: @codex review security\nPlease focus auth." in prompt
|
assert "Trigger message: @codex review security\nPlease focus auth." in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_review_prompt_applies_repo_default_mode_when_command_mode_not_explicit(monkeypatch, tmp_path) -> None:
|
||||||
|
repo_dir = tmp_path / "repo"
|
||||||
|
repo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(repo_dir / ".codex-review.yml").write_text("review:\n default_mode: tests\n", encoding="utf-8")
|
||||||
|
|
||||||
|
pr = type(
|
||||||
|
"PR",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"base_sha": "b" * 40,
|
||||||
|
"head_sha": "a" * 40,
|
||||||
|
"html_url": "https://gitea.example/pr/1",
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
|
||||||
|
monkeypatch.setattr("gitea_codex_bot.services.reviewer.checkout_pr", lambda *_args, **_kwargs: repo_dir)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gitea_codex_bot.services.reviewer.collect_diff_context",
|
||||||
|
lambda *_args, **_kwargs: {"diff": "", "changed_files": [], "truncated": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
gitea = type("GiteaStub", (), {"get_pull_request": lambda *_args, **_kwargs: pr})()
|
||||||
|
command = ParsedCommand(name="review", raw="@codex review")
|
||||||
|
|
||||||
|
prompt, _diff, _cfg = prepare_review_prompt(settings, gitea, "acme/repo", 9, command)
|
||||||
|
|
||||||
|
assert "Mode: tests" in prompt
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ def test_webhook_accepts_review_and_queues(monkeypatch) -> None:
|
|||||||
return 100
|
return 100
|
||||||
|
|
||||||
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment", _post_issue_comment)
|
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment", _post_issue_comment)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
|
||||||
|
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.get_file_content", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
payload_obj = _payload("@codex review security", username="alice", comment_id=111)
|
payload_obj = _payload("@codex review security", username="alice", comment_id=111)
|
||||||
@@ -140,3 +145,38 @@ def test_webhook_logs_when_codex_command_is_not_review(monkeypatch) -> None:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "queued"
|
assert response.json()["status"] == "queued"
|
||||||
assert any("Webhook without @codex review command" in item for item in messages)
|
assert any("Webhook without @codex review command" in item for item in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_rejects_review_when_repo_config_disabled(monkeypatch) -> None:
|
||||||
|
posted_comments: list[str] = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
|
||||||
|
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gitea_codex_bot.services.gitea.GiteaClient.get_file_content",
|
||||||
|
lambda *_args, **_kwargs: "enabled: false\n",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment",
|
||||||
|
lambda _self, _repo, _pr, body: posted_comments.append(body) or 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
payload_obj = _payload("@codex review", username="alice", comment_id=224)
|
||||||
|
raw = json.dumps(payload_obj).encode()
|
||||||
|
response = client.post(
|
||||||
|
"/webhook/gitea",
|
||||||
|
content=raw,
|
||||||
|
headers={
|
||||||
|
"X-Gitea-Event": "issue_comment",
|
||||||
|
"X-Gitea-Delivery": "d-5",
|
||||||
|
"X-Gitea-Signature": _sign(raw),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["reason"] == "review disabled by repo config"
|
||||||
|
assert any("Review is disabled" in body for body in posted_comments)
|
||||||
|
|||||||
Reference in New Issue
Block a user