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
This commit was merged in pull request #2.
This commit is contained in:
2026-05-23 13:12:14 +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.
GITEA_TOKEN=replace
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.
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.
- 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.
- Enforces strict repository allowlist (`ALLOWED_REPOS`).
- 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_PROJECT_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_JSON_PATH` (custom host path to `auth.json`; defaults to `~/.codex/auth.json` in `chatgpt` mode)
- `DATABASE_URL` (overrides composed DB URL)

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`: `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).
- [ ] `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)
- [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_token: SecretStr = Field(alias="GITEA_TOKEN")
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")
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(",")]
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)
def get_settings() -> Settings:

View File

@@ -422,7 +422,7 @@ async def gitea_webhook(
return {"accepted": False, "reason": "bot comment ignored"}
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:
logger.info(
"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
import re
from collections.abc import Iterable
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()
match = COMMAND_RE.match(stripped)
if not match:
return None
name = match.group(1).lower()
rest = match.group(2).strip()
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
name = match.group(2).lower()
rest = match.group(3).strip()
tokens = [token for token in rest.split() if token]
parsed = ParsedCommand(name=name, raw=stripped, arguments=tokens)

View File

@@ -31,21 +31,26 @@ def run_review_ephemeral(
gitea = GiteaClient(settings)
prompt, _diff_context, repo_cfg = prepare_review_prompt(settings, gitea, repo, pr_number, command)
container_name = f"codex-review-{uuid.uuid4().hex[:12]}"
install_and_run = _build_install_and_run_command(settings)
extra_env: dict[str, str] = {}
if settings.codex_auth_mode == "chatgpt":
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:
completed = subprocess.run(
cmd,
input=prompt,
text=True,
check=False,
capture_output=True,
timeout=settings.max_review_minutes * 60,
env={**os.environ, **extra_env},
completed = _run_ephemeral_container(
settings,
container_name=container_name,
prompt=prompt,
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)
@@ -56,7 +61,28 @@ def run_review_ephemeral(
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"]
if settings.codex_auth_mode == "chatgpt":
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"]
if 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)}")
steps.append(" ".join(codex_exec_parts))
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]:
cmd = [
"docker",

View File

@@ -26,3 +26,16 @@ def test_parse_fix_branch() -> None:
def test_invalid_command_returns_none() -> 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()
assert settings.codex_auth_mode == "api_key"
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
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:
missing = tmp_path / "missing-auth.json"
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"]
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:
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"
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:
posted_comments: list[str] = []