diff --git a/TODO.md b/TODO.md index 00fc8ff..298b641 100644 --- a/TODO.md +++ b/TODO.md @@ -5,8 +5,8 @@ ### P0 (Critical) - [ ] `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`: 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`: Enforce `.codex-review.yml` `enabled=false` at runtime (currently loaded but not enforced). +- [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`: 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. @@ -26,7 +26,7 @@ ### P2 (Nice to Have) - [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. -- [ ] `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. - [ ] `TEST`: Add structured log redaction tests to ensure PAT/keys never appear in logs/comments. diff --git a/src/gitea_codex_bot/main.py b/src/gitea_codex_bot/main.py index dbc9186..751d1e1 100644 --- a/src/gitea_codex_bot/main.py +++ b/src/gitea_codex_bot/main.py @@ -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 """ @@ -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: diff --git a/src/gitea_codex_bot/services/commands.py b/src/gitea_codex_bot/services/commands.py index 78b4087..6c64cf3 100644 --- a/src/gitea_codex_bot/services/commands.py +++ b/src/gitea_codex_bot/services/commands.py @@ -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 diff --git a/src/gitea_codex_bot/services/gitea.py b/src/gitea_codex_bot/services/gitea.py index 221d3cb..70d789c 100644 --- a/src/gitea_codex_bot/services/gitea.py +++ b/src/gitea_codex_bot/services/gitea.py @@ -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") diff --git a/src/gitea_codex_bot/services/repo_config.py b/src/gitea_codex_bot/services/repo_config.py index bb692ce..6ea0ed6 100644 --- a/src/gitea_codex_bot/services/repo_config.py +++ b/src/gitea_codex_bot/services/repo_config.py @@ -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)), ) diff --git a/src/gitea_codex_bot/services/review_format.py b/src/gitea_codex_bot/services/review_format.py index 46e4ffb..ffe49ce 100644 --- a/src/gitea_codex_bot/services/review_format.py +++ b/src/gitea_codex_bot/services/review_format.py @@ -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" diff --git a/src/gitea_codex_bot/services/reviewer.py b/src/gitea_codex_bot/services/reviewer.py index 1c930f0..1687570 100644 --- a/src/gitea_codex_bot/services/reviewer.py +++ b/src/gitea_codex_bot/services/reviewer.py @@ -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"]) diff --git a/src/gitea_codex_bot/workers/container_runner.py b/src/gitea_codex_bot/workers/container_runner.py index af3b4e6..08895c0 100644 --- a/src/gitea_codex_bot/workers/container_runner.py +++ b/src/gitea_codex_bot/workers/container_runner.py @@ -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: diff --git a/src/gitea_codex_bot/workers/dispatcher.py b/src/gitea_codex_bot/workers/dispatcher.py index 3112c12..84b38f1 100644 --- a/src/gitea_codex_bot/workers/dispatcher.py +++ b/src/gitea_codex_bot/workers/dispatcher.py @@ -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: diff --git a/tests/test_commands.py b/tests/test_commands.py index 6b8db9e..a7a7ec0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -7,6 +7,14 @@ def test_parse_review_command_modes() -> None: assert cmd.name == "review" assert cmd.mode == "security" 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: @@ -17,4 +25,4 @@ def test_parse_fix_branch() -> None: def test_invalid_command_returns_none() -> None: - assert parse_command("hello") is None \ No newline at end of file + assert parse_command("hello") is None diff --git a/tests/test_container_runner.py b/tests/test_container_runner.py index 9deec0f..fd41790 100644 --- a/tests/test_container_runner.py +++ b/tests/test_container_runner.py @@ -113,7 +113,7 @@ def test_run_review_ephemeral_chatgpt_does_not_fallback_to_api_key_path( from gitea_codex_bot.types import ParsedCommand - result = run_review_ephemeral( + result, _repo_cfg = run_review_ephemeral( settings, repo="acme/repo", pr_number=1, diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index d685f4d..ffa1ee2 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -10,6 +10,7 @@ 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.jobs import enqueue_job +from gitea_codex_bot.services.repo_config import RepoReviewConfig from gitea_codex_bot.types import ParsedCommand 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( "gitea_codex_bot.workers.dispatcher.run_review_ephemeral", - lambda *_args, **_kwargs: { - "verdict": "has_issues", - "confidence": 0.7, - "summary": "runner error", - "findings": [], - }, + lambda *_args, **_kwargs: ( + { + "verdict": "has_issues", + "confidence": 0.7, + "summary": "runner error", + "findings": [], + }, + RepoReviewConfig(configured=True, enabled=True), + ), ) 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): 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: 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) assert processed is True 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" diff --git a/tests/test_review_format.py b/tests/test_review_format.py index 1a5f8ce..6ad96c1 100644 --- a/tests/test_review_format.py +++ b/tests/test_review_format.py @@ -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._") + + +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 diff --git a/tests/test_reviewer_fallback.py b/tests/test_reviewer_fallback.py index 08a3155..c68b0d0 100644 --- a/tests/test_reviewer_fallback.py +++ b/tests/test_reviewer_fallback.py @@ -4,7 +4,7 @@ import httpx from gitea_codex_bot.config import get_settings 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 @@ -55,3 +55,33 @@ def test_build_prompt_includes_trigger_message() -> None: ) 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 diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 8cc7701..d6c9940 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -64,6 +64,11 @@ def test_webhook_accepts_review_and_queues(monkeypatch) -> None: return 100 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) 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.json()["status"] == "queued" 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)