Compare commits

..

8 Commits

Author SHA1 Message Date
cf95343e06 Merge pull request 'improvements: Big Reviewer Update' (#4) from improv/big-reviewer-update into main
All checks were successful
ci / test (push) Successful in 36s
ci / publish (push) Successful in 54s
Reviewed-on: #4
2026-05-23 14:22:33 +02:00
Space-Banane
d1ca1052f4 [fix]. Reply on unsupported @codex commands
All checks were successful
ci / test (pull_request) Successful in 36s
ci / publish (pull_request) Has been skipped
2026-05-23 14:20:37 +02:00
Space-Banane
08075cb3c4 [fix]. Restore PR-scoped review + remove fix cmd
All checks were successful
ci / test (pull_request) Successful in 32s
ci / publish (pull_request) Has been skipped
2026-05-23 14:15:00 +02:00
Space-Banane
9392591429 [feat]. Add @codex -h help summary command
All checks were successful
ci / test (pull_request) Successful in 38s
ci / publish (pull_request) Has been skipped
2026-05-23 14:07:48 +02:00
Space-Banane
30aa737516 [refactor]. Remove legacy review prompt path
All checks were successful
ci / test (pull_request) Successful in 34s
ci / publish (pull_request) Has been skipped
2026-05-23 14:03:20 +02:00
Space-Banane
01e10abc71 [fix]. Harden fork PR fetch + config marker
All checks were successful
ci / test (pull_request) Successful in 33s
ci / publish (pull_request) Has been skipped
2026-05-23 13:57:51 +02:00
Space-Banane
c3925f37e1 Resolve Runner issue, runner refused to launch
All checks were successful
ci / test (pull_request) Successful in 35s
ci / publish (pull_request) Has been skipped
2026-05-23 13:53:26 +02:00
Space-Banane
d6a9397914 feat: Enhance review prompt with detailed instructions and placeholders for empty sections
All checks were successful
ci / test (pull_request) Successful in 34s
ci / publish (pull_request) Has been skipped
2026-05-23 13:37:09 +02:00
19 changed files with 766 additions and 663 deletions

View File

@@ -50,8 +50,5 @@ CONCURRENCY=1
# Image used for ephemeral job containers (Node + npm + Codex CLI install).
REVIEW_RUNNER_IMAGE=node:22-bookworm-slim
# Keep false for review-only mode.
ENABLE_FIX_COMMANDS=false
# Security: fork PRs are skipped unless explicitly enabled.
ALLOW_UNTRUSTED_FORKS=false

View File

@@ -78,7 +78,6 @@ Primary implementation lives under `src/gitea_codex_bot`.
- `@codex review [security|performance|tests] [--full]`
- `@codex rerun`
- `@codex explain`
- `@codex fix [--branch ...]` (gated by `ENABLE_FIX_COMMANDS`)
- `@codex ignore`
## Local Development
@@ -120,12 +119,10 @@ Common optional:
- `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`)
- `OPENAI_PROJECT_ID`, `OPENAI_ORG_ID`
- `OPENAI_REVIEW_MODEL`
- `OPENAI_REASONING_EFFORT`
- `CODEX_AUTH_MODE` (`api_key` default, `chatgpt` supported)
- `CODEX_AUTH_JSON_PATH` (custom path to `auth.json` for `chatgpt` mode)
- `WORKDIR`, `MAX_DIFF_BYTES`, `MAX_REVIEW_MINUTES`, `CONCURRENCY`
- `REVIEW_RUNNER_IMAGE`
- `ENABLE_FIX_COMMANDS`
- `ALLOW_UNTRUSTED_FORKS`
## Database and Migrations

View File

