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

1"""Parse GitHub PR comment commands into safe, allowlisted jury runs (#11). 

2 

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: 

6 

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). 

12 

13Pure and network-free, so parsing and rejection are fully unit-testable. 

14""" 

15 

16from __future__ import annotations 

17 

18import re 

19import shlex 

20from dataclasses import dataclass 

21 

22from .redaction import redact 

23 

24# Allowlisted subcommands. ``review`` = a normal review; ``summary`` = a quick 

25# single-round pass intended for a short summary comment. 

26ALLOWED_COMMANDS = ("review", "summary") 

27 

28# Bound on an allowlisted --rounds value (mirrors the jury's practical range). 

29_MIN_ROUNDS, _MAX_ROUNDS = 1, 3 

30 

31# The trigger: a line that begins (ignoring leading space) with "/jury". 

32_TRIGGER_RE = re.compile(r"^\s*/jury\b(.*)$", re.MULTILINE) 

33 

34 

35class CommandError(Exception): 

36 """Raised when a comment is not a valid, allowlisted jury command.""" 

37 

38 

39@dataclass 

40class ParsedCommand: 

41 command: str 

42 rounds: int | None = None 

43 

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 

54 

55 

56def parse_comment(text: str) -> ParsedCommand: 

57 """Parse a PR comment into an allowlisted :class:`ParsedCommand`. 

58 

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") 

65 

66 match = _TRIGGER_RE.search(text) 

67 if not match: 

68 raise CommandError("no /jury command found in comment") 

69 

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 

74 

75 if not tokens: 

76 raise CommandError(f"missing subcommand; expected one of {', '.join(ALLOWED_COMMANDS)}") 

77 

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 ) 

83 

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 ) 

106 

107 return ParsedCommand(command=command, rounds=rounds)