Coverage for src/pullapprove/presets.py: 100%
22 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-02 15:09 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-02 15:09 -0500
1"""The commands a preset name stands for.
3A preset is PullApprove's own maintained invocation of a reviewer CLI, named by
4a word instead of pasted into every config. It lives here, in the library,
5because it is part of the config contract: `check` resolves the name, so an
6unknown preset is a config error a person sees at commit time rather than a run
7that fails an hour later.
9Nothing here runs anything. These strings are data — the app splices them and
10executes them, and the execution side is what will smoke whether an invocation
11is actually right for the CLI's current release. A preset that stops working
12because its CLI changed is a change to this table.
13"""
15from __future__ import annotations
17import re
18from dataclasses import dataclass
20# What may stand in for `{base}`: a commit hash, or a ref made of the safe
21# subset of ref characters. `{base}` lands inside shell text — a claude
22# preset's template puts it in a double-quoted argument — and branch names may
23# legally contain `"`, `$`, backticks and `;`, so a permissive substitution
24# here would let whoever names a branch choose what runs in the sandbox.
25_SAFE_BASE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
28def substitute_base(template: str, *, base: str) -> str:
29 """`{base}` filled in with the change's base commit.
31 The one place that substitution happens, so what `{base}` means has a
32 single answer rather than one per caller — including the refusal below.
33 """
34 if not _SAFE_BASE.match(base):
35 raise ValueError(
36 f"base {base!r} is not a commit hash or plain ref name — refusing "
37 "to splice it into a shell command"
38 )
39 return template.replace("{base}", base)
42@dataclass(frozen=True)
43class Preset:
44 """One named reviewer command, with the base filled in later."""
46 # `{base}` is the base commit — the one fact about the change a command
47 # can't learn from inside the checkout, where HEAD is the change itself.
48 template: str
50 # What the template pins the reviewer to, said again as data. The command
51 # is ours, so what it asked for is a fact this table can state — the app
52 # records it on the run rather than reading a model back out of shell
53 # text, which would be a guess on the record. Spelled in each CLI's own
54 # vocabulary, the way every model and effort in this app is.
55 model: str
56 effort: str
58 def command(self, *, base: str) -> str:
59 """The command to run, with this base spelled in."""
60 return substitute_base(self.template, base=base)
63PRESETS: dict[str, Preset] = {
64 # The CLI's own review skill, pointed at the base. Their reviewer, not
65 # ours: the skill carries its own prompt.
66 # The range spelling matters: a bare sha reviews that commit itself;
67 # the review wanted is HEAD against the base.
68 # Model and effort are pinned so the record says what reviewed: an
69 # unpinned CLI picks its own default, which can change under a release
70 # or an account without a word in the config. The reviewer is the one
71 # stage that can produce a finding — the later stages only kill or
72 # weigh — so it gets the top-tier model. Effort is medium — the vendors' own default,
73 # and the level above it is documented (and measured on our bench) as
74 # widening into less-confident findings for more cost. The Claude alias
75 # tracks the latest in its family; Codex has no aliases, so its model is
76 # a literal someone has to bump.
77 # The budget cap fails the run at the CLI (exit 1), which reads as a
78 # FAILED review — the gate holds rather than scoring a partial read.
79 # Codex has no budget flag; its preset is bounded by the timeout alone.
80 "claude-code-review": Preset(
81 template=(
82 'claude -p "/code-review {base}..HEAD"'
83 " --model opus --effort medium --max-budget-usd 20"
84 ),
85 model="opus",
86 effort="medium",
87 ),
88 # Codex's own review task, run headlessly. Effort has no flag of its own
89 # on Codex; it is a config override. The sandbox bypass is the documented
90 # mode for externally-sandboxed environments — the hosted run IS the
91 # sandbox, and without it Codex's own sandbox fails to start inside one
92 # and the review reads nothing.
93 "codex-review": Preset(
94 template=(
95 "codex exec review --base {base}"
96 " -m gpt-5.6-sol -c model_reasoning_effort=medium"
97 " --dangerously-bypass-approvals-and-sandbox"
98 ),
99 model="gpt-5.6-sol",
100 effort="medium",
101 ),
102}
104PRESET_NAMES = tuple(PRESETS)
107def resolve_preset(name: str) -> Preset:
108 """The preset `name` stands for.
110 Raises ValueError naming the menu, because the caller is always either
111 validating a config someone just wrote or compiling one to run.
112 """
113 try:
114 return PRESETS[name]
115 except KeyError:
116 raise ValueError(
117 f"unknown preset '{name}'. Available presets: {', '.join(PRESET_NAMES)}."
118 ) from None