From e7c7d82f84692cef9e7cfa623b69e75a273283f9 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Fri, 22 May 2026 22:16:09 +0200 Subject: [PATCH] feat. Review foot note, docker fix, pass message to reviewer , update tests --- AGENTS.md | 8 ++- TODO.md | 2 +- .../0002_review_job_trigger_comment_body.py | 25 ++++++++ docker-compose.dev.yml | 3 - src/gitea_codex_bot/main.py | 2 + src/gitea_codex_bot/models.py | 1 + src/gitea_codex_bot/services/jobs.py | 2 + src/gitea_codex_bot/services/review_format.py | 36 ++++++++++- src/gitea_codex_bot/services/reviewer.py | 24 ++++++- .../workers/container_runner.py | 62 +++++++++++++++++++ src/gitea_codex_bot/workers/dispatcher.py | 3 +- tests/test_container_runner.py | 16 +++++ tests/test_dispatcher.py | 42 +++++++++++++ tests/test_jobs.py | 53 +++++++++++++++- tests/test_review_format.py | 27 ++++++++ tests/test_reviewer_fallback.py | 20 +++++- tests/test_transitions.py | 3 +- tests/test_webhook.py | 7 +++ 18 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/0002_review_job_trigger_comment_body.py diff --git a/AGENTS.md b/AGENTS.md index 329b644..3d958c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ pytest Docker compose: ```bash -docker compose up --build +docker compose up --build -f docker-compose.dev.yml ``` ## Environment Contract @@ -171,3 +171,9 @@ Treat these as high-sensitivity areas when modifying worker/runner paths. 3. Add/update tests with behavior changes. 4. Run `pytest`. 5. Summarize impact, risks, and follow-ups in PR/commit notes. + +## Commiting After Completion +If you are confident that your changes are ready to be committed, please follow the commit message format below: + +```[TYPE] Short description (max 50 chars)``` +Push after commiting. Ask the user once if you have permission to commit and from then on commit without asking. \ No newline at end of file diff --git a/TODO.md b/TODO.md index cdb8ff2..a059dee 100644 --- a/TODO.md +++ b/TODO.md @@ -24,7 +24,7 @@ - [ ] `FEATURE`: Add username as possible command prefix, ex. "@bot-name review" in addition to "@codex review", for better UX discoverability. ### P2 (Nice to Have) -- [ ] `FEATURE`: Add a note line generated by the reviewer 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. - [ ] `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. - [ ] `FEATURE`: Add per-repo command policy in `.codex-review.yml` for enabling/disabling `review`, `fix`, `explain`, and `rerun` independently. diff --git a/alembic/versions/0002_review_job_trigger_comment_body.py b/alembic/versions/0002_review_job_trigger_comment_body.py new file mode 100644 index 0000000..900640b --- /dev/null +++ b/alembic/versions/0002_review_job_trigger_comment_body.py @@ -0,0 +1,25 @@ +"""add trigger comment body to review jobs + +Revision ID: 0002_review_job_trigger_comment_body +Revises: 0001_initial +Create Date: 2026-05-22 20:15:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0002_review_job_trigger_comment_body" +down_revision: Union[str, None] = "0001_initial" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("review_jobs", sa.Column("trigger_comment_body", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("review_jobs", "trigger_comment_body") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2632e34..ce2360b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,9 +19,6 @@ services: bot: build: . - depends_on: - mariadb: - condition: service_healthy env_file: - .env environment: diff --git a/src/gitea_codex_bot/main.py b/src/gitea_codex_bot/main.py index d3e122b..83402f7 100644 --- a/src/gitea_codex_bot/main.py +++ b/src/gitea_codex_bot/main.py @@ -219,6 +219,7 @@ async def gitea_webhook( pr_number=pr_number, head_sha=head_sha, trigger_comment_id=comment_id, + trigger_comment_body=comment_body, requested_by=sender_username, command=parsed_command, ) @@ -232,6 +233,7 @@ async def gitea_webhook( pr_number=pr_number, head_sha=head_sha, trigger_comment_id=comment_id, + trigger_comment_body=comment_body, requested_by=sender_username, command=parsed_command, ) diff --git a/src/gitea_codex_bot/models.py b/src/gitea_codex_bot/models.py index e10f922..b51ba1e 100644 --- a/src/gitea_codex_bot/models.py +++ b/src/gitea_codex_bot/models.py @@ -49,6 +49,7 @@ class ReviewJob(Base): pr_number: Mapped[int] = mapped_column(Integer, nullable=False) head_sha: Mapped[str] = mapped_column(String(64), nullable=False) trigger_comment_id: Mapped[int] = mapped_column(Integer, nullable=False) + trigger_comment_body: Mapped[str | None] = mapped_column(Text, nullable=True) command: Mapped[str] = mapped_column(String(64), nullable=False, default="review") command_args: Mapped[str | None] = mapped_column(Text, nullable=True) requested_by: Mapped[str] = mapped_column(String(255), nullable=False) diff --git a/src/gitea_codex_bot/services/jobs.py b/src/gitea_codex_bot/services/jobs.py index b775c58..28f1e8a 100644 --- a/src/gitea_codex_bot/services/jobs.py +++ b/src/gitea_codex_bot/services/jobs.py @@ -61,6 +61,7 @@ def enqueue_job( pr_number: int, head_sha: str, trigger_comment_id: int, + trigger_comment_body: str | None, requested_by: str, command: ParsedCommand, ) -> ReviewJob: @@ -69,6 +70,7 @@ def enqueue_job( pr_number=pr_number, head_sha=head_sha, trigger_comment_id=trigger_comment_id, + trigger_comment_body=trigger_comment_body, command=command.name, command_args=" ".join(command.arguments) if command.arguments else None, requested_by=requested_by, diff --git a/src/gitea_codex_bot/services/review_format.py b/src/gitea_codex_bot/services/review_format.py index 83e959d..46e4ffb 100644 --- a/src/gitea_codex_bot/services/review_format.py +++ b/src/gitea_codex_bot/services/review_format.py @@ -34,9 +34,13 @@ def format_unsupported_ack(command: ParsedCommand) -> str: def format_result_comment(head_sha: str, result: dict) -> str: + usage_note = _format_usage_note(result) markdown_comment = result.get("markdown_comment") if isinstance(markdown_comment, str) and markdown_comment.strip(): - return _inject_head_sha_marker(head_sha, markdown_comment) + body = markdown_comment.strip() + if usage_note: + body = f"{body}\n\n{usage_note}" + return _inject_head_sha_marker(head_sha, body) verdict = result.get("verdict", "has_issues") confidence = float(result.get("confidence", 0.0)) @@ -64,4 +68,32 @@ def format_result_comment(head_sha: str, result: dict) -> str: f" Suggestion: {suggestion}" if suggestion else " Suggestion: n/a", ] ) - return _inject_head_sha_marker(head_sha, "\n".join(lines).strip()) + body = "\n".join(lines).strip() + if usage_note: + body = f"{body}\n\n{usage_note}" + return _inject_head_sha_marker(head_sha, body) + + +def _format_usage_note(result: dict) -> str: + meta = result.get("_meta") + if not isinstance(meta, dict): + return "" + + model = meta.get("model") + model_text = model.strip() if isinstance(model, str) and model.strip() else "unknown" + + usage = meta.get("usage") + if not isinstance(usage, dict): + return f"_Note: model `{model_text}`._" + + input_tokens = usage.get("input_tokens") + output_tokens = usage.get("output_tokens") + total_tokens = usage.get("total_tokens") + parts = [f"model `{model_text}`"] + if isinstance(input_tokens, int): + parts.append(f"input `{input_tokens}`") + if isinstance(output_tokens, int): + parts.append(f"output `{output_tokens}`") + if isinstance(total_tokens, int): + parts.append(f"total `{total_tokens}`") + return f"_Note: {', '.join(parts)} tokens used._" diff --git a/src/gitea_codex_bot/services/reviewer.py b/src/gitea_codex_bot/services/reviewer.py index ad56a22..1c930f0 100644 --- a/src/gitea_codex_bot/services/reviewer.py +++ b/src/gitea_codex_bot/services/reviewer.py @@ -129,6 +129,7 @@ def _build_prompt( "}\n\n" f"PR URL: {pr.html_url}\n" f"Mode: {mode}\n" + f"Trigger message: {command.raw}\n" f"Repo focus: {', '.join(repo_cfg.focus)}\n" f"Diff truncated: {diff_context['truncated']}\n" f"Changed files:\n{os.linesep.join(diff_context['changed_files'])}\n\n" @@ -166,10 +167,31 @@ def _call_openai_review(settings: Settings, prompt: str) -> dict[str, Any]: for content in item.get("content", []): text_value = content.get("text") if text_value: - return json.loads(text_value) + result = json.loads(text_value) + if isinstance(result, dict): + result["_meta"] = _build_openai_result_meta(payload, settings) + return result raise ReviewError("OpenAI response did not contain JSON output text.") +def _build_openai_result_meta(payload: dict[str, Any], settings: Settings) -> dict[str, Any]: + usage_raw = payload.get("usage") + usage: dict[str, int] = {} + if isinstance(usage_raw, dict): + for output_key, source_key in ( + ("input_tokens", "input_tokens"), + ("output_tokens", "output_tokens"), + ("total_tokens", "total_tokens"), + ): + value = usage_raw.get(source_key) + if isinstance(value, int): + usage[output_key] = value + model = payload.get("model") + if not isinstance(model, str) or not model.strip(): + model = settings.openai_review_model + return {"source": "openai_api", "model": model, "usage": usage} + + def _summarize_openai_failure(exc: Exception) -> str: if isinstance(exc, httpx.HTTPStatusError): status = exc.response.status_code diff --git a/src/gitea_codex_bot/workers/container_runner.py b/src/gitea_codex_bot/workers/container_runner.py index 653f238..af3b4e6 100644 --- a/src/gitea_codex_bot/workers/container_runner.py +++ b/src/gitea_codex_bot/workers/container_runner.py @@ -48,6 +48,7 @@ def run_review_ephemeral( if completed.returncode != 0: 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) except Exception as exc: if settings.codex_auth_mode == "chatgpt": @@ -202,6 +203,67 @@ def _parse_codex_exec_stdout(stdout: str) -> dict[str, Any]: raise RuntimeError(f"codex exec output text did not contain review JSON; text_tail={_tail_text(last_text, 400)}") +def _extract_result_meta_from_codex_stdout(stdout: str, settings: Settings) -> dict[str, Any]: + model = settings.openai_review_model + usage: dict[str, int] = {} + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + discovered_model = _find_first_string_for_key(payload, "model") + if discovered_model: + model = discovered_model + discovered_usage = _find_first_dict_for_key(payload, "usage") + if isinstance(discovered_usage, dict): + for output_key, source_key in ( + ("input_tokens", "input_tokens"), + ("output_tokens", "output_tokens"), + ("total_tokens", "total_tokens"), + ): + value = discovered_usage.get(source_key) + if isinstance(value, int): + usage[output_key] = value + return {"source": "ephemeral_runner", "model": model, "usage": usage} + + +def _find_first_string_for_key(payload: Any, key: str) -> str | None: + if isinstance(payload, dict): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value + for nested in payload.values(): + found = _find_first_string_for_key(nested, key) + if found: + return found + if isinstance(payload, list): + for item in payload: + found = _find_first_string_for_key(item, key) + if found: + return found + return None + + +def _find_first_dict_for_key(payload: Any, key: str) -> dict[str, Any] | None: + if isinstance(payload, dict): + value = payload.get(key) + if isinstance(value, dict): + return value + for nested in payload.values(): + found = _find_first_dict_for_key(nested, key) + if found: + return found + if isinstance(payload, list): + for item in payload: + found = _find_first_dict_for_key(item, key) + if found: + return found + return None + + def _parse_review_json_from_text(text: str) -> dict[str, Any] | None: candidates: list[str] = [text.strip()] fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, flags=re.DOTALL | re.IGNORECASE) diff --git a/src/gitea_codex_bot/workers/dispatcher.py b/src/gitea_codex_bot/workers/dispatcher.py index afe69b7..3112c12 100644 --- a/src/gitea_codex_bot/workers/dispatcher.py +++ b/src/gitea_codex_bot/workers/dispatcher.py @@ -24,7 +24,8 @@ logger = logging.getLogger(__name__) def _command_from_job(job: ReviewJob) -> ParsedCommand: args = job.command_args.split() if job.command_args else [] - return ParsedCommand(name=job.command, raw=f"@codex {job.command}", arguments=args, full="--full" in args, branch_fix="--branch" in args) + 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, branch_fix="--branch" in args) def _handle_non_review_command( diff --git a/tests/test_container_runner.py b/tests/test_container_runner.py index 329bce7..9deec0f 100644 --- a/tests/test_container_runner.py +++ b/tests/test_container_runner.py @@ -9,6 +9,7 @@ from gitea_codex_bot.workers.container_runner import ( CONTAINER_CODEX_HOME, _build_docker_command, _build_install_and_run_command, + _extract_result_meta_from_codex_stdout, _load_codex_auth_json_b64, _parse_codex_exec_stdout, _resolve_codex_auth_json_path, @@ -145,3 +146,18 @@ def test_parse_codex_exec_stdout_from_fenced_json_text() -> None: parsed = _parse_codex_exec_stdout(stdout) assert parsed["verdict"] == "has_issues" assert parsed["summary"] == "x" + + +def test_extract_result_meta_from_codex_stdout_collects_model_and_usage() -> None: + settings = get_settings() + stdout = '\n'.join( + [ + '{"type":"response.started","model":"gpt-5.3-codex"}', + '{"type":"response.completed","response":{"usage":{"input_tokens":101,"output_tokens":22,"total_tokens":123}}}', + ] + ) + meta = _extract_result_meta_from_codex_stdout(stdout, settings) + assert meta["model"] == "gpt-5.3-codex" + assert meta["usage"]["input_tokens"] == 101 + assert meta["usage"]["output_tokens"] == 22 + assert meta["usage"]["total_tokens"] == 123 diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index dc812f0..d685f4d 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -23,6 +23,7 @@ def test_process_one_job_recreates_persistent_comment_when_edit_returns_404(monk pr_number=9, head_sha="deadbeef", trigger_comment_id=111, + trigger_comment_body="@codex review", requested_by="alice", command=ParsedCommand(name="review", raw="@codex review"), ) @@ -71,3 +72,44 @@ def test_process_one_job_recreates_persistent_comment_when_edit_returns_404(monk assert persisted_comment_id == 990 stored_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one() assert stored_job.status.value == "succeeded" + + +def test_process_one_job_passes_full_trigger_message_to_runner(monkeypatch) -> None: + captured: dict[str, str] = {} + session_factory = get_session_factory() + with session_factory() as session: + enqueue_job( + session, + repo="acme/repo", + pr_number=10, + head_sha="cafebabe", + trigger_comment_id=112, + trigger_comment_body="@codex review security --full\nFocus auth/session handling.", + requested_by="alice", + command=ParsedCommand(name="review", raw="@codex review security --full", arguments=["security", "--full"]), + ) + + 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": []} + + 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: + return 901 + + def edit_issue_comment(self, _repo: str, _comment_id: int, _body: str) -> int: + return _comment_id + + monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.run_review_ephemeral", _fake_run_review_ephemeral) + monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient) + + settings = get_settings() + processed = process_one_job(settings) + assert processed is True + assert captured["raw"] == "@codex review security --full\nFocus auth/session handling." diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 91af894..e569cae 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -3,6 +3,7 @@ from __future__ import annotations from sqlalchemy.exc import IntegrityError from gitea_codex_bot.db import get_session_factory +from gitea_codex_bot.models import ReviewJob from gitea_codex_bot.services.jobs import cooldown_remaining_seconds, enqueue_job, persist_webhook_event from gitea_codex_bot.types import ParsedCommand @@ -19,7 +20,16 @@ def test_enqueue_and_cooldown() -> None: session_factory = get_session_factory() with session_factory() as session: cmd = ParsedCommand(name="review", raw="@codex review") - enqueue_job(session, repo="acme/repo", pr_number=42, head_sha="abc", trigger_comment_id=100, requested_by="user", command=cmd) + enqueue_job( + session, + repo="acme/repo", + pr_number=42, + head_sha="abc", + trigger_comment_id=100, + trigger_comment_body="@codex review", + requested_by="user", + command=cmd, + ) remaining = cooldown_remaining_seconds(session, "acme/repo", 42, 60) assert remaining >= 0 @@ -28,11 +38,48 @@ def test_trigger_comment_unique() -> None: session_factory = get_session_factory() with session_factory() as session: cmd = ParsedCommand(name="review", raw="@codex review") - enqueue_job(session, repo="acme/repo", pr_number=7, head_sha="x", trigger_comment_id=321, requested_by="user", command=cmd) + enqueue_job( + session, + repo="acme/repo", + pr_number=7, + head_sha="x", + trigger_comment_id=321, + trigger_comment_body="@codex review", + requested_by="user", + command=cmd, + ) try: - enqueue_job(session, repo="acme/repo", pr_number=7, head_sha="x", trigger_comment_id=321, requested_by="user", command=cmd) + enqueue_job( + session, + repo="acme/repo", + pr_number=7, + head_sha="x", + trigger_comment_id=321, + trigger_comment_body="@codex review", + requested_by="user", + command=cmd, + ) duplicate_raised = False except IntegrityError: duplicate_raised = True session.rollback() assert duplicate_raised is True + + +def test_enqueue_persists_full_trigger_comment_body() -> None: + session_factory = get_session_factory() + with session_factory() as session: + cmd = ParsedCommand(name="review", raw="@codex review security\nplease focus auth") + job = enqueue_job( + session, + repo="acme/repo", + pr_number=55, + head_sha="abc123", + trigger_comment_id=9191, + trigger_comment_body=cmd.raw, + requested_by="alice", + command=cmd, + ) + stored = session.get(ReviewJob, job.id) + assert stored is not None + assert stored.trigger_comment_body == "@codex review security\nplease focus auth" diff --git a/tests/test_review_format.py b/tests/test_review_format.py index 14e41bf..1a5f8ce 100644 --- a/tests/test_review_format.py +++ b/tests/test_review_format.py @@ -28,3 +28,30 @@ def test_format_result_comment_replaces_existing_marker() -> None: assert body.startswith("") assert "old" not in body.splitlines()[0] + +def test_format_result_comment_appends_usage_note_for_markdown_comment() -> None: + body = format_result_comment( + "ff0011", + { + "markdown_comment": "## Codex Review\n\nLooks fine.", + "_meta": { + "model": "gpt-5.3-codex", + "usage": {"input_tokens": 120, "output_tokens": 45, "total_tokens": 165}, + }, + }, + ) + assert "_Note: model `gpt-5.3-codex`, input `120`, output `45`, total `165` tokens used._" in body + + +def test_format_result_comment_appends_usage_note_for_fallback_layout() -> None: + body = format_result_comment( + "ff0011", + { + "verdict": "correct", + "confidence": 0.8, + "summary": "No issues.", + "findings": [], + "_meta": {"model": "gpt-5.3-codex", "usage": {"total_tokens": 88}}, + }, + ) + assert body.endswith("_Note: model `gpt-5.3-codex`, total `88` tokens used._") diff --git a/tests/test_reviewer_fallback.py b/tests/test_reviewer_fallback.py index 9a818e6..08a3155 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 _fallback_review, run_review_for_pr +from gitea_codex_bot.services.reviewer import _build_prompt, _fallback_review, run_review_for_pr from gitea_codex_bot.types import ParsedCommand @@ -37,3 +37,21 @@ def test_run_review_for_pr_uses_openai_http_error_in_fallback(monkeypatch) -> No assert result["findings"][0]["title"] == "OpenAI review request failed" assert "rate_limited" in result["findings"][0]["body"] assert any(finding["title"] == "TODO marker in diff" for finding in result["findings"]) + + +def test_build_prompt_includes_trigger_message() -> None: + pr = type("PR", (), {"html_url": "https://gitea.example/pr/1"})() + command = ParsedCommand(name="review", raw="@codex review security\nPlease focus auth.") + diff_context = {"truncated": False, "changed_files": ["app.py"], "diff": "diff --git a/app.py b/app.py"} + repo_cfg = RepoReviewConfig() + + prompt = _build_prompt( + pr, + command, + diff_context, + repo_cfg, + changed_file_contents="", + test_output=None, + ) + + assert "Trigger message: @codex review security\nPlease focus auth." in prompt diff --git a/tests/test_transitions.py b/tests/test_transitions.py index 8b4bf3e..5e653e9 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -17,6 +17,7 @@ def test_claim_and_transition() -> None: pr_number=314, head_sha="deadbeef", trigger_comment_id=9901, + trigger_comment_body="@codex review", requested_by="alice", command=ParsedCommand(name="review", raw="@codex review"), ) @@ -33,4 +34,4 @@ def test_claim_and_transition() -> None: with session_factory() as session: loaded = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one() assert loaded.status == JobStatus.succeeded - assert loaded.result_json is not None \ No newline at end of file + assert loaded.result_json is not None diff --git a/tests/test_webhook.py b/tests/test_webhook.py index c7a5916..8cc7701 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -6,8 +6,11 @@ import json from typing import Any from fastapi.testclient import TestClient +from sqlalchemy import select from gitea_codex_bot.main import app +from gitea_codex_bot.db import get_session_factory +from gitea_codex_bot.models import ReviewJob def _sign(payload: bytes) -> str: @@ -79,6 +82,10 @@ def test_webhook_accepts_review_and_queues(monkeypatch) -> None: assert response.status_code == 200 assert response.json()["status"] == "queued" assert posted_comments + session_factory = get_session_factory() + with session_factory() as session: + queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 111)).scalar_one() + assert queued.trigger_comment_body == "@codex review security" def test_webhook_logs_when_no_codex_review_command(monkeypatch) -> None: