Files
gitea-codex/src/gitea_codex_bot/workers/container_runner.py
Space-Banane 2482c9911f
All checks were successful
ci / test (push) Successful in 39s
ci / publish (push) Successful in 1m7s
fix. default full review without test execution
2026-05-24 14:33:19 +02:00

501 lines
20 KiB
Python

from __future__ import annotations
import base64
import json
import logging
import os
import re
import shlex
import subprocess
import uuid
from pathlib import Path
from typing import Any
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, 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"
REVIEW_EMITTED_FILE = "/tmp/codex-review-emitted.flag"
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": False,
"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": False,
"required": ["severity", "file", "line_start", "line_end", "title", "body", "suggestion"],
"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", "null"]},
},
},
},
},
}
def run_review_ephemeral(
settings: Settings,
*,
repo: str,
pr_number: int,
command: ParsedCommand,
) -> tuple[dict[str, Any], RepoReviewConfig]:
gitea = GiteaClient(settings)
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]}"
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,
review_prompt=review_prompt,
result_start_marker=result_start_marker,
result_end_marker=result_end_marker,
extra_env=extra_env,
)
if completed.returncode != 0:
raise RuntimeError(_format_runner_failure(completed))
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:
logger.warning("Ephemeral runner failed without host fallback: %s", exc)
return _ephemeral_runner_failure_result(exc, settings.codex_auth_mode), repo_cfg
def _run_ephemeral_container(
settings: Settings,
*,
pr: PullRequestContext,
container_name: str,
review_prompt: str,
result_start_marker: str,
result_end_marker: str,
extra_env: dict[str, str],
) -> subprocess.CompletedProcess[str]:
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,
text=True,
check=False,
capture_output=True,
timeout=settings.max_review_minutes * 60,
env={**os.environ, **extra_env},
)
def _build_install_and_run_command(
settings: Settings,
*,
pr: PullRequestContext,
review_prompt: str,
result_start_marker: str,
result_end_marker: str,
) -> str:
runner_fallback_json = json.dumps(
{
"verdict": "has_issues",
"confidence": 0.67,
"summary": "Ephemeral codex execution failed before producing a review result.",
"markdown_comment": "Ephemeral codex execution failed before producing a review result.",
"findings": [
{
"severity": "high",
"file": "runner",
"line_start": 1,
"line_end": 1,
"title": "Ephemeral review runner failed",
"body": "codex exec failed before emitting a valid structured artifact.",
"suggestion": "Check ephemeral runner logs for auth/model/network issues and rerun @codex review.",
}
],
},
separators=(",", ":"),
)
steps = [
"set -euo pipefail",
f"rm -f {shlex.quote(REVIEW_EMITTED_FILE)}",
"emit_review_artifact() { "
"rc=\"$1\"; "
f"if [ ! -s {shlex.quote(REVIEW_OUTPUT_FILE)} ]; then "
f"cat > {shlex.quote(REVIEW_OUTPUT_FILE)} <<'JSON'\n{runner_fallback_json}\nJSON\n"
"fi; "
f'if [ ! -f {shlex.quote(REVIEW_EMITTED_FILE)} ]; then echo "{result_start_marker}"; cat {shlex.quote(REVIEW_OUTPUT_FILE)}; echo "{result_end_marker}"; touch {shlex.quote(REVIEW_EMITTED_FILE)}; fi; '
"return \"$rc\"; "
"}",
"trap 'rc=$?; set +e; emit_review_artifact \"$rc\"; exit \"$rc\"' EXIT",
]
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(
[
f"mkdir -p {CONTAINER_CODEX_HOME}",
'printf "%s" "$CODEX_AUTH_JSON_B64" | base64 -d > /root/.codex/auth.json',
f"chmod 600 {CONTAINER_CODEX_HOME}/auth.json",
]
)
steps.extend(
[
"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()
codex_exec_parts = [
"codex exec",
"--sandbox",
"danger-full-access",
"--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)}")
codex_exec_parts.append(shlex.quote(review_prompt))
steps.extend(
[
"set +e",
"codex_rc=0",
" ".join(codex_exec_parts) + ' || codex_rc="$?"',
"set -e",
f'if [ "$codex_rc" -ne 0 ] || [ ! -s {shlex.quote(REVIEW_OUTPUT_FILE)} ]; then cat > {REVIEW_OUTPUT_FILE} <<\'JSON\'\n{runner_fallback_json}\nJSON\nfi',
"emit_review_artifact 0",
]
)
return "\n".join(steps)
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"
allow_test_execution = command.mode == "tests" or repo_cfg.include_tests
tests_policy = (
"Tests may be executed for this run because tests mode/include_tests is explicitly enabled."
if allow_test_execution
else "Do not run tests, benchmarks, or other executables. Review changes statically unless explicitly asked."
)
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}.",
tests_policy,
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]:
cmd = [
"docker",
"run",
"--rm",
"-i",
"--name",
container_name,
"-e",
"CODEX_DISABLE_TELEMETRY=1",
"-e",
"CODEX_SANDBOX_MODE=danger-full-access",
]
if settings.codex_auth_mode == "chatgpt":
cmd.extend(
[
"-e",
f"CODEX_HOME={CONTAINER_CODEX_HOME}",
"-e",
"CODEX_AUTH_JSON_B64",
]
)
else:
cmd.extend(
[
"-e",
"OPENAI_API_KEY",
"-e",
"OPENAI_ORG_ID",
"-e",
"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
def _ephemeral_runner_failure_result(exc: Exception, auth_mode: str) -> dict[str, Any]:
message = str(exc).strip() or exc.__class__.__name__
mode_label = "ChatGPT auth" if auth_mode == "chatgpt" else "API-key auth"
summary = f"{mode_label} runner failed before review execution. Error: {message}"
return {
"verdict": "has_issues",
"confidence": 0.67,
"summary": summary,
"findings": [
{
"severity": "high",
"file": "runner",
"line_start": 1,
"line_end": 1,
"title": "Ephemeral review runner failed",
"body": message,
"suggestion": "Check ephemeral runner logs for model/auth/network issues, then rerun @codex review.",
}
],
}
def _format_runner_failure(completed: subprocess.CompletedProcess[str]) -> str:
stdout_tail = _tail_text(completed.stdout)
stderr_tail = _tail_text(completed.stderr)
message = f"ephemeral runner exited with code {completed.returncode}"
if stdout_tail:
message = f"{message}; stdout_tail={stdout_tail}"
if stderr_tail:
message = f"{message}; stderr_tail={stderr_tail}"
return message
def _tail_text(text: str, limit: int = 1200) -> str:
compact = " ".join(text.split())
if len(compact) <= limit:
return compact
return f"...{compact[-limit:]}"
def _resolve_codex_auth_json_path(settings: Settings) -> Path:
raw_path = settings.codex_auth_json_path.strip() if settings.codex_auth_json_path else "~/.codex/auth.json"
path = Path(raw_path).expanduser()
if not path.exists() or not path.is_file():
raise FileNotFoundError(
f"CODEX_AUTH_MODE=chatgpt requires a readable auth.json file. Checked path: {path}"
)
return path.resolve()
def _load_codex_auth_json_b64(settings: Settings) -> str:
auth_path = _resolve_codex_auth_json_path(settings)
content = auth_path.read_text(encoding="utf-8")
# Validate JSON before handing it to the ephemeral runner.
json.loads(content)
return base64.b64encode(content.encode("utf-8")).decode("ascii")
def ensure_workdir(path: str) -> Path:
target = Path(path)
target.mkdir(parents=True, exist_ok=True)
return target
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]:
start_pos = stdout.find(result_start_marker)
if start_pos == -1:
raise RuntimeError("Runner output did not include final review artifact markers.")
artifact_start = start_pos + len(result_start_marker)
# Prefer the last end marker so marker-like text inside JSON does not
# truncate the payload when earlier incidental matches exist.
end_pos = stdout.rfind(result_end_marker)
if end_pos == -1 or end_pos <= artifact_start:
raise RuntimeError("Runner output did not include final review artifact markers.")
artifact = stdout[artifact_start:end_pos].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]:
model = settings.openai_review_model
usage: dict[str, int] = {}
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
discovered_model = _find_first_string_for_key(payload, "model")
if discovered_model:
model = discovered_model
discovered_usage = _find_first_dict_for_key(payload, "usage")
if isinstance(discovered_usage, dict):
for output_key, source_key in (
("input_tokens", "input_tokens"),
("output_tokens", "output_tokens"),
("total_tokens", "total_tokens"),
):
value = discovered_usage.get(source_key)
if isinstance(value, int):
usage[output_key] = value
return {"source": "ephemeral_runner", "model": model, "usage": usage}
def _find_first_string_for_key(payload: Any, key: str) -> str | None:
if isinstance(payload, dict):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value
for nested in payload.values():
found = _find_first_string_for_key(nested, key)
if found:
return found
if isinstance(payload, list):
for item in payload:
found = _find_first_string_for_key(item, key)
if found:
return found
return None
def _find_first_dict_for_key(payload: Any, key: str) -> dict[str, Any] | None:
if isinstance(payload, dict):
value = payload.get(key)
if isinstance(value, dict):
return value
for nested in payload.values():
found = _find_first_dict_for_key(nested, key)
if found:
return found
if isinstance(payload, list):
for item in payload:
found = _find_first_dict_for_key(item, key)
if found:
return found
return None