Compare commits

...

3 Commits

Author SHA1 Message Date
c3bc3501ca Merge pull request '[feat]. Add username and custom bot mention tags' (#2) from codex/username-and-custom-mention-aliases into main
All checks were successful
ci / test (push) Successful in 33s
ci / publish (push) Successful in 1m35s
Reviewed-on: #2
2026-05-23 13:12:14 +02:00
Space-Banane
d662cabe72 feat: Enhance ephemeral review process with reasoning effort handling and retry logic
All checks were successful
ci / test (pull_request) Successful in 32s
ci / publish (pull_request) Has been skipped
2026-05-23 13:05:57 +02:00
Space-Banane
0de069fd32 [feat]. Add username and custom bot mention tags
All checks were successful
ci / test (pull_request) Successful in 57s
ci / publish (pull_request) Has been skipped
2026-05-23 12:48:00 +02:00
11 changed files with 194 additions and 20 deletions

View File

@@ -4,6 +4,8 @@ GITEA_BASE_URL=https://gitea.reversed.dev
# Bot account token used to read PRs and write comments. # Bot account token used to read PRs and write comments.
GITEA_TOKEN=replace GITEA_TOKEN=replace
GITEA_BOT_USERNAME=codex-bot GITEA_BOT_USERNAME=codex-bot
# Optional extra command mentions (comma-separated), e.g. "@review-buddy,helper-bot".
GITEA_BOT_MENTIONS=
# Shared secret configured on the Gitea webhook. # Shared secret configured on the Gitea webhook.
GITEA_WEBHOOK_SECRET=replace GITEA_WEBHOOK_SECRET=replace

View File

@@ -6,7 +6,7 @@ Webhook-driven PR review bot for Gitea.
- Handles `issue_comment` and `pull_request_comment` events. - Handles `issue_comment` and `pull_request_comment` events.
- Verifies `X-Gitea-Signature` HMAC (`sha256`). - Verifies `X-Gitea-Signature` HMAC (`sha256`).
- Triggers on `@codex review`, `@codex rerun`, `@codex explain`, `@codex fix`, `@codex ignore`. - Triggers on `@codex ...`, `@<GITEA_BOT_USERNAME> ...`, plus optional custom aliases from `GITEA_BOT_MENTIONS`.
- Ignores bot-authored comments. - Ignores bot-authored comments.
- Enforces strict repository allowlist (`ALLOWED_REPOS`). - Enforces strict repository allowlist (`ALLOWED_REPOS`).
- Deduplicates webhook deliveries/comments in DB. - Deduplicates webhook deliveries/comments in DB.
@@ -54,6 +54,7 @@ Optional:
- `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`, optional when `CODEX_AUTH_MODE=chatgpt`) - `OPENAI_API_KEY` (required when `CODEX_AUTH_MODE=api_key`, optional when `CODEX_AUTH_MODE=chatgpt`)
- `OPENAI_PROJECT_ID` - `OPENAI_PROJECT_ID`
- `OPENAI_ORG_ID` - `OPENAI_ORG_ID`
- `GITEA_BOT_MENTIONS` (comma-separated extra mention aliases, e.g. `@review-buddy,helper-bot`)
- `CODEX_AUTH_MODE` (`api_key` default, or `chatgpt`) - `CODEX_AUTH_MODE` (`api_key` default, or `chatgpt`)
- `CODEX_AUTH_JSON_PATH` (custom host path to `auth.json`; defaults to `~/.codex/auth.json` in `chatgpt` mode) - `CODEX_AUTH_JSON_PATH` (custom host path to `auth.json`; defaults to `~/.codex/auth.json` in `chatgpt` mode)
- `DATABASE_URL` (overrides composed DB URL) - `DATABASE_URL` (overrides composed DB URL)
@@ -95,4 +96,4 @@ This project was made WITH codex and is meant to be used WITH codex as a review
If you are as rich as Peter Steinberg and get a free OpenAI API Key, feel free to use it for this bot. If you are as rich as Peter Steinberg and get a free OpenAI API Key, feel free to use it for this bot.
## Contributing ## Contributing
Contributions are welcome! Please open issues or submit pull requests for bug fixes, improvements, or new features. Contributions are welcome! Please open issues or submit pull requests for bug fixes, improvements, or new features.

View File

