From 93925914290f32b8ff25b598a4969de7b4d2c811 Mon Sep 17 00:00:00 2001 From: Space-Banane Date: Sat, 23 May 2026 14:07:48 +0200 Subject: [PATCH] [feat]. Add @codex -h help summary command --- src/gitea_codex_bot/main.py | 2 +- src/gitea_codex_bot/services/commands.py | 19 ++++- src/gitea_codex_bot/types.py | 2 +- src/gitea_codex_bot/workers/dispatcher.py | 96 ++++++++++++++++++++++- tests/test_commands.py | 13 +++ tests/test_dispatcher.py | 40 ++++++++++ tests/test_webhook.py | 29 +++++++ 7 files changed, 193 insertions(+), 8 deletions(-) diff --git a/src/gitea_codex_bot/main.py b/src/gitea_codex_bot/main.py index befc36a..f932234 100644 --- a/src/gitea_codex_bot/main.py +++ b/src/gitea_codex_bot/main.py @@ -495,7 +495,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 {"fix", "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..7dbc53d 100644 --- a/src/gitea_codex_bot/services/commands.py +++ b/src/gitea_codex_bot/services/commands.py @@ -5,12 +5,14 @@ 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", "fix", "ignore", "rerun"} 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 +20,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) diff --git a/src/gitea_codex_bot/types.py b/src/gitea_codex_bot/types.py index 62a3ca3..1420cc6 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", "fix", "ignore", "rerun", "help"] @dataclass(slots=True) diff --git a/src/gitea_codex_bot/workers/dispatcher.py b/src/gitea_codex_bot/workers/dispatcher.py index 899ff9d..a47d87b 100644 --- a/src/gitea_codex_bot/workers/dispatcher.py +++ b/src/gitea_codex_bot/workers/dispatcher.py @@ -4,12 +4,12 @@ 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 @@ -34,6 +34,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": @@ -74,6 +81,91 @@ def _handle_non_review_command( 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 fix [--branch ...]`", + "- `@codex ignore`", + "- `@codex -h` / `@codex --help` / `@codex help`", + "", + "Status note:", + f"- Pending jobs on this PR: `{pending_count}`", + f"- Fix command enabled: `{str(settings.enable_fix_commands).lower()}`", + 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/tests/test_commands.py b/tests/test_commands.py index dc1009b..52c0874 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -39,3 +39,16 @@ 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"] diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 57d2ffe..d51ea6d 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -146,3 +146,43 @@ 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 "Discussion summary" in body + assert "@alice: Please check auth edge cases" in body diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 07a4af0..943dfd8 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -225,6 +225,35 @@ 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_logs_when_repo_not_allowed(monkeypatch) -> None: messages: list[str] = []