diff --git a/.env.example b/.env.example index fb27d06..0520fa1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 75fd79f..57fd31d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -176,4 +173,4 @@ Treat these as high-sensitivity areas when modifying worker/runner paths. If you are confident that your changes are ready to be committed, please follow the commit message format below: ```[type]. Short description (max 50 chars)``` -Push after commiting. Ask the user once if you have permission to commit and from then on commit without asking. \ No newline at end of file +Push after commiting. Ask the user once if you have permission to commit and from then on commit without asking. diff --git a/TODO.md b/TODO.md index 3944a14..f32156b 100644 --- a/TODO.md +++ b/TODO.md @@ -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) diff --git a/src/gitea_codex_bot/config.py b/src/gitea_codex_bot/config.py index 3f4fd5a..d93d4b7 100644 --- a/src/gitea_codex_bot/config.py +++ b/src/gitea_codex_bot/config.py @@ -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") diff --git a/src/gitea_codex_bot/main.py b/src/gitea_codex_bot/main.py index befc36a..26ad394 100644 --- a/src/gitea_codex_bot/main.py +++ b/src/gitea_codex_bot/main.py @@ -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, diff --git a/src/gitea_codex_bot/services/commands.py b/src/gitea_codex_bot/services/commands.py index 02d2b9d..2975f8b 100644 --- a/src/gitea_codex_bot/services/commands.py +++ b/src/gitea_codex_bot/services/commands.py @@ -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 diff --git a/src/gitea_codex_bot/services/gitea.py b/src/gitea_codex_bot/services/gitea.py index b44bd24..7323e79 100644 --- a/src/gitea_codex_bot/services/gitea.py +++ b/src/gitea_codex_bot/services/gitea.py @@ -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"]), ) diff --git a/src/gitea_codex_bot/services/review_format.py b/src/gitea_codex_bot/services/review_format.py index ffe49ce..b496924 100644 --- a/src/gitea_codex_bot/services/review_format.py +++ b/src/gitea_codex_bot/services/review_format.py @@ -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" diff --git a/src/gitea_codex_bot/services/reviewer.py b/src/gitea_codex_bot/services/reviewer.py index 1687570..22b72cf 100644 --- a/src/gitea_codex_bot/services/reviewer.py +++ b/src/gitea_codex_bot/services/reviewer.py @@ -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 diff --git a/src/gitea_codex_bot/types.py b/src/gitea_codex_bot/types.py index 62a3ca3..fc9990b 100644 --- a/src/gitea_codex_bot/types.py +++ b/src/gitea_codex_bot/types.py @@ -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) diff --git a/src/gitea_codex_bot/workers/container_runner.py b/src/gitea_codex_bot/workers/container_runner.py index 1e520a9..5e2c1e8 100644 --- a/src/gitea_codex_bot/workers/container_runner.py +++ b/src/gitea_codex_bot/workers/container_runner.py @@ -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 - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(payload, dict) and {"verdict", "summary", "findings"}.issubset(payload.keys()): - 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 _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(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 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 diff --git a/src/gitea_codex_bot/workers/dispatcher.py b/src/gitea_codex_bot/workers/dispatcher.py index 899ff9d..2cf1268 100644 --- a/src/gitea_codex_bot/workers/dispatcher.py +++ b/src/gitea_codex_bot/workers/dispatcher.py @@ -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." - 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 + 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 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" diff --git a/src/gitea_codex_bot/workers/runner_entry.py b/src/gitea_codex_bot/workers/runner_entry.py index c380143..46b94b8 100644 --- a/src/gitea_codex_bot/workers/runner_entry.py +++ b/src/gitea_codex_bot/workers/runner_entry.py @@ -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 diff --git a/tests/test_commands.py b/tests/test_commands.py index dc1009b..57290a3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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 diff --git a/tests/test_container_runner.py b/tests/test_container_runner.py index 70ae9d2..8d76bdb 100644 --- a/tests/test_container_runner.py +++ b/tests/test_container_runner.py @@ -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", + ) + + +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(), ) - parsed = _parse_codex_exec_stdout(stdout) - assert parsed["verdict"] == "has_issues" - assert parsed["summary"] == "x" + 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}}}', diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 57d2ffe..e1cd9b8 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -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 diff --git a/tests/test_review_format.py b/tests/test_review_format.py index 6ad96c1..bf98088 100644 --- a/tests/test_review_format.py +++ b/tests/test_review_format.py @@ -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") diff --git a/tests/test_reviewer_fallback.py b/tests/test_reviewer_fallback.py deleted file mode 100644 index c68b0d0..0000000 --- a/tests/test_reviewer_fallback.py +++ /dev/null @@ -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 diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 07a4af0..8577a44 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -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] = []