@@ -22,7 +22,7 @@
- [ ] `FEATURE`: Add retries/backoff for `codex exec` bootstrap (`npm install -g @openai/codex`) to reduce transient network/setup failures. - [ ] `FEATURE`: Add retries/backoff for `codex exec` bootstrap (`npm install -g @openai/codex`) to reduce transient network/setup failures.
- [ ] `FEATURE`: `WEBHOOK_MODE` is currently informational only; add runtime validation/check endpoint that confirms expected webhook scope (`repo` or `global`) is actually configured in Gitea by host admin. - [ ] `FEATURE`: `WEBHOOK_MODE` is currently informational only; add runtime validation/check endpoint that confirms expected webhook scope (`repo` or `global`) is actually configured in Gitea by host admin.
- [ ] `TEST`: Add end-to-end test path against live Gitea + MariaDB + docker runner (webhook -> queue -> runner -> PR comment update). - [ ] `TEST`: Add end-to-end test path against live Gitea + MariaDB + docker runner (webhook -> queue -> runner -> PR comment update).
- [ ] `FEATURE`: Add username as possible command prefix, ex. "@bot-name review" in addition to "@codex review", for better UX discoverability. - [x] `FEATURE`: Add username as possible command prefix, ex. "@bot-name review" in addition to "@codex review", for better UX discoverability.
### P2 (Nice to Have) ### P2 (Nice to Have)
- [x] `FEATURE`: Add a note line at the end of comments to show model tokens used and such. - [x] `FEATURE`: Add a note line at the end of comments to show model tokens used and such.

View File

