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

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

15from __future__ import annotations 

16 

17import re 

18import shlex 

19from dataclasses import dataclass 

20 

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

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

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

24 

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

26_MIN_ROUNDS, _MAX_ROUNDS = 1, 3 

27 

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

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

30 

31 

32class CommandError(Exception): 

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

34 

35 

36@dataclass 

37class ParsedCommand: 

38 command: str 

39 rounds: int | None = None 

40 

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 

51 

52 

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

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

55 

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

62 

63 match = _TRIGGER_RE.search(text) 

64 if not match: 

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

66 

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 

71 

72 if not tokens: 

73 raise CommandError( 

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

75 ) 

76 

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 ) 

82 

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 ) 

105 

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