claude-dev-env 8.77.0 → 8.78.1
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 +304 -0
- package/hooks/blocking/pr_lifecycle_skill_gate.py +2 -8
- package/hooks/blocking/test_merge_review_gate.py +132 -0
- package/hooks/features/conduct-gates.md +4 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +73 -0
- package/hooks/hooks_constants/pre_tool_use_stdin.py +20 -0
- package/hooks/hooks_constants/test_pre_tool_use_stdin.py +17 -0
- package/package.json +1 -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. In a merge-queue repository, enqueue with `gh pr merge <number> --repo <owner>/<name> --auto --match-head-commit <head sha>`; auto-merge that names no head is denied. 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,304 @@
|
|
|
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 or enqueue pinned to the clean head -> allow
|
|
12
|
+
auto-merge that names no head -> deny, pin it
|
|
13
|
+
repository listed in CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES -> allow
|
|
14
|
+
|
|
15
|
+
The deny reason names the review command CLAUDE_MERGE_REVIEW_COMMAND sets,
|
|
16
|
+
or ``/code-review high --max-findings all`` when it is unset.
|
|
17
|
+
|
|
18
|
+
``scripts/merge_review_receipt.py`` writes the receipt this reads.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
30
|
+
if hooks_directory not in sys.path:
|
|
31
|
+
sys.path.insert(0, hooks_directory)
|
|
32
|
+
|
|
33
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
34
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
35
|
+
PERMISSION_DECISION_KEY,
|
|
36
|
+
)
|
|
37
|
+
from hooks_constants.merge_review_gate_constants import (
|
|
38
|
+
ALL_RECEIPT_DIRECTORY_SEGMENTS,
|
|
39
|
+
ALL_SHELL_TOOL_NAMES,
|
|
40
|
+
AUTO_MERGE_REASON_TEMPLATE,
|
|
41
|
+
AUTO_MERGE_TOOL_SUFFIX,
|
|
42
|
+
API_SHA_FIELD_PATTERN,
|
|
43
|
+
AUTO_OPTION_PATTERN,
|
|
44
|
+
DEFAULT_REVIEW_COMMAND,
|
|
45
|
+
DENY_DECISION,
|
|
46
|
+
EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE,
|
|
47
|
+
EXPECTED_HEAD_FIELD,
|
|
48
|
+
GH_API_MERGE_PATTERN,
|
|
49
|
+
GH_PR_MERGE_PATTERN,
|
|
50
|
+
HOOK_EVENT_NAME,
|
|
51
|
+
MATCH_HEAD_PATTERN,
|
|
52
|
+
MERGE_TOOL_SUFFIX,
|
|
53
|
+
NO_CLEAN_REVIEW_REASON_TEMPLATE,
|
|
54
|
+
NUMBER_FIELD,
|
|
55
|
+
OPTION_WITH_VALUE_PATTERN,
|
|
56
|
+
OWNER_FIELD,
|
|
57
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
58
|
+
PR_NUMBER_PATTERN,
|
|
59
|
+
PR_URL_PATTERN,
|
|
60
|
+
RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
61
|
+
RECEIPT_FILE_NAME_TEMPLATE,
|
|
62
|
+
RECEIPT_FINDINGS_KEY,
|
|
63
|
+
RECEIPT_HEAD_KEY,
|
|
64
|
+
REPO_FIELD,
|
|
65
|
+
REPO_OPTION_PATTERN,
|
|
66
|
+
REPOSITORY_SEPARATOR,
|
|
67
|
+
REVIEW_COMMAND_ENVIRONMENT_VARIABLE,
|
|
68
|
+
STALE_REVIEW_REASON_TEMPLATE,
|
|
69
|
+
UNKNOWN_TARGET_REASON,
|
|
70
|
+
UNPINNED_MERGE_REASON_TEMPLATE,
|
|
71
|
+
)
|
|
72
|
+
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class MergeRequest:
|
|
77
|
+
"""One merge a tool call asks for.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
owner: Repository owner, or empty when the call names none.
|
|
81
|
+
repo: Repository name, or empty when the call names none.
|
|
82
|
+
number: Pull request number, or None when the call names none.
|
|
83
|
+
pinned_head: The head the merge is pinned to, or None.
|
|
84
|
+
is_auto_merge: Whether the call turns on auto-merge.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
owner: str
|
|
88
|
+
repo: str
|
|
89
|
+
number: int | None
|
|
90
|
+
pinned_head: str | None
|
|
91
|
+
is_auto_merge: bool
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def slug(self) -> str:
|
|
95
|
+
"""Return ``owner/repo``."""
|
|
96
|
+
return f"{self.owner}/{self.repo}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def receipt_directory() -> Path:
|
|
100
|
+
"""Return the folder that holds one review receipt per pull request.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The folder CLAUDE_MERGE_REVIEW_RECEIPT_DIR names, else ``~/.claude/merge-reviews``.
|
|
104
|
+
"""
|
|
105
|
+
override = os.environ.get(RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
106
|
+
if override:
|
|
107
|
+
return Path(override)
|
|
108
|
+
return Path.home().joinpath(*ALL_RECEIPT_DIRECTORY_SEGMENTS)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def receipt_path(owner: str, repo: str, number: int) -> Path:
|
|
112
|
+
"""Return where the review receipt for one pull request lives.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
owner: Repository owner.
|
|
116
|
+
repo: Repository name.
|
|
117
|
+
number: Pull request number.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The receipt file path.
|
|
121
|
+
"""
|
|
122
|
+
return receipt_directory() / RECEIPT_FILE_NAME_TEMPLATE.format(
|
|
123
|
+
owner=owner.casefold(), repo=repo.casefold(), number=number
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def merge_request_from(all_payload_fields: dict[str, object]) -> MergeRequest | None:
|
|
128
|
+
"""Read the merge a PreToolUse payload asks for.
|
|
129
|
+
|
|
130
|
+
::
|
|
131
|
+
|
|
132
|
+
mcp__github__merge_pull_request {owner, repo, pullNumber, expectedHeadSha}
|
|
133
|
+
mcp__github__enable_pr_auto_merge {owner, repo, pullNumber}
|
|
134
|
+
gh pr merge 12 --repo o/r --match-head-commit abc123
|
|
135
|
+
gh api repos/o/r/pulls/12/merge -X PUT
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The merge request, or None when the call merges nothing.
|
|
142
|
+
"""
|
|
143
|
+
tool_name = all_payload_fields.get("tool_name")
|
|
144
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
145
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
146
|
+
return None
|
|
147
|
+
if tool_name.endswith((MERGE_TOOL_SUFFIX, AUTO_MERGE_TOOL_SUFFIX)):
|
|
148
|
+
return _tool_merge_request(tool_name, tool_input)
|
|
149
|
+
command = tool_input.get("command")
|
|
150
|
+
if tool_name not in ALL_SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
151
|
+
return None
|
|
152
|
+
return _shell_merge_request(command)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _tool_merge_request(tool_name: str, all_tool_input: dict[object, object]) -> MergeRequest:
|
|
156
|
+
number = all_tool_input.get(NUMBER_FIELD)
|
|
157
|
+
pinned_head = all_tool_input.get(EXPECTED_HEAD_FIELD)
|
|
158
|
+
return MergeRequest(
|
|
159
|
+
owner=str(all_tool_input.get(OWNER_FIELD, "")),
|
|
160
|
+
repo=str(all_tool_input.get(REPO_FIELD, "")),
|
|
161
|
+
number=int(number) if isinstance(number, (int, float)) else None,
|
|
162
|
+
pinned_head=pinned_head if isinstance(pinned_head, str) and pinned_head else None,
|
|
163
|
+
is_auto_merge=tool_name.endswith(AUTO_MERGE_TOOL_SUFFIX),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _shell_merge_request(command: str) -> MergeRequest | None:
|
|
168
|
+
api_match = GH_API_MERGE_PATTERN.search(command)
|
|
169
|
+
if api_match:
|
|
170
|
+
return MergeRequest(
|
|
171
|
+
owner=api_match["owner"],
|
|
172
|
+
repo=api_match["repo"],
|
|
173
|
+
number=int(api_match["number"]),
|
|
174
|
+
pinned_head=_api_pinned_head(command),
|
|
175
|
+
is_auto_merge=False,
|
|
176
|
+
)
|
|
177
|
+
merge_match = GH_PR_MERGE_PATTERN.search(command)
|
|
178
|
+
if not merge_match:
|
|
179
|
+
return None
|
|
180
|
+
rest = merge_match["rest"]
|
|
181
|
+
owner, repo, number = _gh_pr_merge_target(rest)
|
|
182
|
+
head_match = MATCH_HEAD_PATTERN.search(rest)
|
|
183
|
+
return MergeRequest(
|
|
184
|
+
owner=owner,
|
|
185
|
+
repo=repo,
|
|
186
|
+
number=number,
|
|
187
|
+
pinned_head=head_match["sha"] if head_match else None,
|
|
188
|
+
is_auto_merge=bool(AUTO_OPTION_PATTERN.search(rest)),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _gh_pr_merge_target(arguments: str) -> tuple[str, str, int | None]:
|
|
193
|
+
url_match = PR_URL_PATTERN.search(arguments)
|
|
194
|
+
if url_match:
|
|
195
|
+
return url_match["owner"], url_match["repo"], int(url_match["number"])
|
|
196
|
+
repo_match = REPO_OPTION_PATTERN.search(arguments)
|
|
197
|
+
number_match = PR_NUMBER_PATTERN.search(_without_option_values(arguments))
|
|
198
|
+
return (
|
|
199
|
+
repo_match["owner"] if repo_match else "",
|
|
200
|
+
repo_match["repo"] if repo_match else "",
|
|
201
|
+
int(number_match["number"]) if number_match else None,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _api_pinned_head(command: str) -> str | None:
|
|
206
|
+
sha_match = API_SHA_FIELD_PATTERN.search(command)
|
|
207
|
+
return sha_match["sha"] if sha_match else None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _without_option_values(arguments: str) -> str:
|
|
211
|
+
return OPTION_WITH_VALUE_PATTERN.sub(" ", arguments)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_exempt(request: MergeRequest) -> bool:
|
|
215
|
+
listed = os.environ.get(EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE, "")
|
|
216
|
+
all_exempt = {
|
|
217
|
+
each.strip().casefold() for each in listed.split(REPOSITORY_SEPARATOR) if each.strip()
|
|
218
|
+
}
|
|
219
|
+
return request.slug.casefold() in all_exempt
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _clean_head(request: MergeRequest) -> str | None:
|
|
223
|
+
if request.number is None:
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
receipt = json.loads(
|
|
227
|
+
receipt_path(request.owner, request.repo, request.number).read_text(encoding="utf-8")
|
|
228
|
+
)
|
|
229
|
+
except (OSError, ValueError):
|
|
230
|
+
return None
|
|
231
|
+
if not isinstance(receipt, dict) or receipt.get(RECEIPT_FINDINGS_KEY) != 0:
|
|
232
|
+
return None
|
|
233
|
+
head = receipt.get(RECEIPT_HEAD_KEY)
|
|
234
|
+
return head if isinstance(head, str) and head else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _review_command() -> str:
|
|
238
|
+
return os.environ.get(REVIEW_COMMAND_ENVIRONMENT_VARIABLE) or DEFAULT_REVIEW_COMMAND
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _same_commit(first: str, second: str) -> bool:
|
|
242
|
+
shorter, longer = sorted((first.casefold(), second.casefold()), key=len)
|
|
243
|
+
return longer.startswith(shorter)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def deny_reason(request: MergeRequest) -> str | None:
|
|
247
|
+
"""Return why a merge must wait for a clean review, or None to allow it.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
request: The merge the tool call asks for.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The deny reason, or None when the merge may run.
|
|
254
|
+
"""
|
|
255
|
+
if not request.owner or not request.repo or request.number is None:
|
|
256
|
+
return UNKNOWN_TARGET_REASON
|
|
257
|
+
if _is_exempt(request):
|
|
258
|
+
return None
|
|
259
|
+
fields = {"slug": request.slug, "number": request.number}
|
|
260
|
+
clean_head = _clean_head(request)
|
|
261
|
+
if clean_head is None:
|
|
262
|
+
return NO_CLEAN_REVIEW_REASON_TEMPLATE.format(review_command=_review_command(), **fields)
|
|
263
|
+
if request.pinned_head is None and request.is_auto_merge:
|
|
264
|
+
return AUTO_MERGE_REASON_TEMPLATE.format(head=clean_head, **fields)
|
|
265
|
+
if request.pinned_head is None:
|
|
266
|
+
return UNPINNED_MERGE_REASON_TEMPLATE.format(head=clean_head, **fields)
|
|
267
|
+
if not _same_commit(clean_head, request.pinned_head):
|
|
268
|
+
return STALE_REVIEW_REASON_TEMPLATE.format(
|
|
269
|
+
head=clean_head, pinned=request.pinned_head, **fields
|
|
270
|
+
)
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
275
|
+
"""Return a deny decision for a merge with no clean review, else None.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The hook output that denies the call, or None to allow it.
|
|
282
|
+
"""
|
|
283
|
+
request = merge_request_from(all_payload_fields)
|
|
284
|
+
if request is None:
|
|
285
|
+
return None
|
|
286
|
+
reason = deny_reason(request)
|
|
287
|
+
if reason is None:
|
|
288
|
+
return None
|
|
289
|
+
return {
|
|
290
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
291
|
+
"hookEventName": HOOK_EVENT_NAME,
|
|
292
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
293
|
+
PERMISSION_DECISION_REASON_KEY: reason,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main() -> int:
|
|
299
|
+
"""Read one payload, print a deny when needed, and exit zero."""
|
|
300
|
+
return print_decision_from_stdin(decision_for)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
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__":
|
|
@@ -0,0 +1,132 @@
|
|
|
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_that_names_no_head() -> 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 f"--match-head-commit {CLEAN_HEAD}" 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))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_should_allow_auto_merge_pinned_to_the_clean_head() -> None:
|
|
119
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
120
|
+
command = f"gh pr merge 12 --repo acme/tools --auto --match-head-commit {CLEAN_HEAD}"
|
|
121
|
+
assert _reason(_bash(command)) is None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_should_deny_pinned_auto_merge_with_no_clean_review() -> None:
|
|
125
|
+
command = f"gh pr merge 12 --repo acme/tools --auto --match-head-commit {CLEAN_HEAD}"
|
|
126
|
+
assert "no clean review" in _reason(_bash(command))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_deny_auto_merge_pinned_to_a_newer_head() -> None:
|
|
130
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
131
|
+
command = f"gh pr merge 12 --repo acme/tools --auto --match-head-commit {NEW_HEAD}"
|
|
132
|
+
assert "Review the new head" in _reason(_bash(command))
|
|
@@ -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, auto-merge or merge-queue entry to be pinned to that head, 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
|
|
@@ -37,6 +39,7 @@ Preconditions:
|
|
|
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 with no head. 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 or auto-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,73 @@
|
|
|
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} names no head, so it could merge a head no review "
|
|
67
|
+
"has seen. Run `gh pr merge {number} --repo {slug} --auto --match-head-commit {head}`, "
|
|
68
|
+
"which arms auto-merge or joins the merge queue only while the head is {head}."
|
|
69
|
+
)
|
|
70
|
+
UNKNOWN_TARGET_REASON = (
|
|
71
|
+
"This merge names no repository and pull request number the review gate can read. "
|
|
72
|
+
"Name both, such as `gh pr merge 12 --repo owner/name --match-head-commit <sha>`."
|
|
73
|
+
)
|
|
@@ -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() == ""
|
package/package.json
CHANGED
|
@@ -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}
|