claude-dev-env 8.76.0 → 8.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/pr-lifecycle/SKILL.md +1 -1
- package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +11 -0
- package/hooks/blocking/merge_review_gate.py +303 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +2 -8
- package/hooks/blocking/reply_length_gate.py +20 -0
- package/hooks/blocking/test_merge_review_gate.py +115 -0
- package/hooks/blocking/test_reply_length_gate.py +11 -3
- package/hooks/features/conduct-gates.md +5 -1
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +72 -0
- package/hooks/hooks_constants/pre_tool_use_stdin.py +20 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +2 -0
- package/hooks/hooks_constants/test_pre_tool_use_stdin.py +17 -0
- package/hooks/hooks_constants/test_visual_reply_rules_constants.py +1 -0
- package/hooks/hooks_constants/visual_reply_rules_constants.py +10 -0
- package/hooks/test_context_budget_hooks.py +1 -0
- package/hooks/test_visual_reply_rules.py +45 -0
- package/hooks/visual_reply_rules.py +37 -0
- package/package.json +1 -1
- package/rules/visual-reply-rules.json +6 -1
- package/scripts/merge_review_receipt.py +84 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_merge_review_receipt.py +38 -0
|
@@ -298,7 +298,7 @@ Three things stay with the owner, and nothing else does:
|
|
|
298
298
|
- A change the owner said they want to read first.
|
|
299
299
|
- A repository whose branch rule requires an approving review the agent cannot give.
|
|
300
300
|
|
|
301
|
-
Where the branch rule requires zero approvals and one status check, that check is the gate, and the agent merges on its verdict.
|
|
301
|
+
Where the branch rule requires zero approvals and one status check, that check is the gate, and the agent merges on its verdict. Before any merge, the agent runs [the review loop](reference/review-loop-before-merge.md) until a pass on the head is clean.
|
|
302
302
|
|
|
303
303
|
Validation is the precondition, and green means every check reported on the exact head commit. A branch rule that requires one status check names the floor a merge needs; a pull request whose other checks are red or still running is held until they report.
|
|
304
304
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# The review loop runs before the merge
|
|
2
|
+
|
|
3
|
+
A pull request the agent made merges only after a code review of its head comes back clean. Loop it:
|
|
4
|
+
|
|
5
|
+
1. Review the head with the command `CLAUDE_MERGE_REVIEW_COMMAND` names. Where it is unset, run `/code-review high --max-findings all` against the base.
|
|
6
|
+
2. Record the pass: `python3 ~/.claude/scripts/merge_review_receipt.py <owner>/<name> <number> <head sha> <finding count>`.
|
|
7
|
+
3. Fix or answer every finding, push, and review the new head.
|
|
8
|
+
4. Repeat until a pass records `CLEAN`.
|
|
9
|
+
5. Merge pinned to that head: `expectedHeadSha` on the merge tool, or `--match-head-commit` on `gh pr merge`.
|
|
10
|
+
|
|
11
|
+
`hooks/blocking/merge_review_gate.py` denies any other merge and any auto-merge. A repository whose own CI and merge queue review and merge its pull requests is listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`, comma separated as `owner/name`, and skips the gate.
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Deny an agent's merge until a review of the merged head came back clean.
|
|
2
|
+
|
|
3
|
+
The agent that drives a pull request loops a code review on it, fixing and
|
|
4
|
+
pushing each finding, until one pass reports zero findings. The merge then
|
|
5
|
+
names that exact head::
|
|
6
|
+
|
|
7
|
+
merge, receipt says 0 findings at abc123, merge pinned to abc123 -> allow
|
|
8
|
+
merge, receipt at abc123, merge pinned to def456 -> deny, review again
|
|
9
|
+
merge, receipt at abc123, merge names no head -> deny, pin it
|
|
10
|
+
merge, no receipt or the receipt counts findings -> deny, run the loop
|
|
11
|
+
auto-merge -> deny, merge pinned instead
|
|
12
|
+
repository listed in CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES -> allow
|
|
13
|
+
|
|
14
|
+
The deny reason names the review command CLAUDE_MERGE_REVIEW_COMMAND sets,
|
|
15
|
+
or ``/code-review high --max-findings all`` when it is unset.
|
|
16
|
+
|
|
17
|
+
``scripts/merge_review_receipt.py`` writes the receipt this reads.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
29
|
+
if hooks_directory not in sys.path:
|
|
30
|
+
sys.path.insert(0, hooks_directory)
|
|
31
|
+
|
|
32
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
33
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
34
|
+
PERMISSION_DECISION_KEY,
|
|
35
|
+
)
|
|
36
|
+
from hooks_constants.merge_review_gate_constants import (
|
|
37
|
+
ALL_RECEIPT_DIRECTORY_SEGMENTS,
|
|
38
|
+
ALL_SHELL_TOOL_NAMES,
|
|
39
|
+
AUTO_MERGE_REASON_TEMPLATE,
|
|
40
|
+
AUTO_MERGE_TOOL_SUFFIX,
|
|
41
|
+
API_SHA_FIELD_PATTERN,
|
|
42
|
+
AUTO_OPTION_PATTERN,
|
|
43
|
+
DEFAULT_REVIEW_COMMAND,
|
|
44
|
+
DENY_DECISION,
|
|
45
|
+
EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE,
|
|
46
|
+
EXPECTED_HEAD_FIELD,
|
|
47
|
+
GH_API_MERGE_PATTERN,
|
|
48
|
+
GH_PR_MERGE_PATTERN,
|
|
49
|
+
HOOK_EVENT_NAME,
|
|
50
|
+
MATCH_HEAD_PATTERN,
|
|
51
|
+
MERGE_TOOL_SUFFIX,
|
|
52
|
+
NO_CLEAN_REVIEW_REASON_TEMPLATE,
|
|
53
|
+
NUMBER_FIELD,
|
|
54
|
+
OPTION_WITH_VALUE_PATTERN,
|
|
55
|
+
OWNER_FIELD,
|
|
56
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
57
|
+
PR_NUMBER_PATTERN,
|
|
58
|
+
PR_URL_PATTERN,
|
|
59
|
+
RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
60
|
+
RECEIPT_FILE_NAME_TEMPLATE,
|
|
61
|
+
RECEIPT_FINDINGS_KEY,
|
|
62
|
+
RECEIPT_HEAD_KEY,
|
|
63
|
+
REPO_FIELD,
|
|
64
|
+
REPO_OPTION_PATTERN,
|
|
65
|
+
REPOSITORY_SEPARATOR,
|
|
66
|
+
REVIEW_COMMAND_ENVIRONMENT_VARIABLE,
|
|
67
|
+
STALE_REVIEW_REASON_TEMPLATE,
|
|
68
|
+
UNKNOWN_TARGET_REASON,
|
|
69
|
+
UNPINNED_MERGE_REASON_TEMPLATE,
|
|
70
|
+
)
|
|
71
|
+
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class MergeRequest:
|
|
76
|
+
"""One merge a tool call asks for.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
owner: Repository owner, or empty when the call names none.
|
|
80
|
+
repo: Repository name, or empty when the call names none.
|
|
81
|
+
number: Pull request number, or None when the call names none.
|
|
82
|
+
pinned_head: The head the merge is pinned to, or None.
|
|
83
|
+
is_auto_merge: Whether the call turns on auto-merge.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
owner: str
|
|
87
|
+
repo: str
|
|
88
|
+
number: int | None
|
|
89
|
+
pinned_head: str | None
|
|
90
|
+
is_auto_merge: bool
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def slug(self) -> str:
|
|
94
|
+
"""Return ``owner/repo``."""
|
|
95
|
+
return f"{self.owner}/{self.repo}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def receipt_directory() -> Path:
|
|
99
|
+
"""Return the folder that holds one review receipt per pull request.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The folder CLAUDE_MERGE_REVIEW_RECEIPT_DIR names, else ``~/.claude/merge-reviews``.
|
|
103
|
+
"""
|
|
104
|
+
override = os.environ.get(RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
105
|
+
if override:
|
|
106
|
+
return Path(override)
|
|
107
|
+
return Path.home().joinpath(*ALL_RECEIPT_DIRECTORY_SEGMENTS)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def receipt_path(owner: str, repo: str, number: int) -> Path:
|
|
111
|
+
"""Return where the review receipt for one pull request lives.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
owner: Repository owner.
|
|
115
|
+
repo: Repository name.
|
|
116
|
+
number: Pull request number.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The receipt file path.
|
|
120
|
+
"""
|
|
121
|
+
return receipt_directory() / RECEIPT_FILE_NAME_TEMPLATE.format(
|
|
122
|
+
owner=owner.casefold(), repo=repo.casefold(), number=number
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def merge_request_from(all_payload_fields: dict[str, object]) -> MergeRequest | None:
|
|
127
|
+
"""Read the merge a PreToolUse payload asks for.
|
|
128
|
+
|
|
129
|
+
::
|
|
130
|
+
|
|
131
|
+
mcp__github__merge_pull_request {owner, repo, pullNumber, expectedHeadSha}
|
|
132
|
+
mcp__github__enable_pr_auto_merge {owner, repo, pullNumber}
|
|
133
|
+
gh pr merge 12 --repo o/r --match-head-commit abc123
|
|
134
|
+
gh api repos/o/r/pulls/12/merge -X PUT
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The merge request, or None when the call merges nothing.
|
|
141
|
+
"""
|
|
142
|
+
tool_name = all_payload_fields.get("tool_name")
|
|
143
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
144
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
145
|
+
return None
|
|
146
|
+
if tool_name.endswith((MERGE_TOOL_SUFFIX, AUTO_MERGE_TOOL_SUFFIX)):
|
|
147
|
+
return _tool_merge_request(tool_name, tool_input)
|
|
148
|
+
command = tool_input.get("command")
|
|
149
|
+
if tool_name not in ALL_SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
150
|
+
return None
|
|
151
|
+
return _shell_merge_request(command)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tool_merge_request(tool_name: str, all_tool_input: dict[object, object]) -> MergeRequest:
|
|
155
|
+
number = all_tool_input.get(NUMBER_FIELD)
|
|
156
|
+
pinned_head = all_tool_input.get(EXPECTED_HEAD_FIELD)
|
|
157
|
+
return MergeRequest(
|
|
158
|
+
owner=str(all_tool_input.get(OWNER_FIELD, "")),
|
|
159
|
+
repo=str(all_tool_input.get(REPO_FIELD, "")),
|
|
160
|
+
number=int(number) if isinstance(number, (int, float)) else None,
|
|
161
|
+
pinned_head=pinned_head if isinstance(pinned_head, str) and pinned_head else None,
|
|
162
|
+
is_auto_merge=tool_name.endswith(AUTO_MERGE_TOOL_SUFFIX),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _shell_merge_request(command: str) -> MergeRequest | None:
|
|
167
|
+
api_match = GH_API_MERGE_PATTERN.search(command)
|
|
168
|
+
if api_match:
|
|
169
|
+
return MergeRequest(
|
|
170
|
+
owner=api_match["owner"],
|
|
171
|
+
repo=api_match["repo"],
|
|
172
|
+
number=int(api_match["number"]),
|
|
173
|
+
pinned_head=_api_pinned_head(command),
|
|
174
|
+
is_auto_merge=False,
|
|
175
|
+
)
|
|
176
|
+
merge_match = GH_PR_MERGE_PATTERN.search(command)
|
|
177
|
+
if not merge_match:
|
|
178
|
+
return None
|
|
179
|
+
rest = merge_match["rest"]
|
|
180
|
+
owner, repo, number = _gh_pr_merge_target(rest)
|
|
181
|
+
head_match = MATCH_HEAD_PATTERN.search(rest)
|
|
182
|
+
return MergeRequest(
|
|
183
|
+
owner=owner,
|
|
184
|
+
repo=repo,
|
|
185
|
+
number=number,
|
|
186
|
+
pinned_head=head_match["sha"] if head_match else None,
|
|
187
|
+
is_auto_merge=bool(AUTO_OPTION_PATTERN.search(rest)),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _gh_pr_merge_target(arguments: str) -> tuple[str, str, int | None]:
|
|
192
|
+
url_match = PR_URL_PATTERN.search(arguments)
|
|
193
|
+
if url_match:
|
|
194
|
+
return url_match["owner"], url_match["repo"], int(url_match["number"])
|
|
195
|
+
repo_match = REPO_OPTION_PATTERN.search(arguments)
|
|
196
|
+
number_match = PR_NUMBER_PATTERN.search(_without_option_values(arguments))
|
|
197
|
+
return (
|
|
198
|
+
repo_match["owner"] if repo_match else "",
|
|
199
|
+
repo_match["repo"] if repo_match else "",
|
|
200
|
+
int(number_match["number"]) if number_match else None,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _api_pinned_head(command: str) -> str | None:
|
|
205
|
+
sha_match = API_SHA_FIELD_PATTERN.search(command)
|
|
206
|
+
return sha_match["sha"] if sha_match else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _without_option_values(arguments: str) -> str:
|
|
210
|
+
return OPTION_WITH_VALUE_PATTERN.sub(" ", arguments)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_exempt(request: MergeRequest) -> bool:
|
|
214
|
+
listed = os.environ.get(EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE, "")
|
|
215
|
+
all_exempt = {
|
|
216
|
+
each.strip().casefold() for each in listed.split(REPOSITORY_SEPARATOR) if each.strip()
|
|
217
|
+
}
|
|
218
|
+
return request.slug.casefold() in all_exempt
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _clean_head(request: MergeRequest) -> str | None:
|
|
222
|
+
if request.number is None:
|
|
223
|
+
return None
|
|
224
|
+
try:
|
|
225
|
+
receipt = json.loads(
|
|
226
|
+
receipt_path(request.owner, request.repo, request.number).read_text(encoding="utf-8")
|
|
227
|
+
)
|
|
228
|
+
except (OSError, ValueError):
|
|
229
|
+
return None
|
|
230
|
+
if not isinstance(receipt, dict) or receipt.get(RECEIPT_FINDINGS_KEY) != 0:
|
|
231
|
+
return None
|
|
232
|
+
head = receipt.get(RECEIPT_HEAD_KEY)
|
|
233
|
+
return head if isinstance(head, str) and head else None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _review_command() -> str:
|
|
237
|
+
return os.environ.get(REVIEW_COMMAND_ENVIRONMENT_VARIABLE) or DEFAULT_REVIEW_COMMAND
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _same_commit(first: str, second: str) -> bool:
|
|
241
|
+
shorter, longer = sorted((first.casefold(), second.casefold()), key=len)
|
|
242
|
+
return longer.startswith(shorter)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def deny_reason(request: MergeRequest) -> str | None:
|
|
246
|
+
"""Return why a merge must wait for a clean review, or None to allow it.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
request: The merge the tool call asks for.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The deny reason, or None when the merge may run.
|
|
253
|
+
"""
|
|
254
|
+
if not request.owner or not request.repo or request.number is None:
|
|
255
|
+
return UNKNOWN_TARGET_REASON
|
|
256
|
+
if _is_exempt(request):
|
|
257
|
+
return None
|
|
258
|
+
fields = {"slug": request.slug, "number": request.number}
|
|
259
|
+
if request.is_auto_merge:
|
|
260
|
+
return AUTO_MERGE_REASON_TEMPLATE.format(**fields)
|
|
261
|
+
clean_head = _clean_head(request)
|
|
262
|
+
if clean_head is None:
|
|
263
|
+
return NO_CLEAN_REVIEW_REASON_TEMPLATE.format(review_command=_review_command(), **fields)
|
|
264
|
+
if request.pinned_head is None:
|
|
265
|
+
return UNPINNED_MERGE_REASON_TEMPLATE.format(head=clean_head, **fields)
|
|
266
|
+
if not _same_commit(clean_head, request.pinned_head):
|
|
267
|
+
return STALE_REVIEW_REASON_TEMPLATE.format(
|
|
268
|
+
head=clean_head, pinned=request.pinned_head, **fields
|
|
269
|
+
)
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
274
|
+
"""Return a deny decision for a merge with no clean review, else None.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
The hook output that denies the call, or None to allow it.
|
|
281
|
+
"""
|
|
282
|
+
request = merge_request_from(all_payload_fields)
|
|
283
|
+
if request is None:
|
|
284
|
+
return None
|
|
285
|
+
reason = deny_reason(request)
|
|
286
|
+
if reason is None:
|
|
287
|
+
return None
|
|
288
|
+
return {
|
|
289
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
290
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
291
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
292
|
+
PERMISSION_DECISION_REASON_KEY: reason,
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main() -> int:
|
|
298
|
+
"""Read one payload, print a deny when needed, and exit zero."""
|
|
299
|
+
return print_decision_from_stdin(decision_for)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
sys.exit(main())
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
import re
|
|
7
6
|
import shlex
|
|
8
7
|
import sys
|
|
@@ -54,7 +53,7 @@ from hooks_constants.hook_specific_output_keys import (
|
|
|
54
53
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
55
54
|
PERMISSION_DECISION_KEY,
|
|
56
55
|
)
|
|
57
|
-
from hooks_constants.pre_tool_use_stdin import
|
|
56
|
+
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
58
57
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
59
58
|
from blocking.followup_pr_dedupe import create_tool_fields, duplicate_followup_reason
|
|
60
59
|
from blocking.pull_request_proof import (
|
|
@@ -298,12 +297,7 @@ def _deny(reason: str) -> dict[str, object]:
|
|
|
298
297
|
|
|
299
298
|
def main() -> int:
|
|
300
299
|
"""Read one payload, print a deny when needed, and exit zero."""
|
|
301
|
-
|
|
302
|
-
if payload is not None:
|
|
303
|
-
decision = decision_for(payload)
|
|
304
|
-
if decision is not None:
|
|
305
|
-
sys.stdout.write(json.dumps(decision) + "\n")
|
|
306
|
-
return 0
|
|
300
|
+
return print_decision_from_stdin(decision_for)
|
|
307
301
|
|
|
308
302
|
|
|
309
303
|
if __name__ == "__main__":
|
|
@@ -57,6 +57,7 @@ later pass, so this gate denies it and the model resends a shorter one.
|
|
|
57
57
|
|
|
58
58
|
from __future__ import annotations
|
|
59
59
|
|
|
60
|
+
import json
|
|
60
61
|
import os
|
|
61
62
|
import re
|
|
62
63
|
import sys
|
|
@@ -75,9 +76,14 @@ from visual_reply_rules import (
|
|
|
75
76
|
second_reply_violation,
|
|
76
77
|
visual_violation,
|
|
77
78
|
widget_anchor_violation,
|
|
79
|
+
widget_flowchart_advice,
|
|
78
80
|
)
|
|
79
81
|
from hooks_constants.hook_block_logger import log_hook_block
|
|
80
82
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
83
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
84
|
+
HOOK_EVENT_NAME_KEY,
|
|
85
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
86
|
+
)
|
|
81
87
|
from hooks_constants.reply_length_gate_constants import (
|
|
82
88
|
ALL_CHECKED_TOOL_NAMES,
|
|
83
89
|
ALL_DECISION_CARD_PROSE_KEYS,
|
|
@@ -101,6 +107,7 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
101
107
|
EM_DASH_MESSAGE,
|
|
102
108
|
EM_DASH_PATTERN,
|
|
103
109
|
FENCED_BLOCK_PATTERN,
|
|
110
|
+
ADDITIONAL_CONTEXT_KEY,
|
|
104
111
|
HOOK_EVENT_NAME,
|
|
105
112
|
INLINE_CODE_PATTERN,
|
|
106
113
|
LINE_BREAK_PATTERN,
|
|
@@ -322,6 +329,18 @@ def tool_violation(
|
|
|
322
329
|
return None if violation is None else (violation, reply_text)
|
|
323
330
|
|
|
324
331
|
|
|
332
|
+
def write_flowchart_advice(tool_name: object, all_tool_input: dict[str, object]) -> None:
|
|
333
|
+
"""Print the flowchart advice for an allowed widget call that is not a flowchart."""
|
|
334
|
+
mode_rules = active_mode_rules()
|
|
335
|
+
if not isinstance(tool_name, str) or mode_rules is None:
|
|
336
|
+
return
|
|
337
|
+
advice = widget_flowchart_advice(tool_name, all_tool_input, mode_rules)
|
|
338
|
+
if advice is None:
|
|
339
|
+
return
|
|
340
|
+
hook_event_output = {HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME, ADDITIONAL_CONTEXT_KEY: advice}
|
|
341
|
+
sys.stdout.write(json.dumps({HOOK_SPECIFIC_OUTPUT_KEY: hook_event_output}))
|
|
342
|
+
|
|
343
|
+
|
|
325
344
|
def main() -> int:
|
|
326
345
|
hook_input = read_hook_input_dictionary_from_stdin()
|
|
327
346
|
if hook_input is None:
|
|
@@ -332,6 +351,7 @@ def main() -> int:
|
|
|
332
351
|
return ALLOW_EXIT_CODE
|
|
333
352
|
checked = tool_violation(tool_name, tool_input, hook_input.get(TRANSCRIPT_PATH_KEY))
|
|
334
353
|
if checked is None:
|
|
354
|
+
write_flowchart_advice(tool_name, tool_input)
|
|
335
355
|
return ALLOW_EXIT_CODE
|
|
336
356
|
violation, reply_text = checked
|
|
337
357
|
block_reason = violation + RETRY_INSTRUCTION
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Behavior tests for the clean-review merge gate."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
HOOKS_DIRECTORY = Path(__file__).resolve().parent.parent
|
|
9
|
+
SCRIPTS_DIRECTORY = HOOKS_DIRECTORY.parent / "scripts"
|
|
10
|
+
for each_directory in (HOOKS_DIRECTORY, SCRIPTS_DIRECTORY):
|
|
11
|
+
if str(each_directory) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(each_directory))
|
|
13
|
+
|
|
14
|
+
import merge_review_receipt
|
|
15
|
+
from blocking import merge_review_gate as gate
|
|
16
|
+
|
|
17
|
+
CLEAN_HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
18
|
+
NEW_HEAD = "9999999aaaaaaa9999999aaaaaaa9999999aaaaa"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def receipt_folder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
23
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_RECEIPT_DIR", str(tmp_path))
|
|
24
|
+
monkeypatch.delenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", raising=False)
|
|
25
|
+
monkeypatch.delenv("CLAUDE_MERGE_REVIEW_COMMAND", raising=False)
|
|
26
|
+
return tmp_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _merge_tool(head: str | None = None) -> dict[str, object]:
|
|
30
|
+
tool_input: dict[str, object] = {"owner": "acme", "repo": "tools", "pullNumber": 12}
|
|
31
|
+
if head is not None:
|
|
32
|
+
tool_input["expectedHeadSha"] = head
|
|
33
|
+
return {"tool_name": "mcp__github__merge_pull_request", "tool_input": tool_input}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bash(command: str) -> dict[str, object]:
|
|
37
|
+
return {"tool_name": "Bash", "tool_input": {"command": command}}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _reason(payload: dict[str, object]) -> str | None:
|
|
41
|
+
decision = gate.decision_for(payload)
|
|
42
|
+
if decision is None:
|
|
43
|
+
return None
|
|
44
|
+
return decision["hookSpecificOutput"]["permissionDecisionReason"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_should_deny_a_merge_with_no_review() -> None:
|
|
48
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_should_deny_a_merge_whose_last_pass_found_findings() -> None:
|
|
52
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2)
|
|
53
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_should_allow_a_merge_pinned_to_the_clean_head() -> None:
|
|
57
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
58
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_should_deny_a_merge_pinned_to_a_head_pushed_after_the_review() -> None:
|
|
62
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
63
|
+
assert "Review the new head" in _reason(_merge_tool(NEW_HEAD))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_should_deny_an_unpinned_merge_after_a_clean_review() -> None:
|
|
67
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
68
|
+
assert "names no head" in _reason(_merge_tool())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_should_deny_auto_merge_even_after_a_clean_review() -> None:
|
|
72
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
73
|
+
payload = {
|
|
74
|
+
"tool_name": "mcp__github__enable_pr_auto_merge",
|
|
75
|
+
"tool_input": {"owner": "acme", "repo": "tools", "pullNumber": 12},
|
|
76
|
+
}
|
|
77
|
+
assert "Auto-merge" in _reason(payload)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_should_allow_any_merge_in_an_exempt_repository(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
81
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", "other/x, ACME/Tools")
|
|
82
|
+
assert _reason(_merge_tool()) is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_should_read_a_gh_pr_merge_pinned_with_a_short_head() -> None:
|
|
86
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
87
|
+
command = f"gh pr merge --squash 12 --repo acme/tools --match-head-commit {CLEAN_HEAD[:7]}"
|
|
88
|
+
assert _reason(_bash(command)) is None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_should_deny_gh_pr_merge_auto() -> None:
|
|
92
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
93
|
+
assert "Auto-merge" in _reason(
|
|
94
|
+
_bash("gh pr merge https://github.com/acme/tools/pull/12 --auto")
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_should_deny_gh_pr_merge_with_no_repository() -> None:
|
|
99
|
+
assert "names no repository" in _reason(_bash("gh pr merge 12 --squash"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_should_read_a_gh_api_merge_with_a_sha_field() -> None:
|
|
103
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
104
|
+
command = f"gh api -X PUT repos/acme/tools/pulls/12/merge -f sha={CLEAN_HEAD}"
|
|
105
|
+
assert _reason(_bash(command)) is None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_should_ignore_commands_that_merge_nothing() -> None:
|
|
109
|
+
assert gate.decision_for(_bash("gh pr view 12 --repo acme/tools")) is None
|
|
110
|
+
assert gate.decision_for(_bash("git merge origin/main")) is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_should_name_the_configured_review_command(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
114
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_COMMAND", "/team-review")
|
|
115
|
+
assert "`/team-review`" in _reason(_merge_tool(CLEAN_HEAD))
|
|
@@ -635,13 +635,21 @@ def test_should_deny_an_anchor_link_inside_widget_code(
|
|
|
635
635
|
assert "does not open in the Claude app" in stderr_text
|
|
636
636
|
|
|
637
637
|
|
|
638
|
-
def
|
|
638
|
+
def test_should_allow_widget_code_with_no_anchor_and_advise_a_flowchart(
|
|
639
639
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
640
640
|
) -> None:
|
|
641
|
-
|
|
642
|
-
|
|
641
|
+
hook_input = {
|
|
642
|
+
"hook_event_name": "PreToolUse",
|
|
643
|
+
"tool_name": WIDGET_TOOL_NAME,
|
|
644
|
+
"tool_input": {"widget_code": "<div>Three steps</div>"},
|
|
645
|
+
}
|
|
646
|
+
monkeypatch.setattr(
|
|
647
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
643
648
|
)
|
|
649
|
+
exit_code = reply_length_gate.main()
|
|
644
650
|
assert exit_code == 0
|
|
651
|
+
advice_output = json.loads(capsys.readouterr().out)
|
|
652
|
+
assert "Redraw this widget as one SVG flowchart" in advice_output["hookSpecificOutput"]["additionalContext"]
|
|
645
653
|
|
|
646
654
|
|
|
647
655
|
def test_should_skip_the_mode_checks_when_the_switch_turns_the_mode_off(
|
|
@@ -11,6 +11,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
11
11
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
12
12
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
13
13
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
14
|
+
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge to be pinned to that head, denies auto-merge, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
|
|
14
15
|
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
15
16
|
|
|
16
17
|
## When it fires
|
|
@@ -22,6 +23,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
22
23
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
23
24
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
24
25
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
26
|
+
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
25
27
|
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
26
28
|
|
|
27
29
|
## Proving it
|
|
@@ -30,13 +32,14 @@ Preconditions:
|
|
|
30
32
|
|
|
31
33
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
32
34
|
|
|
33
|
-
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason.
|
|
35
|
+
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
|
|
34
36
|
- **Edit marker.** Input is an update message with strikethrough or an edit note. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_edit_marker_gate.py -q`. The adjacent test observes a denial; clean replacement text passes.
|
|
35
37
|
- **Handoff close.** Input is an issue comment that says the issue is closed and that the found defect belongs to another issue. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_issue_close_handoff_gate.py -q`. The adjacent test observes a denial; a close with a plain cross-reference and a routing comment on an open issue both pass.
|
|
36
38
|
- **Session title.** Input is a set session title call with no leading emoji or a name over 25 characters. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_session_title_format_gate.py -q`. The adjacent test observes a denial that names the broken rule; a well-formed title passes.
|
|
37
39
|
- **Step note.** Input is a tool call after a transcript message without a status line while the flag is on. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_step_note_gate.py -q`. The adjacent test observes a block until a status line appears.
|
|
38
40
|
- **Checked claim.** Input is a Write after hedged reasoning in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_acting.py -q`. The adjacent test observes a block and a logged blocked outcome.
|
|
39
41
|
- **Lifecycle skill.** Input is a `git commit` or `gh pr create` command with no `pr-lifecycle` invocation in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_pr_lifecycle_skill_gate.py -q`. The adjacent test observes a denial, then a silent allow once the skill or its slash command appears.
|
|
42
|
+
- **Clean review.** Input is a merge with no receipt, a receipt with findings, a merge pinned to a newer head, an unpinned merge, or auto-merge. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_review_gate.py -q`. The adjacent test observes a denial for each; a merge pinned to the clean head and a merge in an exempt repository pass.
|
|
40
43
|
- **Dark mode page.** Input is an Artifact publish of a page that themes `html` but leaves `body` to the publish wrapper. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_artifact_dark_mode_gate.py -q`. The adjacent test observes a denial that names the text and its contrast; a page that themes `body` passes. The render tests skip on a host with no Node Playwright and Chromium.
|
|
41
44
|
|
|
42
45
|
## Gotchas
|
|
@@ -48,3 +51,4 @@ Preconditions:
|
|
|
48
51
|
- The handoff close gate reads the `body` and `comment` fields of issue tools only. A `gh issue close --comment` command in Bash and a pull request body that closes the issue with `Closes #N` do not reach it.
|
|
49
52
|
- The lifecycle gate counts only invocations after the last compaction, so a compacted session invokes `pr-lifecycle` again. A missing or unreadable transcript allows the call.
|
|
50
53
|
- The dark mode gate denies a page publish when Node, Playwright or Chromium is missing, and its reason names the install command. It checks both dark signals, `prefers-color-scheme: dark` and `data-theme="dark"` on the root element.
|
|
54
|
+
- The clean-review gate trusts the receipt. A receipt the agent writes by hand with zero findings passes, so the pr-lifecycle skill names the review loop that writes it. A `gh pr merge` that names no repository is denied, because the gate cannot tell which repository the current branch belongs to.
|
package/hooks/hooks.json
CHANGED
|
@@ -102,6 +102,16 @@
|
|
|
102
102
|
}
|
|
103
103
|
]
|
|
104
104
|
},
|
|
105
|
+
{
|
|
106
|
+
"matcher": "Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)",
|
|
107
|
+
"hooks": [
|
|
108
|
+
{
|
|
109
|
+
"type": "command",
|
|
110
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/merge_review_gate.py",
|
|
111
|
+
"timeout": 10
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
},
|
|
105
115
|
{
|
|
106
116
|
"matcher": "Read",
|
|
107
117
|
"hooks": [
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Named constants for merge_review_gate, the clean-review merge gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
HOOK_EVENT_NAME = "PreToolUse"
|
|
8
|
+
DENY_DECISION = "deny"
|
|
9
|
+
PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
10
|
+
|
|
11
|
+
MERGE_TOOL_SUFFIX = "__merge_pull_request"
|
|
12
|
+
AUTO_MERGE_TOOL_SUFFIX = "__enable_pr_auto_merge"
|
|
13
|
+
ALL_SHELL_TOOL_NAMES = frozenset({"Bash", "PowerShell"})
|
|
14
|
+
|
|
15
|
+
OWNER_FIELD = "owner"
|
|
16
|
+
REPO_FIELD = "repo"
|
|
17
|
+
NUMBER_FIELD = "pullNumber"
|
|
18
|
+
EXPECTED_HEAD_FIELD = "expectedHeadSha"
|
|
19
|
+
|
|
20
|
+
GH_PR_MERGE_PATTERN = re.compile(r"(?:^|[\s;&|(])gh(?:\.exe)?\s+pr\s+merge\b(?P<rest>[^;&|]*)")
|
|
21
|
+
GH_API_MERGE_PATTERN = re.compile(
|
|
22
|
+
r"(?:^|[\s;&|(])gh(?:\.exe)?\s+api\b[^;&|]*?repos/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)/pulls/(?P<number>\d+)/merge\b"
|
|
23
|
+
)
|
|
24
|
+
PR_NUMBER_PATTERN = re.compile(r"(?:^|\s)#?(?P<number>\d+)(?:\s|$)")
|
|
25
|
+
PR_URL_PATTERN = re.compile(
|
|
26
|
+
r"github\.com/(?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)/pull/(?P<number>\d+)"
|
|
27
|
+
)
|
|
28
|
+
REPO_OPTION_PATTERN = re.compile(r"(?:-R|--repo)[\s=](?P<owner>[\w.-]+)/(?P<repo>[\w.-]+)")
|
|
29
|
+
MATCH_HEAD_PATTERN = re.compile(r"--match-head-commit[\s=](?P<sha>[0-9a-fA-F]{7,40})")
|
|
30
|
+
API_SHA_FIELD_PATTERN = re.compile(r"(?:-f|-F|--field|--raw-field)[\s=]sha=(?P<sha>[0-9a-fA-F]{7,40})")
|
|
31
|
+
OPTION_WITH_VALUE_PATTERN = re.compile(
|
|
32
|
+
r"(?<!\S)(?:-R|--repo|--match-head-commit|-t|--subject|-b|--body|-F|--body-file|-A|--author-email)"
|
|
33
|
+
r"(?:=|\s+)(?:\"[^\"]*\"|'[^']*'|\S+)"
|
|
34
|
+
)
|
|
35
|
+
AUTO_OPTION_PATTERN = re.compile(r"(?:^|\s)--auto(?:\s|$)")
|
|
36
|
+
|
|
37
|
+
EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES"
|
|
38
|
+
REVIEW_COMMAND_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_COMMAND"
|
|
39
|
+
DEFAULT_REVIEW_COMMAND = "/code-review high --max-findings all"
|
|
40
|
+
RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_RECEIPT_DIR"
|
|
41
|
+
ALL_RECEIPT_DIRECTORY_SEGMENTS = (".claude", "merge-reviews")
|
|
42
|
+
RECEIPT_FILE_NAME_TEMPLATE = "{owner}__{repo}__{number}.json"
|
|
43
|
+
REPOSITORY_SEPARATOR = ","
|
|
44
|
+
RECEIPT_HEAD_KEY = "head"
|
|
45
|
+
RECEIPT_FINDINGS_KEY = "findings"
|
|
46
|
+
RECORD_COMMAND = "python3 ~/.claude/scripts/merge_review_receipt.py"
|
|
47
|
+
|
|
48
|
+
NO_CLEAN_REVIEW_REASON_TEMPLATE = (
|
|
49
|
+
"{slug}#{number} has no clean review on the head you are merging. Run the review "
|
|
50
|
+
"loop: review the pull request head with `{review_command}`, fix and push every "
|
|
51
|
+
"finding, and review again "
|
|
52
|
+
"until a pass reports zero findings. Record that pass with `"
|
|
53
|
+
+ RECORD_COMMAND
|
|
54
|
+
+ " {slug} {number} <head sha> 0`, then merge pinned to that head."
|
|
55
|
+
)
|
|
56
|
+
UNPINNED_MERGE_REASON_TEMPLATE = (
|
|
57
|
+
"{slug}#{number} has a clean review for {head}, but this merge names no head. "
|
|
58
|
+
"Pass expectedHeadSha (merge tool) or --match-head-commit (gh pr merge) set to "
|
|
59
|
+
"{head}, so a push after the review cannot ride the merge."
|
|
60
|
+
)
|
|
61
|
+
STALE_REVIEW_REASON_TEMPLATE = (
|
|
62
|
+
"{slug}#{number} was reviewed clean at {head}, and this merge names {pinned}. "
|
|
63
|
+
"Review the new head until a pass reports zero findings, record it, then merge."
|
|
64
|
+
)
|
|
65
|
+
AUTO_MERGE_REASON_TEMPLATE = (
|
|
66
|
+
"Auto-merge on {slug}#{number} merges a future head no review has seen. Run the "
|
|
67
|
+
"review loop until it is clean, then merge directly, pinned to the reviewed head."
|
|
68
|
+
)
|
|
69
|
+
UNKNOWN_TARGET_REASON = (
|
|
70
|
+
"This merge names no repository and pull request number the review gate can read. "
|
|
71
|
+
"Name both, such as `gh pr merge 12 --repo owner/name --match-head-commit <sha>`."
|
|
72
|
+
)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
|
|
8
9
|
from hooks_constants.setup_project_paths_constants import (
|
|
9
10
|
DECODE_ERRORS_POLICY,
|
|
@@ -46,3 +47,22 @@ def read_hook_input_dictionary_from_stdin() -> dict[str, object] | None:
|
|
|
46
47
|
if not isinstance(parsed_payload, dict):
|
|
47
48
|
return None
|
|
48
49
|
return parsed_payload
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_decision_from_stdin(
|
|
53
|
+
decision_for: Callable[[dict[str, object]], dict[str, object] | None],
|
|
54
|
+
) -> int:
|
|
55
|
+
"""Read one payload, print the decision the gate returns, and return zero.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
decision_for: The gate's function from a payload to its hook output, or None.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Zero, so the hook never fails the tool call on its own.
|
|
62
|
+
"""
|
|
63
|
+
payload = read_hook_input_dictionary_from_stdin()
|
|
64
|
+
if payload is not None:
|
|
65
|
+
decision = decision_for(payload)
|
|
66
|
+
if decision is not None:
|
|
67
|
+
sys.stdout.write(json.dumps(decision) + "\n")
|
|
68
|
+
return 0
|
|
@@ -78,3 +78,20 @@ def test_read_returns_none_when_buffer_and_text_read_raise_attribute_error() ->
|
|
|
78
78
|
|
|
79
79
|
with patch("sys.stdin", BrokenStandardInput()):
|
|
80
80
|
assert read_hook_input_dictionary_from_stdin() is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_print_decision_from_stdin_writes_the_gate_output() -> None:
|
|
84
|
+
output = io.StringIO()
|
|
85
|
+
with patch("sys.stdin", io.StringIO('{"tool_name": "Bash"}')), patch("sys.stdout", output):
|
|
86
|
+
exit_code = pre_tool_use_stdin.print_decision_from_stdin(
|
|
87
|
+
lambda payload: {"seen": payload["tool_name"]}
|
|
88
|
+
)
|
|
89
|
+
assert exit_code == 0
|
|
90
|
+
assert json.loads(output.getvalue()) == {"seen": "Bash"}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_print_decision_from_stdin_writes_nothing_on_allow() -> None:
|
|
94
|
+
output = io.StringIO()
|
|
95
|
+
with patch("sys.stdin", io.StringIO('{"tool_name": "Bash"}')), patch("sys.stdout", output):
|
|
96
|
+
assert pre_tool_use_stdin.print_decision_from_stdin(lambda payload: None) == 0
|
|
97
|
+
assert output.getvalue() == ""
|
|
@@ -26,6 +26,7 @@ def test_rule_key_constants_name_rules_in_the_shipped_rule_list() -> None:
|
|
|
26
26
|
constants.NO_PICTURE_RULE_KEY,
|
|
27
27
|
constants.ABBREVIATIONS_RULE_KEY,
|
|
28
28
|
constants.ONE_ITEM_RULE_KEY,
|
|
29
|
+
constants.FLOWCHART_RULE_KEY,
|
|
29
30
|
} <= all_shipped_keys
|
|
30
31
|
assert constants.ALLOWED_CAPITALIZED_WORDS_KEY in rules_document
|
|
31
32
|
assert constants.LOWERCASE_ABBREVIATIONS_KEY in rules_document
|
|
@@ -36,6 +36,15 @@ TRACKER_NUMBER_PATTERN = re.compile(
|
|
|
36
36
|
r"\b(?:issues?|items?|cards?|tickets?|tasks?)\s*#?\d+", re.IGNORECASE
|
|
37
37
|
)
|
|
38
38
|
WIDGET_ANCHOR_PATTERN = re.compile(r"<a\b[^>]*\bhref\s*=", re.IGNORECASE)
|
|
39
|
+
FLOWCHART_RULE_KEY = "not_flowchart"
|
|
40
|
+
WIDGET_CODE_KEY = "widget_code"
|
|
41
|
+
WIDGET_INPUT_KEY = "input"
|
|
42
|
+
FLOWCHART_SVG_PATTERN = re.compile(r"<svg\b", re.IGNORECASE)
|
|
43
|
+
FLOWCHART_ARROW_PATTERN = re.compile(r"marker-end", re.IGNORECASE)
|
|
44
|
+
FLOWCHART_MARKER_BLOCK_PATTERN = re.compile(r"<marker\b[^>]*(?<!/)>.*?</marker>", re.IGNORECASE | re.DOTALL)
|
|
45
|
+
FLOWCHART_DIAMOND_PATTERN = re.compile(
|
|
46
|
+
r"<polygon\b|<path\b[^>]*\bd=[\"']M[^\"']*L[^\"']*L[^\"']*L[^\"']*Z[\"']", re.IGNORECASE
|
|
47
|
+
)
|
|
39
48
|
|
|
40
49
|
TRANSCRIPT_ENCODING = "utf-8"
|
|
41
50
|
ENTRY_TYPE_KEY = "type"
|
|
@@ -57,6 +66,7 @@ ABBREVIATION_MESSAGE = 'Abbreviation "{abbreviation}" in the text. {reminder}'
|
|
|
57
66
|
TRACKER_NUMBER_MESSAGE = 'Tracker number "{reference}" in the text. {reminder}'
|
|
58
67
|
NO_VISUAL_MESSAGE = "This reply has {sentence_count} sentences and no visual this turn. {reminder}"
|
|
59
68
|
SECOND_REPLY_MESSAGE = "A reply already went out this turn. {reminder}"
|
|
69
|
+
NOT_FLOWCHART_MESSAGE = "This widget is not a flowchart. {reminder}"
|
|
60
70
|
WIDGET_ANCHOR_MESSAGE = (
|
|
61
71
|
"A link sits inside the widget code, and a widget link does not open in the Claude app."
|
|
62
72
|
" Remove the anchor and put the page link in the reply text."
|
|
@@ -24,6 +24,7 @@ CONTEXT_OUTPUT_PATTERN = re.compile(r"additionalContext|ADDITIONAL_CONTEXT|addit
|
|
|
24
24
|
UNMEASURED_REASON_BY_SCRIPT = {
|
|
25
25
|
"blocking/pre_tool_use_dispatcher.py": "relays its hosted hooks' output",
|
|
26
26
|
"blocking/bash_pre_tool_use_dispatcher.py": "relays its hosted hooks' output",
|
|
27
|
+
"blocking/reply_length_gate.py": "advises only on a widget while visual reply mode is on",
|
|
27
28
|
"lifecycle/nested_project_hooks.py": "relays a nested project's own hooks",
|
|
28
29
|
"routing/thread_spawn_pace_hook.py": "quotes a usage reading capped by its own constant",
|
|
29
30
|
"session/issue_tracker_session_starter.py": "emits only for a repository the user's "
|
|
@@ -8,6 +8,7 @@ _hooks_directory = str(Path(__file__).resolve().parent)
|
|
|
8
8
|
if _hooks_directory not in sys.path:
|
|
9
9
|
sys.path.insert(0, _hooks_directory)
|
|
10
10
|
|
|
11
|
+
from hooks_constants.visual_reply_rules_constants import RULES_FILE_PATH
|
|
11
12
|
from visual_reply_rules import (
|
|
12
13
|
abbreviation_violation,
|
|
13
14
|
load_rules,
|
|
@@ -17,6 +18,7 @@ from visual_reply_rules import (
|
|
|
17
18
|
visual_shown_this_turn,
|
|
18
19
|
visual_violation,
|
|
19
20
|
widget_anchor_violation,
|
|
21
|
+
widget_flowchart_advice,
|
|
20
22
|
)
|
|
21
23
|
|
|
22
24
|
SHIPPED_RULES = load_rules()
|
|
@@ -162,3 +164,46 @@ def test_second_reply_violation_denies_only_after_a_delivered_reply(tmp_path: Pa
|
|
|
162
164
|
)
|
|
163
165
|
assert second_reply_violation(str(transcript_path), SHIPPED_RULES) is not None
|
|
164
166
|
assert second_reply_violation(str(tmp_path / "missing.jsonl"), SHIPPED_RULES) is None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
FLOWCHART_CODE = (
|
|
170
|
+
'<svg><path d="M0 20L40 0L80 20L40 40Z"/>'
|
|
171
|
+
'<path d="M80 20H120" marker-end="url(#a)"/></svg>'
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_widget_flowchart_advice_should_pass_an_svg_with_a_diamond_and_arrows() -> None:
|
|
176
|
+
rules = load_rules()
|
|
177
|
+
assert rules is not None
|
|
178
|
+
nested_input = {"input": {"widget_code": FLOWCHART_CODE}}
|
|
179
|
+
assert widget_flowchart_advice("mcp__hearthbot__post_widget", nested_input, rules) is None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_widget_flowchart_advice_should_advise_on_cards_and_shapes_without_a_decision() -> None:
|
|
183
|
+
rules = load_rules()
|
|
184
|
+
assert rules is not None
|
|
185
|
+
card_input = {"widget_code": "<div class='card'>Merged</div>"}
|
|
186
|
+
no_diamond_input = {"widget_code": '<svg><path marker-end="url(#a)"/></svg>'}
|
|
187
|
+
arrowhead_only_input = {
|
|
188
|
+
"widget_code": '<svg><marker id="a"><polygon points="0 0,9 5,0 9"/></marker>'
|
|
189
|
+
'<rect/><path marker-end="url(#a)"/></svg>'
|
|
190
|
+
}
|
|
191
|
+
for each_input in (card_input, no_diamond_input, arrowhead_only_input):
|
|
192
|
+
reason = widget_flowchart_advice("mcp__hearthbot__post_widget", each_input, rules)
|
|
193
|
+
assert reason is not None and "diamond" in reason
|
|
194
|
+
assert widget_flowchart_advice("mcp__hearthbot__reply", card_input, rules) is None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_widget_flowchart_advice_should_stay_quiet_when_the_rule_list_has_no_flowchart_rule(
|
|
198
|
+
tmp_path: Path,
|
|
199
|
+
) -> None:
|
|
200
|
+
shipped_document = json.loads(RULES_FILE_PATH.read_text(encoding="utf-8"))
|
|
201
|
+
shipped_document["rules"] = [
|
|
202
|
+
each for each in shipped_document["rules"] if each["key"] != "not_flowchart"
|
|
203
|
+
]
|
|
204
|
+
rules_path = tmp_path / "rules.json"
|
|
205
|
+
rules_path.write_text(json.dumps(shipped_document), encoding="utf-8")
|
|
206
|
+
rules = load_rules(rules_path)
|
|
207
|
+
assert rules is not None
|
|
208
|
+
card_input = {"widget_code": "<div class='card'>Merged</div>"}
|
|
209
|
+
assert widget_flowchart_advice("mcp__hearthbot__post_widget", card_input, rules) is None
|
|
@@ -58,6 +58,14 @@ from hooks_constants.visual_reply_rules_constants import (
|
|
|
58
58
|
TRANSCRIPT_ENCODING,
|
|
59
59
|
USER_ENTRY_TYPE,
|
|
60
60
|
WIDGET_ANCHOR_MESSAGE,
|
|
61
|
+
FLOWCHART_ARROW_PATTERN,
|
|
62
|
+
FLOWCHART_DIAMOND_PATTERN,
|
|
63
|
+
FLOWCHART_MARKER_BLOCK_PATTERN,
|
|
64
|
+
FLOWCHART_RULE_KEY,
|
|
65
|
+
FLOWCHART_SVG_PATTERN,
|
|
66
|
+
NOT_FLOWCHART_MESSAGE,
|
|
67
|
+
WIDGET_CODE_KEY,
|
|
68
|
+
WIDGET_INPUT_KEY,
|
|
61
69
|
WIDGET_ANCHOR_PATTERN,
|
|
62
70
|
WIDGET_TOOL_NAME_SUFFIX,
|
|
63
71
|
)
|
|
@@ -153,6 +161,35 @@ def widget_anchor_violation(tool_name: str, all_tool_input: dict[str, object]) -
|
|
|
153
161
|
return WIDGET_ANCHOR_MESSAGE if WIDGET_ANCHOR_PATTERN.search(serialized_input) else None
|
|
154
162
|
|
|
155
163
|
|
|
164
|
+
def widget_flowchart_advice(
|
|
165
|
+
tool_name: str, all_tool_input: dict[str, object], rules: VisualReplyRules
|
|
166
|
+
) -> str | None:
|
|
167
|
+
"""Return the advice for widget code that is not a flowchart, else None.
|
|
168
|
+
|
|
169
|
+
The gate passes this advice to the session and still allows the call.
|
|
170
|
+
|
|
171
|
+
A flowchart widget is one SVG with at least one arrow marker and one
|
|
172
|
+
diamond shape for a yes or no check. Shapes inside a ``<marker>`` block
|
|
173
|
+
are arrowheads, so they never count as the diamond::
|
|
174
|
+
|
|
175
|
+
<svg><path d="M0 0L9 5L0 9L-9 5Z"/><path marker-end="url(#a)"/></svg>
|
|
176
|
+
"""
|
|
177
|
+
all_rule_keys = {each.key for each in rules.all_rules}
|
|
178
|
+
if not tool_name.endswith(WIDGET_TOOL_NAME_SUFFIX) or FLOWCHART_RULE_KEY not in all_rule_keys:
|
|
179
|
+
return None
|
|
180
|
+
widget_code = all_tool_input.get(WIDGET_CODE_KEY)
|
|
181
|
+
if widget_code is None:
|
|
182
|
+
widget_input = all_tool_input.get(WIDGET_INPUT_KEY)
|
|
183
|
+
widget_code = widget_input.get(WIDGET_CODE_KEY) if isinstance(widget_input, dict) else None
|
|
184
|
+
code_text = FLOWCHART_MARKER_BLOCK_PATTERN.sub(
|
|
185
|
+
"", widget_code if isinstance(widget_code, str) else ""
|
|
186
|
+
)
|
|
187
|
+
all_shape_patterns = (FLOWCHART_SVG_PATTERN, FLOWCHART_ARROW_PATTERN, FLOWCHART_DIAMOND_PATTERN)
|
|
188
|
+
if all(each_pattern.search(code_text) for each_pattern in all_shape_patterns):
|
|
189
|
+
return None
|
|
190
|
+
return NOT_FLOWCHART_MESSAGE.format(reminder=rules.reminder(FLOWCHART_RULE_KEY))
|
|
191
|
+
|
|
192
|
+
|
|
156
193
|
def _parsed_entries(all_transcript_lines: Iterable[str]) -> list[dict[str, object]]:
|
|
157
194
|
all_entries = []
|
|
158
195
|
for each_line in all_transcript_lines:
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
{
|
|
9
9
|
"key": "no_picture",
|
|
10
10
|
"label": "No picture",
|
|
11
|
-
"reminder": "Show the item as one visual first, a widget or a published page. Then send one reply of one to three short sentences that states the outcome and each fact the user needs, and links the page."
|
|
11
|
+
"reminder": "Show the item as one visual first, a widget or a published page. Prefer a flowchart that runs left to right, with boxes for steps, a diamond for each yes or no check, and arrows between them. Then send one reply of one to three short sentences that states the outcome and each fact the user needs, and links the page."
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"key": "too_many_words",
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
"label": "Wall of text",
|
|
26
26
|
"reminder": "Put each point, and each step the reader takes, on its own line."
|
|
27
27
|
},
|
|
28
|
+
{
|
|
29
|
+
"key": "not_flowchart",
|
|
30
|
+
"label": "No flowchart",
|
|
31
|
+
"reminder": "Redraw this widget as one SVG flowchart that runs left to right, with boxes for steps, a diamond for each yes or no check, and arrows between them, unless the item has no steps or checks."
|
|
32
|
+
},
|
|
28
33
|
{
|
|
29
34
|
"key": "use_flowchart",
|
|
30
35
|
"label": "Use the flowchart",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Record one review pass on a pull request head for the merge review gate.
|
|
3
|
+
|
|
4
|
+
The agent loops a code review on its pull request. Each pass is recorded::
|
|
5
|
+
|
|
6
|
+
python3 merge_review_receipt.py owner/name 12 abc1234 3 -> REVIEWED owner/name#12 abc1234 3 findings
|
|
7
|
+
fix, push, review the new head
|
|
8
|
+
python3 merge_review_receipt.py owner/name 12 def5678 0 -> CLEAN owner/name#12 def5678
|
|
9
|
+
|
|
10
|
+
``hooks/blocking/merge_review_gate.py`` allows a merge pinned to the head of
|
|
11
|
+
the newest pass only when that pass counted zero findings.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TextIO
|
|
22
|
+
|
|
23
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent / "hooks")
|
|
24
|
+
if hooks_directory not in sys.path:
|
|
25
|
+
sys.path.insert(0, hooks_directory)
|
|
26
|
+
|
|
27
|
+
from blocking.merge_review_gate import receipt_path
|
|
28
|
+
from hooks_constants.merge_review_gate_constants import (
|
|
29
|
+
RECEIPT_FINDINGS_KEY,
|
|
30
|
+
RECEIPT_HEAD_KEY,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record_review(slug: str, number: int, head: str, finding_count: int) -> Path:
|
|
35
|
+
"""Write the receipt for one review pass, replacing the previous pass.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
slug: Repository as ``owner/name``.
|
|
39
|
+
number: Pull request number.
|
|
40
|
+
head: The commit the pass reviewed.
|
|
41
|
+
finding_count: How many findings the pass reported.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The receipt path written.
|
|
45
|
+
"""
|
|
46
|
+
owner, repo = slug.split("/", 1)
|
|
47
|
+
path = receipt_path(owner, repo, number)
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
path.write_text(
|
|
50
|
+
json.dumps({RECEIPT_HEAD_KEY: head, RECEIPT_FINDINGS_KEY: finding_count})
|
|
51
|
+
+ "\n",
|
|
52
|
+
encoding="utf-8",
|
|
53
|
+
)
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
|
|
58
|
+
"""Record one pass and print its verdict line.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
all_arguments: Command arguments after the program name.
|
|
62
|
+
stdout: Stream that receives the verdict.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
0 for a clean pass, and 1 for a pass with findings.
|
|
66
|
+
"""
|
|
67
|
+
parser = argparse.ArgumentParser(prog="merge_review_receipt")
|
|
68
|
+
parser.add_argument("slug", help="Repository as owner/name.")
|
|
69
|
+
parser.add_argument("number", type=int, help="Pull request number.")
|
|
70
|
+
parser.add_argument("head", help="The head commit the pass reviewed.")
|
|
71
|
+
parser.add_argument("findings", type=int, help="Findings the pass reported.")
|
|
72
|
+
parsed = parser.parse_args(all_arguments)
|
|
73
|
+
record_review(parsed.slug, parsed.number, parsed.head, parsed.findings)
|
|
74
|
+
if parsed.findings == 0:
|
|
75
|
+
stdout.write(f"CLEAN {parsed.slug}#{parsed.number} {parsed.head}\n")
|
|
76
|
+
return 0
|
|
77
|
+
stdout.write(
|
|
78
|
+
f"REVIEWED {parsed.slug}#{parsed.number} {parsed.head} {parsed.findings} findings\n"
|
|
79
|
+
)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -64,6 +64,7 @@ ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
|
|
|
64
64
|
ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
65
65
|
"blocking/bash_pre_tool_use_dispatcher.py",
|
|
66
66
|
"blocking/pr_lifecycle_skill_gate.py",
|
|
67
|
+
"blocking/merge_review_gate.py",
|
|
67
68
|
"blocking/step_note_gate.py",
|
|
68
69
|
"blocking/reply_length_gate.py",
|
|
69
70
|
"blocking/edit_marker_gate.py",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Tests for the merge review receipt recorder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import merge_review_receipt
|
|
12
|
+
|
|
13
|
+
HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def receipt_folder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
18
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_RECEIPT_DIR", str(tmp_path))
|
|
19
|
+
return tmp_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_should_print_clean_and_exit_zero_for_a_pass_with_no_findings(receipt_folder: Path) -> None:
|
|
23
|
+
verdict_stream = StringIO()
|
|
24
|
+
assert merge_review_receipt.main(["acme/tools", "12", HEAD, "0"], verdict_stream) == 0
|
|
25
|
+
assert verdict_stream.getvalue() == f"CLEAN acme/tools#12 {HEAD}\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_should_exit_one_and_count_findings_for_a_pass_with_findings() -> None:
|
|
29
|
+
verdict_stream = StringIO()
|
|
30
|
+
assert merge_review_receipt.main(["acme/tools", "12", HEAD, "3"], verdict_stream) == 1
|
|
31
|
+
assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 3 findings\n"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_record_review_replaces_the_previous_pass(receipt_folder: Path) -> None:
|
|
35
|
+
merge_review_receipt.record_review("Acme/Tools", 12, "old", 4)
|
|
36
|
+
path = merge_review_receipt.record_review("Acme/Tools", 12, HEAD, 0)
|
|
37
|
+
assert path == receipt_folder / "acme__tools__12.json"
|
|
38
|
+
assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD, "findings": 0}
|