@@ -3,10 +3,10 @@
## Open Items By Priority
### P0 (Critical)
- [ ] `BUG`: True isolated runner flow: clone/fetch/checkout PR branch inside the ephemeral container itself, not on host before prompt generation.
- [x] `BUG`: True isolated runner flow: clone/fetch/checkout PR branch inside the ephemeral container itself, not on host before prompt generation.
- [x] `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.
- [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`.
- [x] `BUG`: Remove fix command support from runtime and command parsing.
- [x] `BUG`: Add stuck-job recovery for `running` jobs (lease timeout + requeue/fail) so one crashed worker does not deadlock the queue.
- [x] `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.
@@ -15,7 +15,7 @@
- [x] `BUG`: Log webhook events rejected because repo is not listed in `ALLOWED_REPOS`.
- [ ] `FEATURE`: Full control UI to update the bots settings. Password in env variable protected login page. No more env variables.
- [ ] `FEATURE`: Automatic Trigger on new PRs and or commits on PRs with context that its a change that needs review not the whole PR again. GITEA_ALLOW_PR_AUTO_REVIEW=true would be needed
- [ ] `BUG`: Container runner hardcodes `codex exec --json -m gpt-5`; use `OPENAI_REVIEW_MODEL` and `OPENAI_REASONING_EFFORT` consistently across runner paths.
- [x] `BUG`: Container runner now uses configured `OPENAI_REVIEW_MODEL` and no longer configures reasoning-effort flags.
- [ ] `BUG`: Preserve command arguments losslessly (quoted args are currently flattened by `" ".join(...)` + `.split()` roundtrip).
- [ ] `BUG`: `parse_command` only matches when `@codex` is at the start of the comment; support inline command usage in normal review-discussion comments.
- [ ] `BUG`: Add max comment length handling/chunking before posting to Gitea to avoid failures on large review outputs.
@@ -28,7 +28,7 @@
- [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`: 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`, `explain`, and `rerun` independently.
- [ ] `TEST`: Add structured log redaction tests to ensure PAT/keys never appear in logs/comments.
### P3 (Backlog)

View File

@@ -20,7 +20,6 @@ class Settings(BaseSettings):
openai_project_id: str | None = Field(default=None, alias="OPENAI_PROJECT_ID")
openai_org_id: str | None = Field(default=None, alias="OPENAI_ORG_ID")
openai_review_model: str = Field(default="gpt-5.3-codex", alias="OPENAI_REVIEW_MODEL")
openai_reasoning_effort: Literal["none", "low", "medium", "high"] = Field(default="high", alias="OPENAI_REASONING_EFFORT")
codex_auth_mode: Literal["api_key", "chatgpt"] = Field(default="api_key", alias="CODEX_AUTH_MODE")
codex_auth_json_path: str | None = Field(default=None, alias="CODEX_AUTH_JSON_PATH")
@@ -41,7 +40,6 @@ class Settings(BaseSettings):
concurrency: int = Field(default=1, alias="CONCURRENCY")
review_runner_image: str = Field(default="node:22-bookworm-slim", alias="REVIEW_RUNNER_IMAGE")
enable_fix_commands: bool = Field(default=False, alias="ENABLE_FIX_COMMANDS")
allow_untrusted_forks: bool = Field(default=False, alias="ALLOW_UNTRUSTED_FORKS")
@field_validator("gitea_base_url")

View File

@@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
from gitea_codex_bot.config import Settings, get_settings
from gitea_codex_bot.db import get_session
from gitea_codex_bot.models import JobStatus, ReviewJob
from gitea_codex_bot.services.commands import parse_command
from gitea_codex_bot.services.commands import detect_prefixed_command, 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
@@ -421,9 +421,31 @@ async def gitea_webhook(
if sender_username == settings.gitea_bot_username:
return {"accepted": False, "reason": "bot comment ignored"}
if repo not in settings.allowed_repo_set:
logger.info(
"Webhook ignored: repo not in ALLOWED_REPOS repo=%s pr=%s comment_id=%s sender=%s",
repo,
pr_number,
comment_id,
sender_username,
)
return {"accepted": False, "reason": "repo not allowed"}
comment_body = str(payload.get("comment", {}).get("body", "")).strip()
parsed_command = parse_command(comment_body, aliases=settings.bot_command_aliases)
if not parsed_command:
attempted_command = detect_prefixed_command(comment_body, aliases=settings.bot_command_aliases)
if attempted_command:
gitea = GiteaClient(settings)
if attempted_command == "fix":
gitea.post_issue_comment(repo, pr_number, "⚠️ `@codex fix` is no longer supported on this bot.")
return {"accepted": False, "reason": "unsupported command", "command": attempted_command}
gitea.post_issue_comment(
repo,
pr_number,
f"⚠️ Command `@codex {attempted_command}` is not supported. Try `@codex -h`.",
)
return {"accepted": False, "reason": "unsupported command", "command": attempted_command}
logger.info(
"Webhook ignored: no @codex review command repo=%s pr=%s comment_id=%s sender=%s",
repo,
@@ -442,16 +464,6 @@ async def gitea_webhook(
parsed_command.name,
)
if repo not in settings.allowed_repo_set:
logger.info(
"Webhook ignored: repo not in ALLOWED_REPOS repo=%s pr=%s comment_id=%s sender=%s",
repo,
pr_number,
comment_id,
sender_username,
)
return {"accepted": False, "reason": "repo not allowed"}
inserted = persist_webhook_event(
session,
delivery_id=x_gitea_delivery,
@@ -495,7 +507,7 @@ async def gitea_webhook(
gitea.post_issue_comment(repo, pr_number, format_queue_ack(head_sha))
return {"accepted": True, "job_id": job.id, "status": "queued"}
if parsed_command.name in {"fix", "explain", "ignore"}:
if parsed_command.name in {"explain", "ignore", "help"}:
job = enqueue_job(
session,
repo=repo,

View File

@@ -5,12 +5,29 @@ from collections.abc import Iterable
from gitea_codex_bot.types import ParsedCommand
COMMAND_RE = re.compile(r"^@([^\s]+)\s+(review|explain|fix|ignore|rerun)\b(.*)$", re.IGNORECASE | re.DOTALL)
PREFIX_RE = re.compile(r"^@([^\s]+)\s+(.+)$", re.IGNORECASE | re.DOTALL)
HELP_ALIASES = {"-h", "--help", "help"}
SUPPORTED_COMMANDS = {"review", "explain", "ignore", "rerun"}
def detect_prefixed_command(body: str, aliases: Iterable[str] | None = None) -> str | None:
stripped = body.strip()
match = PREFIX_RE.match(stripped)
if not match:
return None
command_alias = match.group(1).lstrip("@").lower()
allowed_aliases = {alias.lstrip("@").lower() for alias in (aliases or {"codex"})}
if command_alias not in allowed_aliases:
return None
remainder = match.group(2).strip()
if not remainder:
return None
return remainder.split(maxsplit=1)[0].lower()
def parse_command(body: str, aliases: Iterable[str] | None = None) -> ParsedCommand | None:
stripped = body.strip()
match = COMMAND_RE.match(stripped)
match = PREFIX_RE.match(stripped)
if not match:
return None
command_alias = match.group(1).lstrip("@").lower()
@@ -18,8 +35,17 @@ def parse_command(body: str, aliases: Iterable[str] | None = None) -> ParsedComm
if command_alias not in allowed_aliases:
return None
name = match.group(2).lower()
rest = match.group(3).strip()
remainder = match.group(2).strip()
if not remainder:
return None
parts = remainder.split(maxsplit=1)
raw_name = parts[0].lower()
rest = parts[1].strip() if len(parts) > 1 else ""
if raw_name in HELP_ALIASES:
return ParsedCommand(name="help", raw=stripped, arguments=[token for token in rest.split() if token])
if raw_name not in SUPPORTED_COMMANDS:
return None
name = raw_name
tokens = [token for token in rest.split() if token]
parsed = ParsedCommand(name=name, raw=stripped, arguments=tokens)
@@ -33,6 +59,4 @@ def parse_command(body: str, aliases: Iterable[str] | None = None) -> ParsedComm
parsed.mode = mode
parsed.mode_explicit = True
break
elif name == "fix":
parsed.branch_fix = "--branch" in tokens
return parsed

View File

@@ -21,6 +21,8 @@ class PullRequestContext:
clone_url: str
html_url: str
is_fork: bool
base_clone_url: str | None = None
head_clone_url: str | None = None
class GiteaClient:
@@ -56,6 +58,8 @@ class GiteaClient:
encoded_owner = quote(owner, safe="")
encoded_name = quote(name, safe="")
payload = self._request("GET", f"/api/v1/repos/{encoded_owner}/{encoded_name}/pulls/{pr_number}")
base_clone_url = payload["base"]["repo"]["clone_url"]
head_clone_url = payload["head"]["repo"]["clone_url"]
return PullRequestContext(
repo=repo,
pr_number=pr_number,
@@ -63,7 +67,9 @@ class GiteaClient:
base_sha=payload["base"]["sha"],
head_ref=payload["head"]["ref"],
head_sha=payload["head"]["sha"],
clone_url=payload["head"]["repo"]["clone_url"],
clone_url=head_clone_url,
base_clone_url=base_clone_url,
head_clone_url=head_clone_url,
html_url=payload["html_url"],
is_fork=bool(payload["head"]["repo"]["full_name"] != payload["base"]["repo"]["full_name"]),
)

View File

@@ -41,6 +41,8 @@ def format_result_comment(head_sha: str, result: dict, *, repo_configured: bool
body = markdown_comment.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)
verdict = result.get("verdict", "has_issues")
@@ -105,4 +107,4 @@ def _format_usage_note(result: dict) -> str:
def _format_missing_config_note(repo_configured: bool) -> str:
if repo_configured:
return ""
return ".codex-review.yml is not configured"
return "> .codex-review.yml is not configured"

View File

@@ -1,303 +1,12 @@
from __future__ import annotations
import json
import os
import shlex
import subprocess
from fnmatch import fnmatch
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
import httpx
from gitea_codex_bot.config import Settings
from gitea_codex_bot.services.gitea import GiteaClient, PullRequestContext
from gitea_codex_bot.services.repo_config import RepoReviewConfig, load_repo_review_config
from gitea_codex_bot.types import ParsedCommand
class ReviewError(RuntimeError):
pass
def _run_git(args: list[str], cwd: Path | None = None) -> str:
completed = subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True)
return completed.stdout
def checkout_pr(tmpdir: Path, pr: PullRequestContext) -> Path:
repo_dir = tmpdir / "repo"
_run_git(["clone", "--no-tags", "--depth", "50", pr.clone_url, str(repo_dir)])
_run_git(["fetch", "origin", pr.base_ref, pr.head_ref], cwd=repo_dir)
_run_git(["checkout", pr.head_sha], cwd=repo_dir)
return repo_dir
def collect_diff_context(repo_dir: Path, pr: PullRequestContext, max_diff_bytes: int) -> dict[str, Any]:
diff = _run_git(["diff", f"{pr.base_sha}...{pr.head_sha}"], cwd=repo_dir)
changed_files_raw = _run_git(["diff", "--name-only", f"{pr.base_sha}...{pr.head_sha}"], cwd=repo_dir)
changed_files = [line.strip() for line in changed_files_raw.splitlines() if line.strip()]
truncated = False
if len(diff.encode("utf-8")) > max_diff_bytes:
diff = diff.encode("utf-8")[:max_diff_bytes].decode("utf-8", errors="ignore")
truncated = True
return {"diff": diff, "changed_files": changed_files, "truncated": truncated}
def _apply_ignore_patterns(changed_files: list[str], ignore_patterns: list[str]) -> list[str]:
if not ignore_patterns:
return changed_files
kept: list[str] = []
for path in changed_files:
if any(fnmatch(path, pattern) for pattern in ignore_patterns):
continue
kept.append(path)
return kept
def _collect_changed_file_contents(repo_dir: Path, changed_files: list[str], max_total_bytes: int) -> str:
chunks: list[str] = []
total = 0
for rel in changed_files:
path = repo_dir / rel
if not path.exists() or not path.is_file():
continue
try:
content = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
block = f"\n### {rel}\n{content}\n"
block_bytes = len(block.encode("utf-8"))
if total + block_bytes > max_total_bytes:
break
chunks.append(block)
total += block_bytes
return "".join(chunks).strip()
def _collect_test_output(repo_dir: Path, timeout_seconds: int) -> str:
try:
completed = subprocess.run(
["pytest", "-q"],
cwd=repo_dir,
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
output = (completed.stdout + "\n" + completed.stderr).strip()
return output[:10000]
except Exception as exc:
return f"Test execution unavailable: {exc}"
def _redact_secrets_from_diff(diff: str) -> str:
secret_terms = ("api_key", "token", "secret", "password", "private_key", "-----begin")
redacted_lines: list[str] = []
for line in diff.splitlines():
lower = line.lower()
if any(term in lower for term in secret_terms):
redacted_lines.append("[REDACTED_POTENTIAL_SECRET]")
else:
redacted_lines.append(line)
return "\n".join(redacted_lines)
def _build_prompt(
pr: PullRequestContext,
command: ParsedCommand,
diff_context: dict[str, Any],
repo_cfg: RepoReviewConfig,
*,
changed_file_contents: str,
test_output: str | None,
) -> str:
mode = command.mode if command.name in {"review", "rerun"} else "summary"
return (
"You are reviewing a Gitea pull request.\n\n"
"Focus only on issues introduced by this PR.\n"
"Prioritize correctness, security, data loss, broken behavior, bad migrations, and missing tests.\n"
"Avoid style nitpicks.\n\n"
"Return JSON only with schema:\n"
"{\n"
' "verdict": "correct" | "has_issues",\n'
' "confidence": 0.0,\n'
' "summary": "...",\n'
' "findings": [{"severity":"low|medium|high|critical","file":"...","line_start":1,"line_end":1,"title":"...","body":"...","suggestion":"..."}],\n'
' "markdown_comment": "Full markdown comment body to post to Gitea. Include clear section breaks and blank lines."\n'
"}\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"
f"Unified diff:\n{diff_context['diff']}\n\n"
f"Changed file content (optional):\n{changed_file_contents or '(not included)'}\n\n"
f"Test output (optional):\n{test_output or '(not included)'}\n"
)
def _call_openai_review(settings: Settings, prompt: str) -> dict[str, Any]:
api_key = settings.openai_api_key.get_secret_value() if settings.openai_api_key else ""
if not api_key.strip():
raise ReviewError("OPENAI_API_KEY is required for API-key review mode.")
headers: dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if settings.openai_org_id:
headers["OpenAI-Organization"] = settings.openai_org_id
if settings.openai_project_id:
headers["OpenAI-Project"] = settings.openai_project_id
body = {
"model": settings.openai_review_model,
"input": prompt,
"text": {"format": {"type": "json_object"}},
"reasoning": {"effort": settings.openai_reasoning_effort},
}
with httpx.Client(timeout=120.0) as client:
response = client.post("https://api.openai.com/v1/responses", headers=headers, json=body)
response.raise_for_status()
payload = response.json()
for item in payload.get("output", []):
for content in item.get("content", []):
text_value = content.get("text")
if 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
response_text = exc.response.text.strip()
if response_text:
compact = " ".join(response_text.split())
if len(compact) > 400:
compact = f"{compact[:400]}..."
return f"OpenAI API HTTP {status}: {compact}"
return f"OpenAI API HTTP {status}."
if isinstance(exc, httpx.TimeoutException):
return "OpenAI API request timed out."
message = str(exc).strip()
if message:
return message
return f"{exc.__class__.__name__} (no details)"
def _fallback_review(diff_context: dict[str, Any], *, failure_reason: str | None = None) -> dict[str, Any]:
findings: list[dict[str, Any]] = []
summary = "Fallback analysis was used because OpenAI review was unavailable."
if failure_reason:
summary = f"OpenAI review failed. Error: {failure_reason}"
findings.append(
{
"severity": "high",
"file": "unknown",
"line_start": 1,
"line_end": 1,
"title": "OpenAI review request failed",
"body": failure_reason,
"suggestion": "Fix API/auth/network issues and rerun @codex review.",
}
)
if "TODO" in diff_context["diff"]:
findings.append(
{
"severity": "low",
"file": "unknown",
"line_start": 1,
"line_end": 1,
"title": "TODO marker in diff",
"body": "The change introduces TODO markers that may indicate incomplete behavior.",
"suggestion": "Resolve or track TODOs before merging.",
}
)
return {
"verdict": "correct" if not findings else "has_issues",
"confidence": 0.4 if not findings else 0.6,
"summary": summary,
"findings": findings,
}
def run_review_for_pr(
settings: Settings,
gitea: GiteaClient,
repo: str,
pr_number: int,
command: ParsedCommand,
) -> tuple[dict[str, Any], RepoReviewConfig]:
prompt, diff_context, repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command)
try:
result = _call_openai_review(settings, prompt)
except Exception as exc:
result = _fallback_review(diff_context, failure_reason=_summarize_openai_failure(exc))
return normalize_review_result(result), repo_cfg
def prepare_review_prompt(
settings: Settings,
gitea: GiteaClient,
repo: str,
pr_number: int,
command: ParsedCommand,
) -> tuple[str, dict[str, Any], RepoReviewConfig]:
pr = gitea.get_pull_request(repo, pr_number)
with TemporaryDirectory(prefix="gitea-codex-") as tmp:
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"])
changed_file_contents = ""
if command.full:
changed_file_contents = _collect_changed_file_contents(repo_dir, diff_context["changed_files"], settings.max_diff_bytes)
test_output = None
if repo_cfg.include_tests and command.mode == "tests":
test_output = _collect_test_output(repo_dir, timeout_seconds=min(settings.max_review_minutes * 60, 300))
prompt = _build_prompt(
pr,
command,
diff_context,
repo_cfg,
changed_file_contents=changed_file_contents,
test_output=test_output,
)
return prompt, diff_context, repo_cfg
def normalize_review_result(result: Any) -> dict[str, Any]:
if not isinstance(result, dict):
raise ReviewError(f"Invalid review result type: {type(result)!r}")
@@ -310,44 +19,3 @@ def normalize_review_result(result: Any) -> dict[str, Any]:
if "confidence" not in result:
result["confidence"] = 0.5
return result
def summarize_command(command: ParsedCommand) -> str:
return " ".join(["@codex", command.name, *command.arguments]).strip()
def fix_branch_name(pr_number: int, arguments: list[str] | None = None) -> str:
suffix = "fix"
if arguments:
words = [token.lower().strip() for token in arguments if token.strip() and not token.startswith("--")]
if words:
clean = "-".join(words[:4])
cleaned = "".join(ch if ch.isalnum() or ch == "-" else "-" for ch in clean).strip("-")
if cleaned:
suffix = f"fix-{cleaned}"
return f"codex/pr-{pr_number}-{suffix}"
def create_fix_patch_note(command: ParsedCommand) -> str:
details = shlex.join(command.arguments) if command.arguments else "latest findings"
return f"Fix command requested for {details}."
def create_fix_branch(
pr: PullRequestContext,
*,
note: str,
arguments: list[str] | None = None,
) -> str:
branch = fix_branch_name(pr.pr_number, arguments=arguments)
with TemporaryDirectory(prefix="gitea-codex-fix-") as tmp:
tmpdir = Path(tmp)
repo_dir = checkout_pr(tmpdir, pr)
_run_git(["checkout", "-b", branch], cwd=repo_dir)
notes_dir = repo_dir / ".codex"
notes_dir.mkdir(parents=True, exist_ok=True)
(notes_dir / "fix-note.md").write_text(f"# Codex Fix Note\n\n{note}\n", encoding="utf-8")
_run_git(["add", ".codex/fix-note.md"], cwd=repo_dir)
_run_git(["-c", "user.name=codex-bot", "-c", "user.email=codex-bot@example.invalid", "commit", "-m", f"Codex fix note for PR {pr.pr_number}"], cwd=repo_dir)
_run_git(["push", "origin", f"{branch}:{branch}", "--force"], cwd=repo_dir)
return branch

View File

@@ -4,7 +4,7 @@ from dataclasses import dataclass, field
from typing import Literal
CommandName = Literal["review", "explain", "fix", "ignore", "rerun"]
CommandName = Literal["review", "explain", "ignore", "rerun", "help"]
@dataclass(slots=True)
@@ -14,5 +14,4 @@ class ParsedCommand:
mode: str = "summary"
mode_explicit: bool = False
full: bool = False
branch_fix: bool = False
arguments: list[str] = field(default_factory=list)

View File

@@ -12,14 +12,47 @@ from pathlib import Path
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
from gitea_codex_bot.services.gitea import GiteaClient, PullRequestContext
from gitea_codex_bot.services.repo_config import RepoReviewConfig, parse_repo_review_config_text
from gitea_codex_bot.services.reviewer import normalize_review_result
from gitea_codex_bot.types import ParsedCommand
CONTAINER_CODEX_HOME = "/root/.codex"
REVIEW_OUTPUT_FILE = "/tmp/codex-review-result.json"
REVIEW_SCHEMA_FILE = "/tmp/codex-review-schema.json"
RESULT_START_MARKER = "__CODEX_REVIEW_RESULT_BEGIN__"
RESULT_END_MARKER = "__CODEX_REVIEW_RESULT_END__"
logger = logging.getLogger(__name__)
REVIEW_RESULT_SCHEMA: dict[str, Any] = {
"type": "object",
"additionalProperties": True,
"required": ["verdict", "confidence", "summary", "findings", "markdown_comment"],
"properties": {
"verdict": {"type": "string", "enum": ["correct", "has_issues"]},
"confidence": {"type": "number"},
"summary": {"type": "string"},
"markdown_comment": {"type": "string"},
"findings": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": True,
"required": ["severity", "file", "line_start", "line_end", "title", "body"],
"properties": {
"severity": {"type": "string", "enum": ["low", "medium", "high", "critical"]},
"file": {"type": "string"},
"line_start": {"type": "integer"},
"line_end": {"type": "integer"},
"title": {"type": "string"},
"body": {"type": "string"},
"suggestion": {"type": "string"},
},
},
},
},
}
def run_review_ephemeral(
settings: Settings,
@@ -29,31 +62,43 @@ def run_review_ephemeral(
command: ParsedCommand,
) -> tuple[dict[str, Any], RepoReviewConfig]:
gitea = GiteaClient(settings)
prompt, _diff_context, repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command)
pr = gitea.get_pull_request(repo, pr_number)
repo_cfg = _load_repo_review_config_from_gitea(gitea, repo, pr.head_sha)
_apply_repo_default_review_mode(command, repo_cfg)
review_prompt = _build_exec_review_prompt(command, repo_cfg, pr)
container_name = f"codex-review-{uuid.uuid4().hex[:12]}"
extra_env: dict[str, str] = {}
marker_nonce = uuid.uuid4().hex
result_start_marker = f"{RESULT_START_MARKER}_{marker_nonce}"
result_end_marker = f"{RESULT_END_MARKER}_{marker_nonce}"
extra_env: dict[str, str] = {
"GITEA_TOKEN": settings.gitea_token.get_secret_value(),
"GITEA_GIT_USERNAME": settings.gitea_bot_username,
}
if settings.openai_api_key:
extra_env["OPENAI_API_KEY"] = settings.openai_api_key.get_secret_value()
if settings.openai_org_id:
extra_env["OPENAI_ORG_ID"] = settings.openai_org_id
if settings.openai_project_id:
extra_env["OPENAI_PROJECT_ID"] = settings.openai_project_id
if settings.codex_auth_mode == "chatgpt":
extra_env["CODEX_AUTH_JSON_B64"] = _load_codex_auth_json_b64(settings)
try:
completed = _run_ephemeral_container(
settings,
pr=pr,
container_name=container_name,
prompt=prompt,
review_prompt=review_prompt,
result_start_marker=result_start_marker,
result_end_marker=result_end_marker,
extra_env=extra_env,
include_reasoning_effort=True,
)
if _needs_reasoning_effort_compat_retry(completed):
logger.info("Ephemeral runner does not support --reasoning-effort; retrying without it.")
completed = _run_ephemeral_container(
settings,
container_name=container_name,
prompt=prompt,
extra_env=extra_env,
include_reasoning_effort=False,
)
if completed.returncode != 0:
raise RuntimeError(_format_runner_failure(completed))
parsed = _parse_codex_exec_stdout(completed.stdout)
parsed = _parse_review_result_from_stdout_artifact(
completed.stdout,
result_start_marker=result_start_marker,
result_end_marker=result_end_marker,
)
parsed["_meta"] = _extract_result_meta_from_codex_stdout(completed.stdout, settings)
return normalize_review_result(parsed), repo_cfg
except Exception as exc:
@@ -64,16 +109,23 @@ def run_review_ephemeral(
def _run_ephemeral_container(
settings: Settings,
*,
pr: PullRequestContext,
container_name: str,
prompt: str,
review_prompt: str,
result_start_marker: str,
result_end_marker: str,
extra_env: dict[str, str],
include_reasoning_effort: bool,
) -> subprocess.CompletedProcess[str]:
install_and_run = _build_install_and_run_command(settings, include_reasoning_effort=include_reasoning_effort)
install_and_run = _build_install_and_run_command(
settings,
pr=pr,
review_prompt=review_prompt,
result_start_marker=result_start_marker,
result_end_marker=result_end_marker,
)
cmd = _build_docker_command(settings, container_name=container_name, install_and_run=install_and_run)
return subprocess.run(
cmd,
input=prompt,
text=True,
check=False,
capture_output=True,
@@ -82,8 +134,27 @@ def _run_ephemeral_container(
)
def _build_install_and_run_command(settings: Settings, *, include_reasoning_effort: bool = True) -> str:
def _build_install_and_run_command(
settings: Settings,
*,
pr: PullRequestContext,
review_prompt: str,
result_start_marker: str,
result_end_marker: str,
) -> str:
steps = ["set -euo pipefail"]
if settings.codex_auth_mode != "chatgpt":
steps.extend(
[
'if [ -z "${OPENAI_API_KEY:-}" ]; then echo "OPENAI_API_KEY missing in runner env" >&2; exit 8; fi',
]
)
steps.extend(
[
'if [ -z "${GITEA_TOKEN:-}" ]; then echo "GITEA_TOKEN missing in runner env" >&2; exit 8; fi',
'if [ -z "${GITEA_GIT_USERNAME:-}" ]; then echo "GITEA_GIT_USERNAME missing in runner env" >&2; exit 8; fi',
]
)
if settings.codex_auth_mode == "chatgpt":
steps.extend(
[
@@ -94,26 +165,90 @@ def _build_install_and_run_command(settings: Settings, *, include_reasoning_effo
)
steps.extend(
[
"apt-get update >/tmp/apt-update.log 2>&1 && apt-get install -y --no-install-recommends ca-certificates >/tmp/apt-install.log 2>&1 || { rc=$?; echo 'ca-certificates install failed'; tail -n 80 /tmp/apt-update.log || true; tail -n 80 /tmp/apt-install.log || true; exit $rc; }",
"npm install -g @openai/codex >/tmp/codex-install.log 2>&1 || { rc=$?; echo 'codex install failed'; tail -n 200 /tmp/codex-install.log || true; exit $rc; }",
"apt-get update >/tmp/apt-update.log 2>&1 && apt-get install -y --no-install-recommends ca-certificates git >/tmp/apt-install.log 2>&1 || { rc=$?; echo 'ca-certificates/git install failed'; tail -n 80 /tmp/apt-update.log || true; tail -n 80 /tmp/apt-install.log || true; exit $rc; }",
"npm install -g @openai/codex@latest >/tmp/codex-install.log 2>&1 || { rc=$?; echo 'codex install failed'; tail -n 200 /tmp/codex-install.log || true; exit $rc; }",
"codex --version >/tmp/codex-version.log 2>&1 || { rc=$?; echo 'codex version check failed'; tail -n 40 /tmp/codex-version.log || true; exit $rc; }",
]
)
schema_json = json.dumps(REVIEW_RESULT_SCHEMA, separators=(",", ":"))
steps.extend(
[
f"cat > {REVIEW_SCHEMA_FILE} <<'JSON'\n{schema_json}\nJSON",
'auth_b64="$(printf "%s" "${GITEA_GIT_USERNAME}:${GITEA_TOKEN}" | base64 | tr -d \'\\n\')"',
f'git -c http.extraHeader="Authorization: Basic $auth_b64" clone --no-tags --depth 80 {shlex.quote(pr.clone_url)} /work/repo',
"cd /work/repo",
"fetch_required() { "
"remote=\"$1\"; ref=\"$2\"; sha=\"$3\"; label=\"$4\"; "
"if git -c http.extraHeader=\"Authorization: Basic $auth_b64\" fetch --no-tags \"$remote\" \"$ref\"; then return 0; fi; "
"if git -c http.extraHeader=\"Authorization: Basic $auth_b64\" fetch --no-tags \"$remote\" \"$sha\"; then return 0; fi; "
"echo \"Failed to fetch $label from remote '$remote' using ref '$ref' or sha '$sha'\" >&2; "
"return 7; "
"}",
f"base_remote={'upstream' if pr.base_clone_url and pr.base_clone_url != pr.clone_url else 'origin'}",
f"if [ \"$base_remote\" = \"upstream\" ]; then git remote add upstream {shlex.quote(pr.base_clone_url or '')}; fi",
f"fetch_required origin {shlex.quote(pr.head_ref)} {shlex.quote(pr.head_sha)} head",
f"fetch_required \"$base_remote\" {shlex.quote(pr.base_ref)} {shlex.quote(pr.base_sha)} base",
f"git checkout --detach {shlex.quote(pr.head_sha)}",
'resolved_head="$(git rev-parse HEAD)"',
f'if [ "$resolved_head" != {shlex.quote(pr.head_sha)} ]; then echo "Checked out SHA mismatch: expected {pr.head_sha}, got $resolved_head" >&2; exit 9; fi',
"unset GITEA_TOKEN auth_b64",
"git config --global --unset-all http.extraHeader >/dev/null 2>&1 || true",
]
)
model = settings.openai_review_model.strip()
reasoning_effort = settings.openai_reasoning_effort.strip()
codex_exec_parts = ["codex exec --skip-git-repo-check --json"]
codex_exec_parts = [
"codex exec",
"--json",
"--output-schema",
shlex.quote(REVIEW_SCHEMA_FILE),
"-o",
shlex.quote(REVIEW_OUTPUT_FILE),
]
if model:
codex_exec_parts.append(f"-m {shlex.quote(model)}")
if include_reasoning_effort and reasoning_effort:
codex_exec_parts.append(f"--reasoning-effort {shlex.quote(reasoning_effort)}")
steps.append(" ".join(codex_exec_parts))
return "; ".join(steps)
codex_exec_parts.append(shlex.quote(review_prompt))
steps.extend(
[
" ".join(codex_exec_parts),
f'echo "{result_start_marker}"',
f"cat {shlex.quote(REVIEW_OUTPUT_FILE)}",
f'echo "{result_end_marker}"',
]
)
return "\n".join(steps)
def _needs_reasoning_effort_compat_retry(completed: subprocess.CompletedProcess[str]) -> bool:
if completed.returncode == 0:
return False
stderr_text = completed.stderr or ""
return "unexpected argument '--reasoning-effort' found" in stderr_text
def _apply_repo_default_review_mode(command: ParsedCommand, repo_cfg: RepoReviewConfig) -> None:
if command.name != "review" or command.mode_explicit:
return
configured_mode = repo_cfg.default_mode
command.mode = configured_mode if configured_mode in {"summary", "security", "performance", "tests", "full"} else "summary"
def _build_exec_review_prompt(command: ParsedCommand, repo_cfg: RepoReviewConfig, pr: PullRequestContext) -> str:
raw = (command.raw or "").strip()
remainder = raw
match = re.match(r"^@[^\s]+\s+\S+\s*(.*)$", raw, flags=re.IGNORECASE | re.DOTALL)
if match:
remainder = match.group(1).strip()
intent = remainder or "review this pull request and report introduced issues."
focus = ", ".join(repo_cfg.focus) if repo_cfg.focus else "correctness, security, maintainability"
ignore = ", ".join(repo_cfg.ignore) if repo_cfg.ignore else "(none)"
mode = command.mode if command.name in {"review", "rerun"} else "summary"
return "\n".join(
[
f"review: {intent}",
"Review only issues introduced by this PR.",
f"Compare exactly these commits: base `{pr.base_sha}` ... head `{pr.head_sha}`.",
"Use local git data from this checkout; do not review unrelated history.",
f"Requested mode: {mode}.",
f"Focus areas: {focus}.",
f"Ignore patterns: {ignore}.",
f"Include tests setting: {repo_cfg.include_tests}.",
f"Full review requested: {command.full}.",
"Return strict JSON matching the provided output schema.",
]
)
def _build_docker_command(settings: Settings, *, container_name: str, install_and_run: str) -> list[str]:
@@ -147,6 +282,14 @@ def _build_docker_command(settings: Settings, *, container_name: str, install_an
"OPENAI_PROJECT_ID",
]
)
cmd.extend(
[
"-e",
"GITEA_TOKEN",
"-e",
"GITEA_GIT_USERNAME",
]
)
cmd.extend([settings.review_runner_image, "bash", "-lc", install_and_run])
return cmd
@@ -157,7 +300,7 @@ def _ephemeral_runner_failure_result(exc: Exception, auth_mode: str) -> dict[str
summary = f"{mode_label} runner failed before review execution. Error: {message}"
return {
"verdict": "has_issues",
"confidence": 0.6,
"confidence": 0.67,
"summary": summary,
"findings": [
{
@@ -215,27 +358,43 @@ def ensure_workdir(path: str) -> Path:
return target
def _parse_codex_exec_stdout(stdout: str) -> dict[str, Any]:
last_text: str | None = None
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
def _load_repo_review_config_from_gitea(gitea: GiteaClient, repo: str, head_sha: str) -> RepoReviewConfig:
content = gitea.get_file_content(repo, ".codex-review.yml", ref=head_sha)
if content is None:
return RepoReviewConfig(configured=False)
return parse_repo_review_config_text(content, configured=True)
def _parse_review_result_from_stdout_artifact(
stdout: str,
*,
result_start_marker: str,
result_end_marker: str,
) -> dict[str, Any]:
lines = stdout.splitlines()
start_idx = -1
end_idx = -1
for idx, line in enumerate(lines):
if line.strip() == result_start_marker:
start_idx = idx
break
if start_idx != -1:
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip() == result_end_marker:
end_idx = idx
break
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
raise RuntimeError("Runner output did not include final review artifact markers.")
artifact = "\n".join(lines[start_idx + 1 : end_idx]).strip()
if not artifact:
raise RuntimeError("Runner output contained empty final review artifact.")
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(payload, dict) and {"verdict", "summary", "findings"}.issubset(payload.keys()):
payload = json.loads(artifact)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Final review artifact was not valid JSON: {exc}") from exc
if not isinstance(payload, dict):
raise RuntimeError(f"Final review artifact JSON must be an object, got {type(payload)!r}.")
return payload
extracted = _extract_text(payload)
if extracted:
last_text = extracted
parsed = _parse_review_json_from_text(extracted)
if parsed:
return parsed
if not last_text:
raise RuntimeError("codex exec output did not include parseable review payload text")
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]:
@@ -297,49 +456,3 @@ def _find_first_dict_for_key(payload: Any, key: str) -> dict[str, Any] | None:
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)
if fenced:
candidates.append(fenced.group(1).strip())
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
candidates.append(text[start : end + 1].strip())
seen: set[str] = set()
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
continue
if isinstance(payload, dict) and {"verdict", "summary", "findings"}.issubset(payload.keys()):
return payload
return None
def _extract_text(payload: Any) -> str | None:
if isinstance(payload, str):
return payload
if isinstance(payload, dict):
for key in ("text", "message", "content", "output"):
value = payload.get(key)
text = _extract_text(value)
if text:
return text
for value in payload.values():
if not isinstance(value, (dict, list)):
continue
text = _extract_text(value)
if text:
return text
if isinstance(payload, list):
for item in payload:
text = _extract_text(item)
if text:
return text
return None

View File

@@ -4,17 +4,16 @@ import asyncio
import logging
from typing import Any
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from gitea_codex_bot.config import Settings
from gitea_codex_bot.db import get_session_factory
from gitea_codex_bot.models import ReviewJob
from gitea_codex_bot.models import JobStatus, ReviewJob
from gitea_codex_bot.services.comments import 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_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
@@ -24,7 +23,7 @@ logger = logging.getLogger(__name__)
def _command_from_job(job: ReviewJob) -> ParsedCommand:
args = job.command_args.split() if job.command_args else []
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)
return ParsedCommand(name=job.command, raw=raw, arguments=args, full="--full" in args)
def _handle_non_review_command(
@@ -34,6 +33,13 @@ def _handle_non_review_command(
job: ReviewJob,
command: ParsedCommand,
) -> tuple[bool, bool, dict[str, Any] | None, str | None]:
if command.name == "help":
try:
message = _build_help_comment(settings, session, gitea, job)
gitea.post_issue_comment(job.repo, job.pr_number, message)
return True, True, {"summary": "Help/status summary posted."}, None
except Exception as exc:
return True, False, None, f"Failed to post help summary: {exc}"
if command.name == "ignore":
return True, True, {"summary": "Ignore command acknowledged. No review run executed."}, None
if command.name == "explain":
@@ -54,26 +60,96 @@ def _handle_non_review_command(
message = "## Codex Explain\n\nNo previous result found for this command."
gitea.post_issue_comment(job.repo, job.pr_number, message)
return True, True, {"summary": message}, None
if command.name == "fix":
if not settings.enable_fix_commands:
message = "⚠️ `@codex fix` is disabled on this bot instance."
if str(command.name).lower() == "fix":
message = "⚠️ `@codex fix` is no longer supported on this bot."
gitea.post_issue_comment(job.repo, job.pr_number, message)
return True, True, {"summary": message}, None
note = create_fix_patch_note(command)
if command.branch_fix:
try:
pr = gitea.get_pull_request(job.repo, job.pr_number)
branch = create_fix_branch(pr, note=note, arguments=command.arguments)
message = f"## Codex Fix\n\n{note}\n\nCreated branch `{branch}`."
gitea.post_issue_comment(job.repo, job.pr_number, message)
return True, True, {"summary": note, "mode": "branch", "branch": branch}, None
except Exception as exc:
return True, False, None, f"Failed to create fix branch: {exc}"
gitea.post_issue_comment(job.repo, job.pr_number, f"## Codex Fix\n\n{note}\n\nPatch suggestion mode.")
return True, True, {"summary": note, "mode": "patch"}, None
return False, False, None, None
def _build_help_comment(settings: Settings, session: Session, gitea: GiteaClient, job: ReviewJob) -> str:
comments = gitea.list_issue_comments(job.repo, job.pr_number)
comment_summaries = _summarize_comments(comments, settings.gitea_bot_username)
latest_review = session.execute(
select(ReviewJob)
.where(
ReviewJob.repo == job.repo,
ReviewJob.pr_number == job.pr_number,
ReviewJob.command.in_(["review", "rerun"]),
)
.order_by(ReviewJob.id.desc())
.limit(1)
).scalar_one_or_none()
pending_count = session.execute(
select(func.count(ReviewJob.id)).where(
ReviewJob.repo == job.repo,
ReviewJob.pr_number == job.pr_number,
ReviewJob.status.in_([JobStatus.queued, JobStatus.running]),
)
).scalar_one()
latest_status_line = "No previous review run."
if latest_review is not None:
latest_status = latest_review.status.value if hasattr(latest_review.status, "value") else str(latest_review.status)
latest_summary = ""
if isinstance(latest_review.result_json, dict):
summary_raw = latest_review.result_json.get("summary")
if isinstance(summary_raw, str):
latest_summary = " ".join(summary_raw.split())
latest_status_line = f"Latest review command: `{latest_review.command}` status `{latest_status}`."
if latest_summary:
latest_status_line = f"{latest_status_line} Summary: {latest_summary[:180]}"
lines = [
"## Codex Help",
"",
"Supported commands:",
"- `@codex review [security|performance|tests] [--full]`",
"- `@codex rerun`",
"- `@codex explain`",
"- `@codex ignore`",
"- `@codex -h` / `@codex --help` / `@codex help`",
"",
"Status note:",
f"- Pending jobs on this PR: `{pending_count}`",
f"- {latest_status_line}",
"",
f"Discussion summary ({comment_summaries['total']} comments, human `{comment_summaries['human']}`, bot `{comment_summaries['bot']}`):",
]
if comment_summaries["items"]:
lines.extend(comment_summaries["items"])
else:
lines.append("- No comments available to summarize.")
return "\n".join(lines).strip()
def _summarize_comments(comments: list[dict[str, Any]], bot_username: str) -> dict[str, Any]:
normalized_bot = (bot_username or "").strip().lower()
bot_count = 0
summarized: list[str] = []
recent = comments[-8:] if comments else []
for row in comments:
user = row.get("user")
username = ""
if isinstance(user, dict):
username = str(user.get("username") or user.get("login") or "").strip().lower()
if username and username == normalized_bot:
bot_count += 1
for row in recent:
body_raw = str(row.get("body") or "").strip()
if not body_raw:
continue
one_line = " ".join(body_raw.split())
preview = one_line if len(one_line) <= 180 else f"{one_line[:180]}..."
user = row.get("user")
username = "unknown"
if isinstance(user, dict):
username = str(user.get("username") or user.get("login") or "unknown").strip() or "unknown"
summarized.append(f"- @{username}: {preview}")
total = len(comments)
human_count = max(total - bot_count, 0)
return {"total": total, "human": human_count, "bot": bot_count, "items": summarized}
def _post_review_failure_comment(gitea: GiteaClient, job: ReviewJob, error_message: str) -> None:
message = (
"⚠️ Codex review run failed after queueing.\n\n"

View File

@@ -4,9 +4,8 @@ import json
import sys
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.services.gitea import GiteaClient
from gitea_codex_bot.services.reviewer import run_review_for_pr
from gitea_codex_bot.types import ParsedCommand
from gitea_codex_bot.workers.container_runner import run_review_ephemeral
def main() -> int:
@@ -18,11 +17,14 @@ def main() -> int:
raw=f"@codex {command_payload['name']}",
mode=command_payload.get("mode", "summary"),
full=bool(command_payload.get("full", False)),
branch_fix=bool(command_payload.get("branch_fix", False)),
arguments=list(command_payload.get("arguments", [])),
)
gitea = GiteaClient(settings)
result, _repo_cfg = run_review_for_pr(settings, gitea, payload["repo"], int(payload["pr_number"]), command)
result, _repo_cfg = run_review_ephemeral(
settings,
repo=payload["repo"],
pr_number=int(payload["pr_number"]),
command=command,
)
print(json.dumps(result))
return 0

View File

@@ -1,4 +1,4 @@
from gitea_codex_bot.services.commands import parse_command
from gitea_codex_bot.services.commands import detect_prefixed_command, parse_command
def test_parse_review_command_modes() -> None:
@@ -17,11 +17,8 @@ def test_parse_review_command_defaults_to_non_explicit_summary_mode() -> None:
assert cmd.mode_explicit is False
def test_parse_fix_branch() -> None:
cmd = parse_command("@codex fix --branch finding 2")
assert cmd is not None
assert cmd.name == "fix"
assert cmd.branch_fix is True
def test_parse_fix_command_returns_none() -> None:
assert parse_command("@codex fix --branch finding 2") is None
def test_invalid_command_returns_none() -> None:
@@ -39,3 +36,24 @@ def test_parse_review_command_for_custom_alias() -> None:
assert cmd is not None
assert cmd.name == "review"
assert cmd.mode == "tests"
def test_parse_help_short_flag() -> None:
cmd = parse_command("@codex -h")
assert cmd is not None
assert cmd.name == "help"
def test_parse_help_long_flag_and_arguments() -> None:
cmd = parse_command("@codex --help status quick", aliases={"codex"})
assert cmd is not None
assert cmd.name == "help"
assert cmd.arguments == ["status", "quick"]
def test_detect_prefixed_command_for_unsupported_name() -> None:
assert detect_prefixed_command("@codex shipit now", aliases={"codex"}) == "shipit"
def test_detect_prefixed_command_returns_none_for_non_alias() -> None:
assert detect_prefixed_command("@someone review", aliases={"codex"}) is None

View File

@@ -5,18 +5,56 @@ from pathlib import Path
import pytest
from gitea_codex_bot.config import get_settings
from gitea_codex_bot.services.gitea import PullRequestContext
from gitea_codex_bot.services.repo_config import RepoReviewConfig
from gitea_codex_bot.types import ParsedCommand
from gitea_codex_bot.workers.container_runner import (
CONTAINER_CODEX_HOME,
RESULT_END_MARKER,
RESULT_START_MARKER,
_apply_repo_default_review_mode,
_build_docker_command,
_build_exec_review_prompt,
_build_install_and_run_command,
_extract_result_meta_from_codex_stdout,
_load_codex_auth_json_b64,
_parse_codex_exec_stdout,
_load_repo_review_config_from_gitea,
_parse_review_result_from_stdout_artifact,
_resolve_codex_auth_json_path,
run_review_ephemeral,
)
def _sample_pr() -> PullRequestContext:
return PullRequestContext(
repo="acme/repo",
pr_number=1,
base_ref="main",
base_sha="b" * 40,
head_ref="feature",
head_sha="a" * 40,
clone_url="https://gitea.test/acme/repo.git",
html_url="https://gitea.test/acme/repo/pulls/1",
is_fork=False,
)
def _sample_fork_pr() -> PullRequestContext:
return PullRequestContext(
repo="acme/repo",
pr_number=2,
base_ref="main",
base_sha="c" * 40,
head_ref="feature",
head_sha="d" * 40,
clone_url="https://gitea.test/fork/repo.git",
base_clone_url="https://gitea.test/acme/repo.git",
head_clone_url="https://gitea.test/fork/repo.git",
html_url="https://gitea.test/acme/repo/pulls/2",
is_fork=True,
)
def test_build_docker_command_api_key_mode_uses_openai_env() -> None:
settings = get_settings()
@@ -25,6 +63,8 @@ def test_build_docker_command_api_key_mode_uses_openai_env() -> None:
assert "OPENAI_API_KEY" in cmd
assert "OPENAI_ORG_ID" in cmd
assert "OPENAI_PROJECT_ID" in cmd
assert "GITEA_TOKEN" in cmd
assert "GITEA_GIT_USERNAME" in cmd
assert "--mount" not in cmd
@@ -45,41 +85,81 @@ def test_build_docker_command_chatgpt_mode_mounts_auth_json(
assert "OPENAI_API_KEY" not in cmd
assert f"CODEX_HOME={CONTAINER_CODEX_HOME}" in env_items
assert "CODEX_AUTH_JSON_B64" in env_items
assert "GITEA_TOKEN" in env_items
assert "GITEA_GIT_USERNAME" in env_items
def test_build_install_command_chatgpt_mode_copies_auth_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
def test_build_install_command_chatgpt_mode_sets_git_checkout_and_review(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
auth_file = tmp_path / "auth.json"
auth_file.write_text("{}", encoding="utf-8")
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
monkeypatch.setenv("CODEX_AUTH_JSON_PATH", str(auth_file))
get_settings.cache_clear()
settings = get_settings()
pr = _sample_pr()
command = _build_install_and_run_command(settings)
command = _build_install_and_run_command(
settings,
pr=pr,
review_prompt="review: security --full",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
assert 'printf "%s" "$CODEX_AUTH_JSON_B64" | base64 -d > /root/.codex/auth.json' in command
assert "codex exec --skip-git-repo-check --json -m gpt-5.3-codex" in command
assert f"--reasoning-effort {settings.openai_reasoning_effort}" in command
assert "git -c http.extraHeader=" in command
assert f"clone --no-tags --depth 80 {pr.clone_url} /work/repo" in command
assert "fetch_required() {" in command
assert f"fetch_required origin {pr.head_ref} {pr.head_sha} head" in command
assert f"fetch_required \"$base_remote\" {pr.base_ref} {pr.base_sha} base" in command
assert "base_remote=origin" in command
assert f"git checkout --detach {pr.head_sha}" in command
assert "resolved_head=\"$(git rev-parse HEAD)\"" in command
assert "unset GITEA_TOKEN auth_b64" in command
assert "codex exec --json --output-schema /tmp/codex-review-schema.json -o /tmp/codex-review-result.json" in command
assert "review: security --full" in command
assert "--output-schema /tmp/codex-review-schema.json" in command
assert "-o /tmp/codex-review-result.json" in command
assert "npm install -g @openai/codex@latest" in command
assert "codex --version >/tmp/codex-version.log" in command
assert " - " not in command
assert f'echo "{RESULT_START_MARKER}_x"' in command
assert f'echo "{RESULT_END_MARKER}_x"' in command
def test_build_install_command_includes_configured_reasoning_effort(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OPENAI_REASONING_EFFORT", "medium")
get_settings.cache_clear()
def test_build_install_command_does_not_include_reasoning_effort_flag() -> None:
settings = get_settings()
pr = _sample_pr()
command = _build_install_and_run_command(settings)
assert "--reasoning-effort medium" in command
def test_build_install_command_can_disable_reasoning_effort_flag() -> None:
settings = get_settings()
command = _build_install_and_run_command(settings, include_reasoning_effort=False)
command = _build_install_and_run_command(
settings,
pr=pr,
review_prompt="review: tests",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
assert "--reasoning-effort" not in command
def test_build_install_command_uses_upstream_remote_for_fork_pr_base_fetch() -> None:
settings = get_settings()
pr = _sample_fork_pr()
command = _build_install_and_run_command(
settings,
pr=pr,
review_prompt="review: tests",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
assert "base_remote=upstream" in command
assert f"git remote add upstream {pr.base_clone_url}" in command
assert f"fetch_required origin {pr.head_ref} {pr.head_sha} head" in command
assert f"fetch_required \"$base_remote\" {pr.base_ref} {pr.base_sha} base" in command
def test_chatgpt_mode_requires_existing_auth_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
missing = tmp_path / "missing-auth.json"
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
@@ -104,6 +184,29 @@ def test_load_codex_auth_json_b64_roundtrip(monkeypatch: pytest.MonkeyPatch, tmp
assert encoded
def test_load_repo_review_config_from_gitea_when_missing() -> None:
class _Gitea:
def get_file_content(self, *_args, **_kwargs):
return None
cfg = _load_repo_review_config_from_gitea(_Gitea(), "acme/repo", "a" * 40)
assert cfg.configured is False
assert cfg.enabled is True
def test_load_repo_review_config_from_gitea_when_present() -> None:
class _Gitea:
def get_file_content(self, *_args, **_kwargs):
return "enabled: false\nreview:\n default_mode: tests\n"
cfg = _load_repo_review_config_from_gitea(_Gitea(), "acme/repo", "a" * 40)
assert cfg.configured is True
assert cfg.enabled is False
assert cfg.default_mode == "tests"
def test_run_review_ephemeral_chatgpt_does_not_fallback_to_api_key_path(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -115,18 +218,22 @@ def test_run_review_ephemeral_chatgpt_does_not_fallback_to_api_key_path(
get_settings.cache_clear()
settings = get_settings()
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.prepare_review_prompt",
lambda *_args, **_kwargs: ("prompt", {"diff": ""}, object()),
)
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", lambda _settings: object())
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, *_args, **_kwargs):
return _sample_pr()
def get_file_content(self, *_args, **_kwargs):
return None
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", _FakeGiteaClient)
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.subprocess.run",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("docker unavailable")),
)
from gitea_codex_bot.types import ParsedCommand
result, _repo_cfg = run_review_ephemeral(
settings,
repo="acme/repo",
@@ -142,18 +249,22 @@ def test_run_review_ephemeral_api_key_mode_does_not_fallback_to_host(monkeypatch
get_settings.cache_clear()
settings = get_settings()
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.prepare_review_prompt",
lambda *_args, **_kwargs: ("prompt", {"diff": ""}, object()),
)
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", lambda _settings: object())
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, *_args, **_kwargs):
return _sample_pr()
def get_file_content(self, *_args, **_kwargs):
return None
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", _FakeGiteaClient)
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.subprocess.run",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("docker unavailable")),
)
from gitea_codex_bot.types import ParsedCommand
result, _repo_cfg = run_review_ephemeral(
settings,
repo="acme/repo",
@@ -165,44 +276,46 @@ def test_run_review_ephemeral_api_key_mode_does_not_fallback_to_host(monkeypatch
assert "API-key auth runner failed" in result["summary"]
def test_run_review_ephemeral_retries_without_reasoning_effort_when_unsupported(monkeypatch: pytest.MonkeyPatch) -> None:
def test_run_review_ephemeral_single_attempt_success(monkeypatch: pytest.MonkeyPatch) -> None:
get_settings.cache_clear()
settings = get_settings()
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.prepare_review_prompt",
lambda *_args, **_kwargs: ("prompt", {"diff": ""}, object()),
)
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", lambda _settings: object())
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def get_pull_request(self, *_args, **_kwargs):
return _sample_pr()
def get_file_content(self, *_args, **_kwargs):
return None
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.GiteaClient", _FakeGiteaClient)
monkeypatch.setattr(
"gitea_codex_bot.workers.container_runner.uuid.uuid4",
lambda: type("U", (), {"hex": "abc123def4567890abc123def4567890"})(),
)
calls: list[list[str]] = []
def _fake_run(cmd, *args, **kwargs):
calls.append(cmd)
if len(calls) == 1:
return type(
"Completed",
(),
{
"returncode": 2,
"stdout": "",
"stderr": "error: unexpected argument '--reasoning-effort' found",
},
)()
return type(
"Completed",
(),
{
"returncode": 0,
"stdout": '{"verdict":"correct","confidence":0.9,"summary":"ok","findings":[]}\n',
"stdout": (
'{"type":"response.started","model":"gpt-5.3-codex"}\n'
f"{RESULT_START_MARKER}_abc123def4567890abc123def4567890\n"
'{"verdict":"correct","confidence":0.9,"summary":"ok","findings":[],"markdown_comment":"ok"}\n'
f"{RESULT_END_MARKER}_abc123def4567890abc123def4567890\n"
),
"stderr": "",
},
)()
monkeypatch.setattr("gitea_codex_bot.workers.container_runner.subprocess.run", _fake_run)
from gitea_codex_bot.types import ParsedCommand
result, _repo_cfg = run_review_ephemeral(
settings,
repo="acme/repo",
@@ -211,40 +324,75 @@ def test_run_review_ephemeral_retries_without_reasoning_effort_when_unsupported(
)
assert result["verdict"] == "correct"
assert len(calls) == 2
assert len(calls) == 1
first_shell = calls[0][-1]
second_shell = calls[1][-1]
assert "--reasoning-effort" in first_shell
assert "--reasoning-effort" not in second_shell
assert "--reasoning-effort" not in first_shell
def test_parse_codex_exec_stdout_from_stream_item_text_json() -> None:
stdout = '\n'.join(
[
'{"type":"thread.started","thread_id":"abc"}',
'{"type":"item.completed","item":{"type":"agent_message","text":"{\\"verdict\\":\\"correct\\",\\"confidence\\":0.9,\\"summary\\":\\"ok\\",\\"findings\\":[]}"}}',
]
def test_parse_review_result_from_stdout_artifact() -> None:
stdout = (
"noise\n"
f"{RESULT_START_MARKER}_test\n"
'{"verdict":"correct","confidence":0.9,"summary":"ok","findings":[],"markdown_comment":"ok"}\n'
f"{RESULT_END_MARKER}_test\n"
)
parsed = _parse_review_result_from_stdout_artifact(
stdout,
result_start_marker=f"{RESULT_START_MARKER}_test",
result_end_marker=f"{RESULT_END_MARKER}_test",
)
parsed = _parse_codex_exec_stdout(stdout)
assert parsed["verdict"] == "correct"
assert parsed["summary"] == "ok"
def test_parse_codex_exec_stdout_from_fenced_json_text() -> None:
stdout = '\n'.join(
[
'{"type":"thread.started","thread_id":"abc"}',
'{"type":"item.completed","item":{"type":"agent_message","text":"Here is the result:\\n```json\\n{\\"verdict\\":\\"has_issues\\",\\"confidence\\":0.8,\\"summary\\":\\"x\\",\\"findings\\":[]}\\n```"}}',
]
def test_parse_review_result_from_stdout_artifact_fails_without_markers() -> None:
with pytest.raises(RuntimeError):
_parse_review_result_from_stdout_artifact(
"no markers here",
result_start_marker=f"{RESULT_START_MARKER}_x",
result_end_marker=f"{RESULT_END_MARKER}_x",
)
parsed = _parse_codex_exec_stdout(stdout)
assert parsed["verdict"] == "has_issues"
assert parsed["summary"] == "x"
def test_build_exec_review_prompt_strips_mention_and_command() -> None:
prompt = _build_exec_review_prompt(
ParsedCommand(name="review", raw="@codex review security --full\nfocus session handling"),
RepoReviewConfig(),
_sample_pr(),
)
assert prompt.startswith("review: security --full\nfocus session handling")
assert "Compare exactly these commits:" in prompt
def test_build_exec_review_prompt_falls_back_when_no_extra_text() -> None:
prompt = _build_exec_review_prompt(ParsedCommand(name="rerun", raw="@codex rerun"), RepoReviewConfig(), _sample_pr())
assert prompt.startswith("review: review this pull request and report introduced issues.")
def test_apply_repo_default_review_mode_for_review_command() -> None:
command = ParsedCommand(name="review", raw="@codex review")
cfg = RepoReviewConfig(default_mode="tests")
_apply_repo_default_review_mode(command, cfg)
assert command.mode == "tests"
def test_parse_review_result_from_stdout_artifact_uses_end_marker_after_start() -> None:
stdout = (
f"{RESULT_START_MARKER}_a\n"
'{"verdict":"correct","confidence":0.9,"summary":"contains marker text __CODEX_REVIEW_RESULT_END___a","findings":[],"markdown_comment":"ok"}\n'
f"{RESULT_END_MARKER}_a\n"
)
parsed = _parse_review_result_from_stdout_artifact(
stdout,
result_start_marker=f"{RESULT_START_MARKER}_a",
result_end_marker=f"{RESULT_END_MARKER}_a",
)
assert parsed["verdict"] == "correct"
def test_extract_result_meta_from_codex_stdout_collects_model_and_usage() -> None:
settings = get_settings()
stdout = '\n'.join(
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}}}',

View File

@@ -146,3 +146,44 @@ def test_process_one_job_skips_review_when_repo_config_disabled(monkeypatch) ->
with session_factory() as session:
stored_job = session.execute(select(ReviewJob).where(ReviewJob.id == job.id)).scalar_one()
assert stored_job.status.value == "skipped"
def test_process_one_job_help_command_posts_summary(monkeypatch) -> None:
posted_comments: list[str] = []
session_factory = get_session_factory()
with session_factory() as session:
enqueue_job(
session,
repo="acme/repo",
pr_number=12,
head_sha="abc12345",
trigger_comment_id=114,
trigger_comment_body="@codex -h",
requested_by="alice",
command=ParsedCommand(name="help", raw="@codex -h"),
)
class _FakeGiteaClient:
def __init__(self, _settings) -> None:
pass
def list_issue_comments(self, _repo: str, _pr_number: int):
return [
{"body": "Please check auth edge cases", "user": {"username": "alice"}},
{"body": "On it, running review now.", "user": {"username": "codex-bot"}},
]
def post_issue_comment(self, _repo: str, _pr_number: int, body: str) -> int:
posted_comments.append(body)
return 903
monkeypatch.setattr("gitea_codex_bot.workers.dispatcher.GiteaClient", _FakeGiteaClient)
assert process_one_job(get_settings()) is True
assert posted_comments
body = posted_comments[0]
assert "## Codex Help" in body
assert "@codex -h" in body
assert "@codex fix" not in body
assert "Discussion summary" in body
assert "@alice: Please check auth edge cases" in body

View File

@@ -68,10 +68,10 @@ def test_format_result_comment_appends_missing_config_note_for_system_layout() -
},
repo_configured=False,
)
assert body.endswith(".codex-review.yml is not configured")
assert body.endswith("> .codex-review.yml is not configured")
def test_format_result_comment_does_not_append_missing_config_note_to_agent_markdown() -> None:
def test_format_result_comment_appends_missing_config_note_to_agent_markdown() -> None:
body = format_result_comment(
"ff0011",
{
@@ -79,4 +79,4 @@ def test_format_result_comment_does_not_append_missing_config_note_to_agent_mark
},
repo_configured=False,
)
assert ".codex-review.yml is not configured" not in body
assert body.endswith("> .codex-review.yml is not configured")

View File

@@ -1,87 +0,0 @@
from __future__ import annotations
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, prepare_review_prompt, run_review_for_pr
from gitea_codex_bot.types import ParsedCommand
def test_fallback_review_surfaces_failure_reason() -> None:
result = _fallback_review({"diff": ""}, failure_reason="OpenAI API HTTP 401: invalid_api_key")
assert result["verdict"] == "has_issues"
assert result["summary"] == "OpenAI review failed. Error: OpenAI API HTTP 401: invalid_api_key"
assert result["findings"][0]["title"] == "OpenAI review request failed"
assert result["findings"][0]["body"] == "OpenAI API HTTP 401: invalid_api_key"
def test_run_review_for_pr_uses_openai_http_error_in_fallback(monkeypatch) -> None:
def _fake_prepare(*_args, **_kwargs):
return "prompt", {"diff": "TODO: tighten validation"}, RepoReviewConfig()
def _raise_http_error(*_args, **_kwargs):
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(429, request=request, text='{"error":{"message":"rate_limited"}}')
raise httpx.HTTPStatusError("rate limited", request=request, response=response)
monkeypatch.setattr("gitea_codex_bot.services.reviewer.prepare_review_prompt", _fake_prepare)
monkeypatch.setattr("gitea_codex_bot.services.reviewer._call_openai_review", _raise_http_error)
settings = get_settings()
command = ParsedCommand(name="review", raw="@codex review")
result, _repo_cfg = run_review_for_pr(settings, object(), "acme/repo", 9, command)
assert result["summary"].startswith("OpenAI review failed. Error: OpenAI API HTTP 429:")
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
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

View File

@@ -225,6 +225,95 @@ def test_webhook_logs_when_codex_command_is_not_review(monkeypatch) -> None:
assert any("Webhook without @codex review command" in item for item in messages)
def test_webhook_accepts_help_short_flag_and_queues(monkeypatch) -> None:
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
)
client = TestClient(app)
payload_obj = _payload("@codex -h", username="alice", comment_id=333)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-help-1",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 333)).scalar_one()
assert queued.command == "help"
def test_webhook_replies_fix_is_no_longer_supported(monkeypatch) -> None:
posted_comments: list[str] = []
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 fix --branch", username="alice", comment_id=444)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-fix-unsupported",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "unsupported command"
assert response.json()["command"] == "fix"
assert any("no longer supported" in body for body in posted_comments)
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 444)).scalar_one_or_none()
assert queued is None
def test_webhook_replies_for_unknown_prefixed_command(monkeypatch) -> None:
posted_comments: list[str] = []
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 deploy", username="alice", comment_id=445)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-unknown-unsupported",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["reason"] == "unsupported command"
assert response.json()["command"] == "deploy"
assert any("not supported" in body for body in posted_comments)
def test_webhook_logs_when_repo_not_allowed(monkeypatch) -> None:
messages: list[str] = []