Coverage for src/ai_jury/commands.py: 100%
54 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-05 20:29 +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"""
15from __future__ import annotations
17import re
18import shlex
19from dataclasses import dataclass
21# Allowlisted subcommands. ``review`` = a normal review; ``summary`` = a quick
22# single-round pass intended for a short summary comment.
23ALLOWED_COMMANDS = ("review", "summary")
25# Bound on an allowlisted --rounds value (mirrors the jury's practical range).
26_MIN_ROUNDS, _MAX_ROUNDS = 1, 3
28# The trigger: a line that begins (ignoring leading space) with "/jury".
29_TRIGGER_RE = re.compile(r"^\s*/jury\b(.*)$", re.MULTILINE)
32class CommandError(Exception):
33 """Raised when a comment is not a valid, allowlisted jury command."""
36@dataclass
37class ParsedCommand:
38 command: str
39 rounds: int | None = None
41 def to_cli_args(self) -> list[str]:
42 """Map the parsed command to a jury CLI argument vector (allowlisted)."""
43 args: list[str] = []
44 if self.command == "summary":
45 # A summary is a fast single-round pass unless an explicit rounds
46 # override was given.
47 args += ["--rounds", str(self.rounds if self.rounds is not None else 1)]
48 elif self.rounds is not None:
49 args += ["--rounds", str(self.rounds)]
50 return args
53def parse_comment(text: str) -> ParsedCommand:
54 """Parse a PR comment into an allowlisted :class:`ParsedCommand`.
56 Raises :class:`CommandError` when the text is not a ``/jury`` command, the
57 subcommand is not allowlisted, or any flag/argument is unrecognized or out of
58 range.
59 """
60 if not text:
61 raise CommandError("empty comment: no /jury command found")
63 match = _TRIGGER_RE.search(text)
64 if not match:
65 raise CommandError("no /jury command found in comment")
67 try:
68 tokens = shlex.split(match.group(1).strip())
69 except ValueError as exc:
70 raise CommandError(f"could not parse command: {exc}") from exc
72 if not tokens:
73 raise CommandError(
74 f"missing subcommand; expected one of {', '.join(ALLOWED_COMMANDS)}"
75 )
77 command, rest = tokens[0], tokens[1:]
78 if command not in ALLOWED_COMMANDS:
79 raise CommandError(
80 f"unsupported command '{command}'; allowed: {', '.join(ALLOWED_COMMANDS)}"
81 )
83 rounds: int | None = None
84 i = 0
85 while i < len(rest):
86 tok = rest[i]
87 if tok == "--rounds":
88 if i + 1 >= len(rest):
89 raise CommandError("--rounds requires a value")
90 value = rest[i + 1]
91 i += 2
92 elif tok.startswith("--rounds="):
93 value = tok.split("=", 1)[1]
94 i += 1
95 else:
96 raise CommandError(f"unsupported argument '{tok}'; only --rounds is allowed")
97 try:
98 rounds = int(value)
99 except ValueError as exc:
100 raise CommandError(f"--rounds must be an integer (got {value!r})") from exc
101 if not (_MIN_ROUNDS <= rounds <= _MAX_ROUNDS):
102 raise CommandError(
103 f"--rounds must be between {_MIN_ROUNDS} and {_MAX_ROUNDS} (got {rounds})"
104 )
106 return ParsedCommand(command=command, rounds=rounds)