Coverage for src/ai_jury/commands.py: 100%
55 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-26 23:18 +0000
1"""Parse GitHub PR comment commands into safe, allowlisted jury runs (#11).
3Mature review tools can be triggered from a PR comment like ``/jury review``.
4This module parses such a comment into a structured, **allowlisted** command and
5maps it to a jury CLI argument vector. Security properties:
7- only a fixed allowlist of subcommands (``review``, ``summary``) is accepted;
8- only an allowlist of flags (``--rounds N``) is accepted;
9- the comment text is tokenized with :func:`shlex.split` and mapped to an argv
10 — it is NEVER passed to a shell, so arbitrary commands in a comment cannot run;
11- anything unrecognized raises :class:`CommandError` (the caller rejects it).
13Pure and network-free, so parsing and rejection are fully unit-testable.
14"""
16from __future__ import annotations
18import re
19import shlex
20from dataclasses import dataclass
22from .redaction import redact
24# Allowlisted subcommands. ``review`` = a normal review; ``summary`` = a quick
25# single-round pass intended for a short summary comment.
26ALLOWED_COMMANDS = ("review", "summary")
28# Bound on an allowlisted --rounds value (mirrors the jury's practical range).
29_MIN_ROUNDS, _MAX_ROUNDS = 1, 3
31# The trigger: a line that begins (ignoring leading space) with "/jury".
32_TRIGGER_RE = re.compile(r"^\s*/jury\b(.*)$", re.MULTILINE)
35class CommandError(Exception):
36 """Raised when a comment is not a valid, allowlisted jury command."""
39@dataclass
40class ParsedCommand:
41 command: str
42 rounds: int | None = None
44 def to_cli_args(self) -> list[str]:
45 """Map the parsed command to a jury CLI argument vector (allowlisted)."""
46 args: list[str] = []
47 if self.command == "summary":
48 # A summary is a fast single-round pass unless an explicit rounds
49 # override was given.
50 args += ["--rounds", str(self.rounds if self.rounds is not None else 1)]
51 elif self.rounds is not None:
52 args += ["--rounds", str(self.rounds)]
53 return args
56def parse_comment(text: str) -> ParsedCommand:
57 """Parse a PR comment into an allowlisted :class:`ParsedCommand`.
59 Raises :class:`CommandError` when the text is not a ``/jury`` command, the
60 subcommand is not allowlisted, or any flag/argument is unrecognized or out of
61 range.
62 """
63 if not text:
64 raise CommandError("empty comment: no /jury command found")
66 match = _TRIGGER_RE.search(text)
67 if not match:
68 raise CommandError("no /jury command found in comment")
70 try:
71 tokens = shlex.split(match.group(1).strip())
72 except ValueError as exc:
73 raise CommandError(f"could not parse command: {redact(str(exc))[0]}") from exc
75 if not tokens:
76 raise CommandError(f"missing subcommand; expected one of {', '.join(ALLOWED_COMMANDS)}")
78 command, rest = tokens[0], tokens[1:]
79 if command not in ALLOWED_COMMANDS:
80 raise CommandError(
81 f"unsupported command '{command}'; allowed: {', '.join(ALLOWED_COMMANDS)}"
82 )
84 rounds: int | None = None
85 i = 0
86 while i < len(rest):
87 tok = rest[i]
88 if tok == "--rounds":
89 if i + 1 >= len(rest):
90 raise CommandError("--rounds requires a value")
91 value = rest[i + 1]
92 i += 2
93 elif tok.startswith("--rounds="):
94 value = tok.split("=", 1)[1]
95 i += 1
96 else:
97 raise CommandError(f"unsupported argument '{tok}'; only --rounds is allowed")
98 try:
99 rounds = int(value)
100 except ValueError as exc:
101 raise CommandError(f"--rounds must be an integer (got {value!r})") from exc
102 if not (_MIN_ROUNDS <= rounds <= _MAX_ROUNDS):
103 raise CommandError(
104 f"--rounds must be between {_MIN_ROUNDS} and {_MAX_ROUNDS} (got {rounds})"
105 )
107 return ParsedCommand(command=command, rounds=rounds)