@@ -13,6 +13,7 @@ class Settings(BaseSettings):
gitea_base_url: str = Field(alias="GITEA_BASE_URL") gitea_base_url: str = Field(alias="GITEA_BASE_URL")
gitea_token: SecretStr = Field(alias="GITEA_TOKEN") gitea_token: SecretStr = Field(alias="GITEA_TOKEN")
gitea_bot_username: str = Field(alias="GITEA_BOT_USERNAME") gitea_bot_username: str = Field(alias="GITEA_BOT_USERNAME")
gitea_bot_mentions: str = Field(default="", alias="GITEA_BOT_MENTIONS")
gitea_webhook_secret: SecretStr = Field(alias="GITEA_WEBHOOK_SECRET") gitea_webhook_secret: SecretStr = Field(alias="GITEA_WEBHOOK_SECRET")
openai_api_key: SecretStr | None = Field(default=None, alias="OPENAI_API_KEY") openai_api_key: SecretStr | None = Field(default=None, alias="OPENAI_API_KEY")
@@ -60,6 +61,13 @@ class Settings(BaseSettings):
values = [item.strip() for item in self.allowed_repos.split(",")] values = [item.strip() for item in self.allowed_repos.split(",")]
return {value for value in values if value} return {value for value in values if value}
@property
def bot_command_aliases(self) -> set[str]:
configured = [item.strip().lstrip("@").lower() for item in self.gitea_bot_mentions.split(",")]
aliases = {"codex", self.gitea_bot_username.strip().lstrip("@").lower()}
aliases.update(alias for alias in configured if alias)
return aliases
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -422,7 +422,7 @@ async def gitea_webhook(
return {"accepted": False, "reason": "bot comment ignored"} return {"accepted": False, "reason": "bot comment ignored"}
comment_body = str(payload.get("comment", {}).get("body", "")).strip() comment_body = str(payload.get("comment", {}).get("body", "")).strip()
parsed_command = parse_command(comment_body) parsed_command = parse_command(comment_body, aliases=settings.bot_command_aliases)
if not parsed_command: if not parsed_command:
logger.info( logger.info(
"Webhook ignored: no @codex review command repo=%s pr=%s comment_id=%s sender=%s", "Webhook ignored: no @codex review command repo=%s pr=%s comment_id=%s sender=%s",

View File

@@ -1,19 +1,25 @@
from __future__ import annotations from __future__ import annotations
import re import re
from collections.abc import Iterable
from gitea_codex_bot.types import ParsedCommand from gitea_codex_bot.types import ParsedCommand
COMMAND_RE = re.compile(r"^@codex\s+(review|explain|fix|ignore|rerun)\b(.*)$", re.IGNORECASE | re.DOTALL) COMMAND_RE = re.compile(r"^@([^\s]+)\s+(review|explain|fix|ignore|rerun)\b(.*)$", re.IGNORECASE | re.DOTALL)
def parse_command(body: str) -> ParsedCommand | None: def parse_command(body: str, aliases: Iterable[str] | None = None) -> ParsedCommand | None:
stripped = body.strip() stripped = body.strip()
match = COMMAND_RE.match(stripped) match = COMMAND_RE.match(stripped)
if not match: if not match:
return None return None
name = match.group(1).lower() command_alias = match.group(1).lstrip("@").lower()
rest = match.group(2).strip() allowed_aliases = {alias.lstrip("@").lower() for alias in (aliases or {"codex"})}
if command_alias not in allowed_aliases:
return None
name = match.group(2).lower()
rest = match.group(3).strip()
tokens = [token for token in rest.split() if token] tokens = [token for token in rest.split() if token]
parsed = ParsedCommand(name=name, raw=stripped, arguments=tokens) parsed = ParsedCommand(name=name, raw=stripped, arguments=tokens)

View File

@@ -31,21 +31,26 @@ def run_review_ephemeral(
gitea = GiteaClient(settings) gitea = GiteaClient(settings)
prompt, _diff_context, repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command) prompt, _diff_context, repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command)
container_name = f"codex-review-{uuid.uuid4().hex[:12]}" container_name = f"codex-review-{uuid.uuid4().hex[:12]}"
install_and_run = _build_install_and_run_command(settings)
extra_env: dict[str, str] = {} extra_env: dict[str, str] = {}
if settings.codex_auth_mode == "chatgpt": if settings.codex_auth_mode == "chatgpt":
extra_env["CODEX_AUTH_JSON_B64"] = _load_codex_auth_json_b64(settings) extra_env["CODEX_AUTH_JSON_B64"] = _load_codex_auth_json_b64(settings)
cmd = _build_docker_command(settings, container_name=container_name, install_and_run=install_and_run)
try: try:
completed = subprocess.run( completed = _run_ephemeral_container(
cmd, settings,
input=prompt, container_name=container_name,
text=True, prompt=prompt,
check=False, extra_env=extra_env,
capture_output=True, include_reasoning_effort=True,
timeout=settings.max_review_minutes * 60,
env={**os.environ, **extra_env},
) )
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: if completed.returncode != 0:
raise RuntimeError(_format_runner_failure(completed)) raise RuntimeError(_format_runner_failure(completed))
parsed = _parse_codex_exec_stdout(completed.stdout) parsed = _parse_codex_exec_stdout(completed.stdout)
@@ -56,7 +61,28 @@ def run_review_ephemeral(
return _ephemeral_runner_failure_result(exc, settings.codex_auth_mode), repo_cfg return _ephemeral_runner_failure_result(exc, settings.codex_auth_mode), repo_cfg
def _build_install_and_run_command(settings: Settings) -> str: def _run_ephemeral_container(
settings: Settings,
*,
container_name: str,
prompt: 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)
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,
timeout=settings.max_review_minutes * 60,
env={**os.environ, **extra_env},
)
def _build_install_and_run_command(settings: Settings, *, include_reasoning_effort: bool = True) -> str:
steps = ["set -euo pipefail"] steps = ["set -euo pipefail"]
if settings.codex_auth_mode == "chatgpt": if settings.codex_auth_mode == "chatgpt":
steps.extend( steps.extend(
@@ -77,12 +103,19 @@ def _build_install_and_run_command(settings: Settings) -> str:
codex_exec_parts = ["codex exec --skip-git-repo-check --json"] codex_exec_parts = ["codex exec --skip-git-repo-check --json"]
if model: if model:
codex_exec_parts.append(f"-m {shlex.quote(model)}") codex_exec_parts.append(f"-m {shlex.quote(model)}")
if reasoning_effort: if include_reasoning_effort and reasoning_effort:
codex_exec_parts.append(f"--reasoning-effort {shlex.quote(reasoning_effort)}") codex_exec_parts.append(f"--reasoning-effort {shlex.quote(reasoning_effort)}")
steps.append(" ".join(codex_exec_parts)) steps.append(" ".join(codex_exec_parts))
return "; ".join(steps) return "; ".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 _build_docker_command(settings: Settings, *, container_name: str, install_and_run: str) -> list[str]: def _build_docker_command(settings: Settings, *, container_name: str, install_and_run: str) -> list[str]:
cmd = [ cmd = [
"docker", "docker",

View File

@@ -26,3 +26,16 @@ def test_parse_fix_branch() -> None:
def test_invalid_command_returns_none() -> None: def test_invalid_command_returns_none() -> None:
assert parse_command("hello") is None assert parse_command("hello") is None
def test_parse_review_command_for_bot_username_alias() -> None:
cmd = parse_command("@codex-bot review", aliases={"codex", "codex-bot"})
assert cmd is not None
assert cmd.name == "review"
def test_parse_review_command_for_custom_alias() -> None:
cmd = parse_command("@review-buddy review tests", aliases={"codex", "review-buddy"})
assert cmd is not None
assert cmd.name == "review"
assert cmd.mode == "tests"

View File

@@ -11,3 +11,16 @@ def test_codex_auth_defaults_to_api_key_mode() -> None:
settings = get_settings() settings = get_settings()
assert settings.codex_auth_mode == "api_key" assert settings.codex_auth_mode == "api_key"
assert settings.codex_auth_json_path is None assert settings.codex_auth_json_path is None
def test_bot_command_aliases_include_codex_and_username() -> None:
settings = get_settings()
assert settings.bot_command_aliases == {"codex", "codex-bot"}
def test_bot_command_aliases_include_custom_mentions(monkeypatch) -> None:
monkeypatch.setenv("GITEA_BOT_MENTIONS", "@review-buddy,helper-bot")
get_settings.cache_clear()
settings = get_settings()
assert settings.bot_command_aliases == {"codex", "codex-bot", "review-buddy", "helper-bot"}

View File

@@ -72,6 +72,14 @@ def test_build_install_command_includes_configured_reasoning_effort(monkeypatch:
assert "--reasoning-effort medium" in command 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)
assert "--reasoning-effort" not in command
def test_chatgpt_mode_requires_existing_auth_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def test_chatgpt_mode_requires_existing_auth_json(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
missing = tmp_path / "missing-auth.json" missing = tmp_path / "missing-auth.json"
monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt") monkeypatch.setenv("CODEX_AUTH_MODE", "chatgpt")
@@ -157,6 +165,59 @@ def test_run_review_ephemeral_api_key_mode_does_not_fallback_to_host(monkeypatch
assert "API-key auth runner failed" in result["summary"] assert "API-key auth runner failed" in result["summary"]
def test_run_review_ephemeral_retries_without_reasoning_effort_when_unsupported(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())
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',
"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",
pr_number=1,
command=ParsedCommand(name="review", raw="@codex review"),
)
assert result["verdict"] == "correct"
assert len(calls) == 2
first_shell = calls[0][-1]
second_shell = calls[1][-1]
assert "--reasoning-effort" in first_shell
assert "--reasoning-effort" not in second_shell
def test_parse_codex_exec_stdout_from_stream_item_text_json() -> None: def test_parse_codex_exec_stdout_from_stream_item_text_json() -> None:
stdout = '\n'.join( stdout = '\n'.join(
[ [

View File

@@ -93,6 +93,43 @@ def test_webhook_accepts_review_and_queues(monkeypatch) -> None:
assert queued.trigger_comment_body == "@codex review security" assert queued.trigger_comment_body == "@codex review security"
def test_webhook_accepts_review_for_bot_username_alias(monkeypatch) -> None:
posted_comments: list[str] = []
def _post_issue_comment(self, repo: str, pr_number: int, body: str) -> int:
posted_comments.append(body)
return 100
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.post_issue_comment", _post_issue_comment)
monkeypatch.setattr(
"gitea_codex_bot.services.gitea.GiteaClient.get_pull_request",
lambda *_args, **_kwargs: type("PR", (), {"head_sha": "abcdef123"})(),
)
monkeypatch.setattr("gitea_codex_bot.services.gitea.GiteaClient.get_file_content", lambda *_args, **_kwargs: None)
client = TestClient(app)
payload_obj = _payload("@codex-bot review security", username="alice", comment_id=311)
raw = json.dumps(payload_obj).encode()
response = client.post(
"/webhook/gitea",
content=raw,
headers={
"X-Gitea-Event": "issue_comment",
"X-Gitea-Delivery": "d-2-alias",
"X-Gitea-Signature": _sign(raw),
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert posted_comments
session_factory = get_session_factory()
with session_factory() as session:
queued = session.execute(select(ReviewJob).where(ReviewJob.trigger_comment_id == 311)).scalar_one()
assert queued.trigger_comment_body == "@codex-bot review security"
def test_webhook_uses_latest_pr_head_sha_when_config_lookup_fails(monkeypatch) -> None: def test_webhook_uses_latest_pr_head_sha_when_config_lookup_fails(monkeypatch) -> None:
posted_comments: list[str] = [] posted_comments: list[str] = []