feat. Review foot note, docker fix, pass message to reviewer , update tests
This commit is contained in:
@@ -100,7 +100,7 @@ pytest
|
|||||||
Docker compose:
|
Docker compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build -f docker-compose.dev.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Contract
|
## Environment Contract
|
||||||
@@ -171,3 +171,9 @@ Treat these as high-sensitivity areas when modifying worker/runner paths.
|
|||||||
3. Add/update tests with behavior changes.
|
3. Add/update tests with behavior changes.
|
||||||
4. Run `pytest`.
|
4. Run `pytest`.
|
||||||
5. Summarize impact, risks, and follow-ups in PR/commit notes.
|
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.
|
||||||
2
TODO.md
2
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.
|
- [ ] `FEATURE`: Add username as possible command prefix, ex. "@bot-name review" in addition to "@codex review", for better UX discoverability.
|
||||||
|
|
||||||
### P2 (Nice to Have)
|
### 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`: 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`: 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.
|
||||||
|
|||||||
25
alembic/versions/0002_review_job_trigger_comment_body.py
Normal file
25
alembic/versions/0002_review_job_trigger_comment_body.py
Normal file
@@ -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")
|
||||||
@@ -19,9 +19,6 @@ services:
|
|||||||
|
|
||||||
bot:
|
bot:
|
||||||
build: .
|
build: .
|
||||||
depends_on:
|
|
||||||
mariadb:
|
|
||||||
condition: service_healthy
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ async def gitea_webhook(
|
|||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
head_sha=head_sha,
|
head_sha=head_sha,
|
||||||
trigger_comment_id=comment_id,
|
trigger_comment_id=comment_id,
|
||||||
|
trigger_comment_body=comment_body,
|
||||||
requested_by=sender_username,
|
requested_by=sender_username,
|
||||||
command=parsed_command,
|
command=parsed_command,
|
||||||
)
|
)
|
||||||
@@ -232,6 +233,7 @@ async def gitea_webhook(
|
|||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
head_sha=head_sha,
|
head_sha=head_sha,
|
||||||
trigger_comment_id=comment_id,
|
trigger_comment_id=comment_id,
|
||||||
|
trigger_comment_body=comment_body,
|
||||||
requested_by=sender_username,
|
requested_by=sender_username,
|
||||||
command=parsed_command,
|
command=parsed_command,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class ReviewJob(Base):
|
|||||||
pr_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
pr_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
head_sha: Mapped[str] = mapped_column(String(64), nullable=False)
|
head_sha: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
trigger_comment_id: Mapped[int] = mapped_column(Integer, 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: Mapped[str] = mapped_column(String(64), nullable=False, default="review")
|
||||||
command_args: Mapped[str | None] = mapped_column(Text, nullable=True)
|
command_args: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
requested_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
requested_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ def enqueue_job(
|
|||||||
pr_number: int,
|
pr_number: int,
|
||||||
head_sha: str,
|
head_sha: str,
|
||||||
trigger_comment_id: int,
|
trigger_comment_id: int,
|
||||||
|
trigger_comment_body: str | None,
|
||||||
requested_by: str,
|
requested_by: str,
|
||||||
command: ParsedCommand,
|
command: ParsedCommand,
|
||||||
) -> ReviewJob:
|
) -> ReviewJob:
|
||||||
@@ -69,6 +70,7 @@ def enqueue_job(
|
|||||||
pr_number=pr_number,
|
pr_number=pr_number,
|
||||||
head_sha=head_sha,
|
head_sha=head_sha,
|
||||||
trigger_comment_id=trigger_comment_id,
|
trigger_comment_id=trigger_comment_id,
|
||||||
|
trigger_comment_body=trigger_comment_body,
|
||||||
command=command.name,
|
command=command.name,
|
||||||
command_args=" ".join(command.arguments) if command.arguments else None,
|
command_args=" ".join(command.arguments) if command.arguments else None,
|
||||||
requested_by=requested_by,
|
requested_by=requested_by,
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ def format_unsupported_ack(command: ParsedCommand) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def format_result_comment(head_sha: str, result: dict) -> str:
|
def format_result_comment(head_sha: str, result: dict) -> str:
|
||||||
|
usage_note = _format_usage_note(result)
|
||||||
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():
|
||||||
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")
|
verdict = result.get("verdict", "has_issues")
|
||||||
confidence = float(result.get("confidence", 0.0))
|
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",
|
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._"
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ def _build_prompt(
|
|||||||
"}\n\n"
|
"}\n\n"
|
||||||
f"PR URL: {pr.html_url}\n"
|
f"PR URL: {pr.html_url}\n"
|
||||||
f"Mode: {mode}\n"
|
f"Mode: {mode}\n"
|
||||||
|
f"Trigger message: {command.raw}\n"
|
||||||
f"Repo focus: {', '.join(repo_cfg.focus)}\n"
|
f"Repo focus: {', '.join(repo_cfg.focus)}\n"
|
||||||
f"Diff truncated: {diff_context['truncated']}\n"
|
f"Diff truncated: {diff_context['truncated']}\n"
|
||||||
f"Changed files:\n{os.linesep.join(diff_context['changed_files'])}\n\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", []):
|
for content in item.get("content", []):
|
||||||
text_value = content.get("text")
|
text_value = content.get("text")
|
||||||
if text_value:
|
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.")
|
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:
|
def _summarize_openai_failure(exc: Exception) -> str:
|
||||||
if isinstance(exc, httpx.HTTPStatusError):
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
status = exc.response.status_code
|
status = exc.response.status_code
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ def run_review_ephemeral(
|
|||||||
if completed.returncode != 0:
|
if completed.returncode != 0:
|
||||||
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)
|
||||||
return normalize_review_result(parsed)
|
return normalize_review_result(parsed)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if settings.codex_auth_mode == "chatgpt":
|
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)}")
|
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:
|
def _parse_review_json_from_text(text: str) -> dict[str, Any] | None:
|
||||||
candidates: list[str] = [text.strip()]
|
candidates: list[str] = [text.strip()]
|
||||||
fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, flags=re.DOTALL | re.IGNORECASE)
|
fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _command_from_job(job: ReviewJob) -> ParsedCommand:
|
def _command_from_job(job: ReviewJob) -> ParsedCommand:
|
||||||
args = job.command_args.split() if job.command_args else []
|
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(
|
def _handle_non_review_command(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from gitea_codex_bot.workers.container_runner import (
|
|||||||
CONTAINER_CODEX_HOME,
|
CONTAINER_CODEX_HOME,
|
||||||
_build_docker_command,
|
_build_docker_command,
|
||||||
_build_install_and_run_command,
|
_build_install_and_run_command,
|
||||||
|
_extract_result_meta_from_codex_stdout,
|
||||||
_load_codex_auth_json_b64,
|
_load_codex_auth_json_b64,
|
||||||
_parse_codex_exec_stdout,
|
_parse_codex_exec_stdout,
|
||||||
_resolve_codex_auth_json_path,
|
_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)
|
parsed = _parse_codex_exec_stdout(stdout)
|
||||||
assert parsed["verdict"] == "has_issues"
|
assert parsed["verdict"] == "has_issues"
|
||||||
assert parsed["summary"] == "x"
|
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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def test_process_one_job_recreates_persistent_comment_when_edit_returns_404(monk
|
|||||||
pr_number=9,
|
pr_number=9,
|
||||||
head_sha="deadbeef",
|
head_sha="deadbeef",
|
||||||
trigger_comment_id=111,
|
trigger_comment_id=111,
|
||||||
|
trigger_comment_body="@codex review",
|
||||||
requested_by="alice",
|
requested_by="alice",
|
||||||
command=ParsedCommand(name="review", raw="@codex review"),
|
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
|
assert persisted_comment_id == 990
|
||||||
stored_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
|
stored_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
|
||||||
assert stored_job.status.value == "succeeded"
|
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."
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from gitea_codex_bot.db import get_session_factory
|
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.services.jobs import cooldown_remaining_seconds, enqueue_job, persist_webhook_event
|
||||||
from gitea_codex_bot.types import ParsedCommand
|
from gitea_codex_bot.types import ParsedCommand
|
||||||
|
|
||||||
@@ -19,7 +20,16 @@ def test_enqueue_and_cooldown() -> None:
|
|||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
with session_factory() as session:
|
with session_factory() as session:
|
||||||
cmd = ParsedCommand(name="review", raw="@codex review")
|
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)
|
remaining = cooldown_remaining_seconds(session, "acme/repo", 42, 60)
|
||||||
assert remaining >= 0
|
assert remaining >= 0
|
||||||
|
|
||||||
@@ -28,11 +38,48 @@ def test_trigger_comment_unique() -> None:
|
|||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
with session_factory() as session:
|
with session_factory() as session:
|
||||||
cmd = ParsedCommand(name="review", raw="@codex review")
|
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:
|
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
|
duplicate_raised = False
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
duplicate_raised = True
|
duplicate_raised = True
|
||||||
session.rollback()
|
session.rollback()
|
||||||
assert duplicate_raised is True
|
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"
|
||||||
|
|||||||
@@ -28,3 +28,30 @@ def test_format_result_comment_replaces_existing_marker() -> None:
|
|||||||
assert body.startswith("<!-- codex-review:head_sha=def5678 -->")
|
assert body.startswith("<!-- codex-review:head_sha=def5678 -->")
|
||||||
assert "old" not in body.splitlines()[0]
|
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._")
|
||||||
|
|||||||
@@ -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 _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
|
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 result["findings"][0]["title"] == "OpenAI review request failed"
|
||||||
assert "rate_limited" in result["findings"][0]["body"]
|
assert "rate_limited" in result["findings"][0]["body"]
|
||||||
assert any(finding["title"] == "TODO marker in diff" for finding in result["findings"])
|
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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ def test_claim_and_transition() -> None:
|
|||||||
pr_number=314,
|
pr_number=314,
|
||||||
head_sha="deadbeef",
|
head_sha="deadbeef",
|
||||||
trigger_comment_id=9901,
|
trigger_comment_id=9901,
|
||||||
|
trigger_comment_body="@codex review",
|
||||||
requested_by="alice",
|
requested_by="alice",
|
||||||
command=ParsedCommand(name="review", raw="@codex review"),
|
command=ParsedCommand(name="review", raw="@codex review"),
|
||||||
)
|
)
|
||||||
@@ -33,4 +34,4 @@ def test_claim_and_transition() -> None:
|
|||||||
with session_factory() as session:
|
with session_factory() as session:
|
||||||
loaded = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
|
loaded = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
|
||||||
assert loaded.status == JobStatus.succeeded
|
assert loaded.status == JobStatus.succeeded
|
||||||
assert loaded.result_json is not None
|
assert loaded.result_json is not None
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import json
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from gitea_codex_bot.main import app
|
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:
|
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.status_code == 200
|
||||||
assert response.json()["status"] == "queued"
|
assert response.json()["status"] == "queued"
|
||||||
assert posted_comments
|
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:
|
def test_webhook_logs_when_no_codex_review_command(monkeypatch) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user