claude-dev-env 8.99.1 → 8.100.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/hooks/blocking/merge_review_gate.py +66 -105
- package/hooks/blocking/merge_review_gate_parts/__init__.py +1 -0
- package/hooks/blocking/merge_review_gate_parts/open_request.py +163 -0
- package/hooks/blocking/merge_review_gate_parts/receipts.py +192 -0
- package/hooks/blocking/test_merge_review_gate.py +218 -0
- package/hooks/blocking/tests/test_open_request.py +38 -0
- package/hooks/blocking/tests/test_receipts.py +57 -0
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/merge_review_gate_constants.py +46 -1
- package/package.json +1 -1
- package/scripts/merge_review_receipt.py +56 -4
- package/scripts/test_merge_review_receipt.py +53 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
"""Deny an agent's merge until a review of
|
|
1
|
+
"""Deny an agent's pull request open or merge until a review of that head came back clean.
|
|
2
2
|
|
|
3
|
-
The agent
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The agent reviews its branch before it opens the pull request, and loops a
|
|
4
|
+
code review on the pull request, fixing and pushing each finding, until one
|
|
5
|
+
pass reports zero findings or every finding it reports is ruled out with a
|
|
6
|
+
link to the posted rulings. The open and the merge then name that exact head::
|
|
7
7
|
|
|
8
|
+
open, open receipt says 0 findings at abc123, branch head abc123 -> allow
|
|
9
|
+
open, no open receipt for the branch head -> deny, review first
|
|
8
10
|
merge, receipt says 0 findings at abc123, merge pinned to abc123 -> allow
|
|
11
|
+
merge, open receipt clean at abc123, merge pinned to abc123 -> allow
|
|
9
12
|
merge, receipt rules out all 3 findings with a link, pinned -> allow
|
|
10
13
|
merge, receipt at abc123, merge pinned to def456 -> deny, review again
|
|
11
14
|
merge, receipt at abc123, merge names no head -> deny, pin it
|
|
@@ -32,12 +35,18 @@ hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
|
32
35
|
if hooks_directory not in sys.path:
|
|
33
36
|
sys.path.insert(0, hooks_directory)
|
|
34
37
|
|
|
38
|
+
from blocking.merge_review_gate_parts.open_request import OpenRequest, open_request_from
|
|
39
|
+
from blocking.merge_review_gate_parts.receipts import (
|
|
40
|
+
has_clean_open_review,
|
|
41
|
+
is_clean_receipt,
|
|
42
|
+
receipt_path,
|
|
43
|
+
same_commit,
|
|
44
|
+
)
|
|
35
45
|
from hooks_constants.hook_specific_output_keys import (
|
|
36
46
|
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
37
47
|
PERMISSION_DECISION_KEY,
|
|
38
48
|
)
|
|
39
49
|
from hooks_constants.merge_review_gate_constants import (
|
|
40
|
-
ALL_RECEIPT_DIRECTORY_SEGMENTS,
|
|
41
50
|
ALL_SHELL_TOOL_NAMES,
|
|
42
51
|
AUTO_MERGE_REASON_TEMPLATE,
|
|
43
52
|
AUTO_MERGE_TOOL_SUFFIX,
|
|
@@ -52,6 +61,7 @@ from hooks_constants.merge_review_gate_constants import (
|
|
|
52
61
|
HOOK_EVENT_NAME,
|
|
53
62
|
MATCH_HEAD_PATTERN,
|
|
54
63
|
MERGE_TOOL_SUFFIX,
|
|
64
|
+
NO_CLEAN_OPEN_REVIEW_REASON_TEMPLATE,
|
|
55
65
|
NO_CLEAN_REVIEW_REASON_TEMPLATE,
|
|
56
66
|
NUMBER_FIELD,
|
|
57
67
|
OPTION_WITH_VALUE_PATTERN,
|
|
@@ -59,18 +69,13 @@ from hooks_constants.merge_review_gate_constants import (
|
|
|
59
69
|
PERMISSION_DECISION_REASON_KEY,
|
|
60
70
|
PR_NUMBER_PATTERN,
|
|
61
71
|
PR_URL_PATTERN,
|
|
62
|
-
RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
63
|
-
RECEIPT_FILE_NAME_TEMPLATE,
|
|
64
|
-
RECEIPT_FINDINGS_KEY,
|
|
65
72
|
RECEIPT_HEAD_KEY,
|
|
66
|
-
RECEIPT_RULED_OUT_KEY,
|
|
67
|
-
RECEIPT_RULINGS_KEY,
|
|
68
73
|
REPO_FIELD,
|
|
69
74
|
REPO_OPTION_PATTERN,
|
|
70
75
|
REPOSITORY_SEPARATOR,
|
|
71
76
|
REVIEW_COMMAND_ENVIRONMENT_VARIABLE,
|
|
72
|
-
RULINGS_LINK_PATTERN,
|
|
73
77
|
STALE_REVIEW_REASON_TEMPLATE,
|
|
78
|
+
UNKNOWN_OPEN_TARGET_REASON,
|
|
74
79
|
UNKNOWN_TARGET_REASON,
|
|
75
80
|
UNPINNED_MERGE_REASON_TEMPLATE,
|
|
76
81
|
)
|
|
@@ -101,31 +106,25 @@ class MergeRequest:
|
|
|
101
106
|
return f"{self.owner}/{self.repo}"
|
|
102
107
|
|
|
103
108
|
|
|
104
|
-
def
|
|
105
|
-
"""Return
|
|
106
|
-
|
|
107
|
-
Returns:
|
|
108
|
-
The folder CLAUDE_MERGE_REVIEW_RECEIPT_DIR names, else ``~/.claude/merge-reviews``.
|
|
109
|
-
"""
|
|
110
|
-
override = os.environ.get(RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
111
|
-
if override:
|
|
112
|
-
return Path(override)
|
|
113
|
-
return Path.home().joinpath(*ALL_RECEIPT_DIRECTORY_SEGMENTS)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def receipt_path(owner: str, repo: str, number: int) -> Path:
|
|
117
|
-
"""Return where the review receipt for one pull request lives.
|
|
109
|
+
def open_deny_reason(request: OpenRequest) -> str | None:
|
|
110
|
+
"""Return why a pull request open must wait for a clean review, or None to allow it.
|
|
118
111
|
|
|
119
112
|
Args:
|
|
120
|
-
|
|
121
|
-
repo: Repository name.
|
|
122
|
-
number: Pull request number.
|
|
113
|
+
request: The open the tool call asks for.
|
|
123
114
|
|
|
124
115
|
Returns:
|
|
125
|
-
The
|
|
116
|
+
The deny reason, or None when the open may run.
|
|
126
117
|
"""
|
|
127
|
-
|
|
128
|
-
|
|
118
|
+
if not request.owner or not request.repo:
|
|
119
|
+
return UNKNOWN_OPEN_TARGET_REASON
|
|
120
|
+
if _is_exempt(request.slug):
|
|
121
|
+
return None
|
|
122
|
+
if not request.head:
|
|
123
|
+
return UNKNOWN_OPEN_TARGET_REASON
|
|
124
|
+
if has_clean_open_review(request.repo, request.head):
|
|
125
|
+
return None
|
|
126
|
+
return NO_CLEAN_OPEN_REVIEW_REASON_TEMPLATE.format(
|
|
127
|
+
slug=request.slug, head=request.head, review_command=_review_command()
|
|
129
128
|
)
|
|
130
129
|
|
|
131
130
|
|
|
@@ -216,15 +215,15 @@ def _without_option_values(arguments: str) -> str:
|
|
|
216
215
|
return OPTION_WITH_VALUE_PATTERN.sub(" ", arguments)
|
|
217
216
|
|
|
218
217
|
|
|
219
|
-
def _is_exempt(
|
|
218
|
+
def _is_exempt(slug: str) -> bool:
|
|
220
219
|
listed = os.environ.get(EXEMPT_REPOSITORIES_ENVIRONMENT_VARIABLE, "")
|
|
221
220
|
all_exempt = {
|
|
222
221
|
each.strip().casefold() for each in listed.split(REPOSITORY_SEPARATOR) if each.strip()
|
|
223
222
|
}
|
|
224
|
-
return
|
|
223
|
+
return slug.casefold() in all_exempt
|
|
225
224
|
|
|
226
225
|
|
|
227
|
-
def
|
|
226
|
+
def _numbered_receipt(request: MergeRequest) -> dict[str, object] | None:
|
|
228
227
|
if request.number is None:
|
|
229
228
|
return None
|
|
230
229
|
try:
|
|
@@ -233,78 +232,26 @@ def _clean_head(request: MergeRequest) -> str | None:
|
|
|
233
232
|
)
|
|
234
233
|
except (OSError, ValueError):
|
|
235
234
|
return None
|
|
236
|
-
if
|
|
237
|
-
receipt, request.owner, request.repo, request.number
|
|
238
|
-
):
|
|
239
|
-
return None
|
|
240
|
-
head = receipt.get(RECEIPT_HEAD_KEY)
|
|
241
|
-
return head if isinstance(head, str) and head else None
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def is_clean_receipt(
|
|
245
|
-
all_receipt_fields: dict[str, object], owner: str, repo: str, number: int
|
|
246
|
-
) -> bool:
|
|
247
|
-
"""Return whether a recorded pass lets the merge through.
|
|
235
|
+
return receipt if isinstance(receipt, dict) else None
|
|
248
236
|
|
|
249
|
-
A pass is clean when it reported zero findings, or when every finding it
|
|
250
|
-
reported is ruled out and the receipt links the rulings comment posted on
|
|
251
|
-
this same pull request::
|
|
252
237
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
Args:
|
|
258
|
-
all_receipt_fields: The parsed receipt of one review pass.
|
|
259
|
-
owner: Owner of the pull request the merge targets.
|
|
260
|
-
repo: Repository of the pull request the merge targets.
|
|
261
|
-
number: Number of the pull request the merge targets.
|
|
262
|
-
|
|
263
|
-
Returns:
|
|
264
|
-
True when the pass counts as clean.
|
|
265
|
-
"""
|
|
266
|
-
finding_count = all_receipt_fields.get(RECEIPT_FINDINGS_KEY)
|
|
267
|
-
if not _is_count(finding_count):
|
|
268
|
-
return False
|
|
269
|
-
return finding_count == 0 or _rules_out_every_finding(
|
|
270
|
-
all_receipt_fields, finding_count, owner, repo, number
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _is_count(candidate: object) -> bool:
|
|
275
|
-
return isinstance(candidate, int) and not isinstance(candidate, bool) and candidate >= 0
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def _rules_out_every_finding(
|
|
279
|
-
all_receipt_fields: dict[str, object], finding_count: object, owner: str, repo: str, number: int
|
|
280
|
-
) -> bool:
|
|
281
|
-
ruled_out_count = all_receipt_fields.get(RECEIPT_RULED_OUT_KEY)
|
|
282
|
-
return (
|
|
283
|
-
_is_count(ruled_out_count)
|
|
284
|
-
and ruled_out_count == finding_count
|
|
285
|
-
and _links_rulings_on(all_receipt_fields.get(RECEIPT_RULINGS_KEY), owner, repo, number)
|
|
286
|
-
)
|
|
238
|
+
def _receipt_head(all_receipt_fields: dict[str, object] | None) -> str | None:
|
|
239
|
+
head = all_receipt_fields.get(RECEIPT_HEAD_KEY) if all_receipt_fields else None
|
|
240
|
+
return head if isinstance(head, str) and head else None
|
|
287
241
|
|
|
288
242
|
|
|
289
|
-
def
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
and int(rulings_match["number"]) == number
|
|
296
|
-
)
|
|
243
|
+
def _clean_head(request: MergeRequest, all_receipt_fields: dict[str, object] | None) -> str | None:
|
|
244
|
+
if all_receipt_fields is None or request.number is None:
|
|
245
|
+
return None
|
|
246
|
+
if not is_clean_receipt(all_receipt_fields, request.owner, request.repo, request.number):
|
|
247
|
+
return None
|
|
248
|
+
return _receipt_head(all_receipt_fields)
|
|
297
249
|
|
|
298
250
|
|
|
299
251
|
def _review_command() -> str:
|
|
300
252
|
return os.environ.get(REVIEW_COMMAND_ENVIRONMENT_VARIABLE) or DEFAULT_REVIEW_COMMAND
|
|
301
253
|
|
|
302
254
|
|
|
303
|
-
def _same_commit(first: str, second: str) -> bool:
|
|
304
|
-
shorter, longer = sorted((first.casefold(), second.casefold()), key=len)
|
|
305
|
-
return longer.startswith(shorter)
|
|
306
|
-
|
|
307
|
-
|
|
308
255
|
def deny_reason(request: MergeRequest) -> str | None:
|
|
309
256
|
"""Return why a merge must wait for a clean review, or None to allow it.
|
|
310
257
|
|
|
@@ -316,17 +263,30 @@ def deny_reason(request: MergeRequest) -> str | None:
|
|
|
316
263
|
"""
|
|
317
264
|
if not request.owner or not request.repo or request.number is None:
|
|
318
265
|
return UNKNOWN_TARGET_REASON
|
|
319
|
-
if _is_exempt(request):
|
|
266
|
+
if _is_exempt(request.slug):
|
|
267
|
+
return None
|
|
268
|
+
numbered_receipt = _numbered_receipt(request)
|
|
269
|
+
numbered_head = _receipt_head(numbered_receipt)
|
|
270
|
+
pass_on_pinned_head = (
|
|
271
|
+
numbered_head is not None
|
|
272
|
+
and request.pinned_head is not None
|
|
273
|
+
and same_commit(numbered_head, request.pinned_head)
|
|
274
|
+
)
|
|
275
|
+
if (
|
|
276
|
+
request.pinned_head
|
|
277
|
+
and not pass_on_pinned_head
|
|
278
|
+
and has_clean_open_review(request.repo, request.pinned_head)
|
|
279
|
+
):
|
|
320
280
|
return None
|
|
321
281
|
fields = {"slug": request.slug, "number": request.number}
|
|
322
|
-
clean_head = _clean_head(request)
|
|
282
|
+
clean_head = _clean_head(request, numbered_receipt)
|
|
323
283
|
if clean_head is None:
|
|
324
284
|
return NO_CLEAN_REVIEW_REASON_TEMPLATE.format(review_command=_review_command(), **fields)
|
|
325
285
|
if request.pinned_head is None and request.is_auto_merge:
|
|
326
286
|
return AUTO_MERGE_REASON_TEMPLATE.format(head=clean_head, **fields)
|
|
327
287
|
if request.pinned_head is None:
|
|
328
288
|
return UNPINNED_MERGE_REASON_TEMPLATE.format(head=clean_head, **fields)
|
|
329
|
-
if not
|
|
289
|
+
if not same_commit(clean_head, request.pinned_head):
|
|
330
290
|
return STALE_REVIEW_REASON_TEMPLATE.format(
|
|
331
291
|
head=clean_head, pinned=request.pinned_head, **fields
|
|
332
292
|
)
|
|
@@ -334,7 +294,7 @@ def deny_reason(request: MergeRequest) -> str | None:
|
|
|
334
294
|
|
|
335
295
|
|
|
336
296
|
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
337
|
-
"""Return a deny decision for
|
|
297
|
+
"""Return a deny decision for an open or merge with no clean review, else None.
|
|
338
298
|
|
|
339
299
|
Args:
|
|
340
300
|
all_payload_fields: The parsed PreToolUse input.
|
|
@@ -342,10 +302,11 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
342
302
|
Returns:
|
|
343
303
|
The hook output that denies the call, or None to allow it.
|
|
344
304
|
"""
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
305
|
+
open_request = open_request_from(all_payload_fields)
|
|
306
|
+
merge_request = merge_request_from(all_payload_fields)
|
|
307
|
+
reason = (open_deny_reason(open_request) if open_request is not None else None) or (
|
|
308
|
+
deny_reason(merge_request) if merge_request is not None else None
|
|
309
|
+
)
|
|
349
310
|
if reason is None:
|
|
350
311
|
return None
|
|
351
312
|
return {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Decomposed concern modules the ``merge_review_gate`` entry hook wires together."""
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Read the pull request open a PreToolUse payload asks for, and the head it opens with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent.parent)
|
|
12
|
+
if hooks_directory not in sys.path:
|
|
13
|
+
sys.path.insert(0, hooks_directory)
|
|
14
|
+
|
|
15
|
+
from hooks_constants.merge_review_gate_constants import (
|
|
16
|
+
ALL_ORIGIN_URL_ARGUMENTS,
|
|
17
|
+
ALL_SHELL_TOOL_NAMES,
|
|
18
|
+
CHANGE_FOLDER_PATTERN,
|
|
19
|
+
CREATE_TEXT_OPTION_PATTERN,
|
|
20
|
+
CREATE_TOOL_SUFFIX,
|
|
21
|
+
CURRENT_HEAD_REFERENCE,
|
|
22
|
+
CWD_FIELD,
|
|
23
|
+
GH_PR_CREATE_PATTERN,
|
|
24
|
+
GIT_TIMEOUT_SECONDS,
|
|
25
|
+
HEAD_BRANCH_FIELD,
|
|
26
|
+
HEAD_OPTION_PATTERN,
|
|
27
|
+
ORIGIN_URL_PATTERN,
|
|
28
|
+
OWNER_FIELD,
|
|
29
|
+
PULL_REQUEST_SCRIPT_CREATE_PATTERN,
|
|
30
|
+
REMOTE_BRANCH_PREFIX,
|
|
31
|
+
REPO_FIELD,
|
|
32
|
+
REPO_OPTION_PATTERN,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class OpenRequest:
|
|
38
|
+
"""One pull request a tool call asks to open.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
owner: Repository owner, or empty when it could not be read.
|
|
42
|
+
repo: Repository name, or empty when it could not be read.
|
|
43
|
+
head: The commit the pull request would open with, or None.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
owner: str
|
|
47
|
+
repo: str
|
|
48
|
+
head: str | None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def slug(self) -> str:
|
|
52
|
+
"""Return ``owner/repo``."""
|
|
53
|
+
return f"{self.owner}/{self.repo}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def open_request_from(all_payload_fields: dict[str, object]) -> OpenRequest | None:
|
|
57
|
+
"""Read the pull request open a PreToolUse payload asks for.
|
|
58
|
+
|
|
59
|
+
::
|
|
60
|
+
|
|
61
|
+
mcp__github__create_pull_request {owner, repo, head}
|
|
62
|
+
gh pr create --repo o/r --head branch
|
|
63
|
+
python pull_request.py create --repo o/r --head branch
|
|
64
|
+
|
|
65
|
+
A branch resolves in the payload's folder or a ``cd`` target, as
|
|
66
|
+
``origin/<branch>`` first. No head means ``HEAD``; no repository, ``origin``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
all_payload_fields: The parsed PreToolUse input.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The open request, or None when the call opens no pull request.
|
|
73
|
+
"""
|
|
74
|
+
tool_name = all_payload_fields.get("tool_name")
|
|
75
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
76
|
+
if not isinstance(tool_name, str) or not isinstance(tool_input, dict):
|
|
77
|
+
return None
|
|
78
|
+
working_folder = all_payload_fields.get(CWD_FIELD)
|
|
79
|
+
folder = working_folder if isinstance(working_folder, str) and working_folder else None
|
|
80
|
+
if tool_name.endswith(CREATE_TOOL_SUFFIX):
|
|
81
|
+
return _tool_open_request(tool_input, folder)
|
|
82
|
+
command = tool_input.get("command")
|
|
83
|
+
if tool_name not in ALL_SHELL_TOOL_NAMES or not isinstance(command, str):
|
|
84
|
+
return None
|
|
85
|
+
return _shell_open_request(command, folder)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tool_open_request(all_tool_input: dict[object, object], folder: str | None) -> OpenRequest:
|
|
89
|
+
branch = all_tool_input.get(HEAD_BRANCH_FIELD)
|
|
90
|
+
return OpenRequest(
|
|
91
|
+
owner=str(all_tool_input.get(OWNER_FIELD, "")),
|
|
92
|
+
repo=str(all_tool_input.get(REPO_FIELD, "")),
|
|
93
|
+
head=_branch_commit(branch, folder) if isinstance(branch, str) and branch else None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _shell_open_request(command: str, folder: str | None) -> OpenRequest | None:
|
|
98
|
+
options_only = CREATE_TEXT_OPTION_PATTERN.sub(" ", command)
|
|
99
|
+
create_match = GH_PR_CREATE_PATTERN.search(
|
|
100
|
+
options_only
|
|
101
|
+
) or PULL_REQUEST_SCRIPT_CREATE_PATTERN.search(options_only)
|
|
102
|
+
if not create_match:
|
|
103
|
+
return None
|
|
104
|
+
folder = _folder_after_changes(options_only[: create_match.start()], folder)
|
|
105
|
+
rest = create_match["rest"]
|
|
106
|
+
repo_match = REPO_OPTION_PATTERN.search(rest)
|
|
107
|
+
owner, repo = (
|
|
108
|
+
(repo_match["owner"], repo_match["repo"]) if repo_match else _origin_repository(folder)
|
|
109
|
+
)
|
|
110
|
+
head_match = HEAD_OPTION_PATTERN.search(rest)
|
|
111
|
+
branch = head_match["head"] if head_match else CURRENT_HEAD_REFERENCE
|
|
112
|
+
return OpenRequest(owner=owner, repo=repo, head=_branch_commit(branch, folder))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _folder_after_changes(command_before_create: str, folder: str | None) -> str | None:
|
|
116
|
+
all_changes = list(CHANGE_FOLDER_PATTERN.finditer(command_before_create))
|
|
117
|
+
if not all_changes:
|
|
118
|
+
return folder
|
|
119
|
+
target = Path(os.path.expanduser(all_changes[-1]["folder"].strip("\"'")))
|
|
120
|
+
if not target.is_absolute() and folder:
|
|
121
|
+
target = Path(folder) / target
|
|
122
|
+
return str(target)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_git(all_arguments: list[str], folder: str | None) -> str | None:
|
|
126
|
+
try:
|
|
127
|
+
completed = subprocess.run(
|
|
128
|
+
["git", *all_arguments],
|
|
129
|
+
cwd=folder,
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
timeout=GIT_TIMEOUT_SECONDS,
|
|
133
|
+
check=False,
|
|
134
|
+
)
|
|
135
|
+
except (OSError, subprocess.SubprocessError):
|
|
136
|
+
return None
|
|
137
|
+
if completed.returncode != 0:
|
|
138
|
+
return None
|
|
139
|
+
return completed.stdout.strip() or None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _branch_commit(branch: str, folder: str | None) -> str | None:
|
|
143
|
+
branch_name = branch.split(":", 1)[-1]
|
|
144
|
+
all_references = (
|
|
145
|
+
(branch_name,)
|
|
146
|
+
if branch_name == CURRENT_HEAD_REFERENCE
|
|
147
|
+
else (REMOTE_BRANCH_PREFIX + branch_name, branch_name)
|
|
148
|
+
)
|
|
149
|
+
for each_reference in all_references:
|
|
150
|
+
commit = _run_git(
|
|
151
|
+
["rev-parse", "--verify", "--quiet", f"{each_reference}^{{commit}}"], folder
|
|
152
|
+
)
|
|
153
|
+
if commit:
|
|
154
|
+
return commit
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _origin_repository(folder: str | None) -> tuple[str, str]:
|
|
159
|
+
origin_url = _run_git(list(ALL_ORIGIN_URL_ARGUMENTS), folder)
|
|
160
|
+
origin_match = ORIGIN_URL_PATTERN.search(origin_url) if origin_url else None
|
|
161
|
+
if not origin_match:
|
|
162
|
+
return "", ""
|
|
163
|
+
return origin_match["owner"], origin_match["repo"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Read the review receipts ``scripts/merge_review_receipt.py`` writes.
|
|
2
|
+
|
|
3
|
+
A numbered receipt covers one pull request. An open receipt covers one head
|
|
4
|
+
reviewed before its pull request opened.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent.parent)
|
|
15
|
+
if hooks_directory not in sys.path:
|
|
16
|
+
sys.path.insert(0, hooks_directory)
|
|
17
|
+
|
|
18
|
+
from hooks_constants.merge_review_gate_constants import (
|
|
19
|
+
ALL_RECEIPT_DIRECTORY_SEGMENTS,
|
|
20
|
+
OPEN_HEAD_PATTERN,
|
|
21
|
+
OPEN_RECEIPT_DIRECTORY_NAME,
|
|
22
|
+
OPEN_RECEIPT_FILE_NAME_TEMPLATE,
|
|
23
|
+
OPEN_RECEIPT_GLOB_TEMPLATE,
|
|
24
|
+
OPEN_RECEIPT_HEAD_PATTERN,
|
|
25
|
+
RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE,
|
|
26
|
+
RECEIPT_FILE_NAME_TEMPLATE,
|
|
27
|
+
RECEIPT_FINDINGS_KEY,
|
|
28
|
+
RECEIPT_HEAD_KEY,
|
|
29
|
+
RECEIPT_RULED_OUT_KEY,
|
|
30
|
+
RECEIPT_RULINGS_KEY,
|
|
31
|
+
RULINGS_LINK_PATTERN,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def receipt_directory() -> Path:
|
|
36
|
+
"""Return the folder that holds one review receipt per pull request.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The folder CLAUDE_MERGE_REVIEW_RECEIPT_DIR names, else ``~/.claude/merge-reviews``.
|
|
40
|
+
"""
|
|
41
|
+
override = os.environ.get(RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE)
|
|
42
|
+
if override:
|
|
43
|
+
return Path(override)
|
|
44
|
+
return Path.home().joinpath(*ALL_RECEIPT_DIRECTORY_SEGMENTS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def receipt_path(owner: str, repo: str, number: int) -> Path:
|
|
48
|
+
"""Return where the review receipt for one pull request lives.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
owner: Repository owner.
|
|
52
|
+
repo: Repository name.
|
|
53
|
+
number: Pull request number.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The receipt file path.
|
|
57
|
+
"""
|
|
58
|
+
return receipt_directory() / RECEIPT_FILE_NAME_TEMPLATE.format(
|
|
59
|
+
owner=owner.casefold(), repo=repo.casefold(), number=number
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def open_receipt_path(owner: str, repo: str, head: str) -> Path:
|
|
64
|
+
"""Return where the review receipt for a head reviewed before its pull request opened lives.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
owner: Repository owner.
|
|
68
|
+
repo: Repository name.
|
|
69
|
+
head: The commit the pass reviewed.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The receipt file path.
|
|
73
|
+
"""
|
|
74
|
+
return (
|
|
75
|
+
receipt_directory()
|
|
76
|
+
/ OPEN_RECEIPT_DIRECTORY_NAME
|
|
77
|
+
/ OPEN_RECEIPT_FILE_NAME_TEMPLATE.format(
|
|
78
|
+
owner=owner.casefold(), repo=repo.casefold(), head=head.casefold()
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def has_clean_open_review(repo: str, head: str) -> bool:
|
|
84
|
+
"""Return whether a pass recorded before the pull request opened found zero findings at a head.
|
|
85
|
+
|
|
86
|
+
The head may be a full or an abbreviated commit, matched against each
|
|
87
|
+
recorded head by prefix. The owner is left out of the match, since a
|
|
88
|
+
checkout whose origin still names a transferred repository's old owner
|
|
89
|
+
opens against the new one::
|
|
90
|
+
|
|
91
|
+
open receipt {head: abc1234..., findings: 0}, head abc1234 -> True
|
|
92
|
+
open receipt {head: abc1234..., findings: 2}, head abc1234 -> False
|
|
93
|
+
no open receipt for the repository -> False
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
repo: Repository name.
|
|
97
|
+
head: The commit the open or merge names.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True when a clean open receipt covers that head.
|
|
101
|
+
"""
|
|
102
|
+
if not OPEN_HEAD_PATTERN.fullmatch(head):
|
|
103
|
+
return False
|
|
104
|
+
folder = receipt_directory() / OPEN_RECEIPT_DIRECTORY_NAME
|
|
105
|
+
pattern = OPEN_RECEIPT_GLOB_TEMPLATE.format(repo=repo.casefold())
|
|
106
|
+
return any(_is_clean_open_receipt(each_path, head) for each_path in folder.glob(pattern))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_clean_open_receipt(path: Path, head: str) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
all_receipt_fields = json.loads(path.read_text(encoding="utf-8"))
|
|
112
|
+
except (OSError, ValueError):
|
|
113
|
+
return False
|
|
114
|
+
if not isinstance(all_receipt_fields, dict):
|
|
115
|
+
return False
|
|
116
|
+
recorded_head = all_receipt_fields.get(RECEIPT_HEAD_KEY)
|
|
117
|
+
return (
|
|
118
|
+
isinstance(recorded_head, str)
|
|
119
|
+
and OPEN_RECEIPT_HEAD_PATTERN.fullmatch(recorded_head) is not None
|
|
120
|
+
and same_commit(recorded_head, head)
|
|
121
|
+
and _is_count(all_receipt_fields.get(RECEIPT_FINDINGS_KEY))
|
|
122
|
+
and all_receipt_fields.get(RECEIPT_FINDINGS_KEY) == 0
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_clean_receipt(
|
|
127
|
+
all_receipt_fields: dict[str, object], owner: str, repo: str, number: int
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""Return whether a recorded pass lets the merge through.
|
|
130
|
+
|
|
131
|
+
A pass is clean when it reported zero findings, or when every finding it
|
|
132
|
+
reported is ruled out and the receipt links the rulings comment posted on
|
|
133
|
+
this same pull request::
|
|
134
|
+
|
|
135
|
+
findings 0 -> clean
|
|
136
|
+
findings 2, ruled out 2, .../acme/tools/pull/12#issuecomment-9 -> clean
|
|
137
|
+
findings 2, ruled out 2, .../acme/other/pull/12#issuecomment-9 -> not clean
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
all_receipt_fields: The parsed receipt of one review pass.
|
|
141
|
+
owner: Owner of the pull request the merge targets.
|
|
142
|
+
repo: Repository of the pull request the merge targets.
|
|
143
|
+
number: Number of the pull request the merge targets.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
True when the pass counts as clean.
|
|
147
|
+
"""
|
|
148
|
+
finding_count = all_receipt_fields.get(RECEIPT_FINDINGS_KEY)
|
|
149
|
+
if not _is_count(finding_count):
|
|
150
|
+
return False
|
|
151
|
+
return finding_count == 0 or _rules_out_every_finding(
|
|
152
|
+
all_receipt_fields, finding_count, owner, repo, number
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def same_commit(first: str, second: str) -> bool:
|
|
157
|
+
"""Return whether two commit names match, one being a prefix of the other.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
first: A full or abbreviated commit.
|
|
161
|
+
second: A full or abbreviated commit.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
True when the shorter name starts the longer one.
|
|
165
|
+
"""
|
|
166
|
+
shorter, longer = sorted((first.casefold(), second.casefold()), key=len)
|
|
167
|
+
return longer.startswith(shorter)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_count(candidate: object) -> bool:
|
|
171
|
+
return isinstance(candidate, int) and not isinstance(candidate, bool) and candidate >= 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _rules_out_every_finding(
|
|
175
|
+
all_receipt_fields: dict[str, object], finding_count: object, owner: str, repo: str, number: int
|
|
176
|
+
) -> bool:
|
|
177
|
+
ruled_out_count = all_receipt_fields.get(RECEIPT_RULED_OUT_KEY)
|
|
178
|
+
return (
|
|
179
|
+
_is_count(ruled_out_count)
|
|
180
|
+
and ruled_out_count == finding_count
|
|
181
|
+
and _links_rulings_on(all_receipt_fields.get(RECEIPT_RULINGS_KEY), owner, repo, number)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _links_rulings_on(rulings: object, owner: str, repo: str, number: int) -> bool:
|
|
186
|
+
rulings_match = RULINGS_LINK_PATTERN.fullmatch(rulings) if isinstance(rulings, str) else None
|
|
187
|
+
return (
|
|
188
|
+
rulings_match is not None
|
|
189
|
+
and rulings_match["owner"].casefold() == owner.casefold()
|
|
190
|
+
and rulings_match["repo"].casefold() == repo.casefold()
|
|
191
|
+
and int(rulings_match["number"]) == number
|
|
192
|
+
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Behavior tests for the clean-review merge gate."""
|
|
2
2
|
|
|
3
|
+
import subprocess
|
|
3
4
|
import sys
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -199,3 +200,220 @@ def test_should_deny_auto_merge_pinned_to_a_newer_head() -> None:
|
|
|
199
200
|
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
200
201
|
command = f"gh pr merge 12 --repo acme/tools --auto --match-head-commit {NEW_HEAD}"
|
|
201
202
|
assert "Review the new head" in _reason(_bash(command))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _git(folder: Path, *all_arguments: str) -> str:
|
|
206
|
+
completed = subprocess.run(
|
|
207
|
+
["git", "-C", str(folder), *all_arguments], capture_output=True, text=True, check=True
|
|
208
|
+
)
|
|
209
|
+
return completed.stdout.strip()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.fixture
|
|
213
|
+
def branch_checkout(tmp_path: Path) -> tuple[Path, str]:
|
|
214
|
+
folder = tmp_path / "checkout"
|
|
215
|
+
folder.mkdir()
|
|
216
|
+
_git(folder, "init", "--quiet", "--initial-branch", "main")
|
|
217
|
+
_git(folder, "remote", "add", "origin", "https://github.com/acme/tools.git")
|
|
218
|
+
_git(folder, "checkout", "--quiet", "-b", "feature")
|
|
219
|
+
_git(
|
|
220
|
+
folder,
|
|
221
|
+
"-c",
|
|
222
|
+
"user.name=t",
|
|
223
|
+
"-c",
|
|
224
|
+
"user.email=t@example.com",
|
|
225
|
+
"commit",
|
|
226
|
+
"--quiet",
|
|
227
|
+
"--allow-empty",
|
|
228
|
+
"-m",
|
|
229
|
+
"first",
|
|
230
|
+
)
|
|
231
|
+
return folder, _git(folder, "rev-parse", "HEAD")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _create_tool(folder: Path, head: str = "feature") -> dict[str, object]:
|
|
235
|
+
return {
|
|
236
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
237
|
+
"tool_input": {"owner": "acme", "repo": "tools", "head": head, "base": "main"},
|
|
238
|
+
"cwd": str(folder),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _bash_in(folder: Path, command: str) -> dict[str, object]:
|
|
243
|
+
return {"tool_name": "Bash", "tool_input": {"command": command}, "cwd": str(folder)}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_should_deny_a_pull_request_open_with_no_review(branch_checkout: tuple[Path, str]) -> None:
|
|
247
|
+
folder, head = branch_checkout
|
|
248
|
+
reason = _reason(_create_tool(folder))
|
|
249
|
+
assert f"no clean review for head {head}" in reason
|
|
250
|
+
assert f"acme/tools open {head} 0" in reason
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_should_allow_a_pull_request_open_reviewed_clean_at_its_head(
|
|
254
|
+
branch_checkout: tuple[Path, str],
|
|
255
|
+
) -> None:
|
|
256
|
+
folder, head = branch_checkout
|
|
257
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
258
|
+
assert _reason(_create_tool(folder)) is None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_should_deny_a_pull_request_open_whose_pass_found_findings(
|
|
262
|
+
branch_checkout: tuple[Path, str],
|
|
263
|
+
) -> None:
|
|
264
|
+
folder, head = branch_checkout
|
|
265
|
+
merge_review_receipt.record_open_review("acme/tools", head, 2)
|
|
266
|
+
assert "no clean review" in _reason(_create_tool(folder))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_should_deny_a_pull_request_open_after_a_push_past_the_review(
|
|
270
|
+
branch_checkout: tuple[Path, str],
|
|
271
|
+
) -> None:
|
|
272
|
+
folder, _head = branch_checkout
|
|
273
|
+
merge_review_receipt.record_open_review("acme/tools", NEW_HEAD, 0)
|
|
274
|
+
assert "no clean review" in _reason(_create_tool(folder))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_should_read_gh_pr_create_with_repo_and_head(branch_checkout: tuple[Path, str]) -> None:
|
|
278
|
+
folder, head = branch_checkout
|
|
279
|
+
command = "gh pr create --repo acme/tools --head feature --title t --body b"
|
|
280
|
+
assert "no clean review" in _reason(_bash_in(folder, command))
|
|
281
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
282
|
+
assert _reason(_bash_in(folder, command)) is None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_should_read_gh_pr_create_repository_from_origin(branch_checkout: tuple[Path, str]) -> None:
|
|
286
|
+
folder, head = branch_checkout
|
|
287
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
288
|
+
assert _reason(_bash_in(folder, "gh pr create --fill")) is None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_should_read_the_pull_request_script_create(branch_checkout: tuple[Path, str]) -> None:
|
|
292
|
+
folder, head = branch_checkout
|
|
293
|
+
command = "python scripts/pull_request.py create --repo acme/tools --head feature"
|
|
294
|
+
assert f"head {head}" in _reason(_bash_in(folder, command))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_should_deny_a_pull_request_open_whose_head_cannot_be_read(tmp_path: Path) -> None:
|
|
298
|
+
reason = _reason(_create_tool(tmp_path, head="missing-branch"))
|
|
299
|
+
assert "names no repository or head commit" in reason
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_should_allow_a_pull_request_open_in_an_exempt_repository(
|
|
303
|
+
branch_checkout: tuple[Path, str], monkeypatch: pytest.MonkeyPatch
|
|
304
|
+
) -> None:
|
|
305
|
+
folder, _head = branch_checkout
|
|
306
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", "acme/tools")
|
|
307
|
+
assert _reason(_create_tool(folder)) is None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_should_allow_a_merge_pinned_to_the_head_reviewed_clean_before_open() -> None:
|
|
311
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
312
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
313
|
+
command = f"gh pr merge 12 --repo acme/tools --match-head-commit {CLEAN_HEAD[:7]}"
|
|
314
|
+
assert _reason(_bash(command)) is None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_should_deny_a_merge_pinned_past_the_head_reviewed_before_open() -> None:
|
|
318
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
319
|
+
assert "no clean review" in _reason(_merge_tool(NEW_HEAD))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_should_deny_an_unpinned_merge_with_only_an_open_review() -> None:
|
|
323
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
324
|
+
assert "no clean review" in _reason(_merge_tool())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_should_not_accept_an_open_review_from_another_repository() -> None:
|
|
328
|
+
merge_review_receipt.record_open_review("acme/other", CLEAN_HEAD, 0)
|
|
329
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_should_accept_an_open_review_recorded_under_the_old_owner() -> None:
|
|
333
|
+
merge_review_receipt.record_open_review("oldowner/tools", CLEAN_HEAD, 0)
|
|
334
|
+
assert _reason(_merge_tool(CLEAN_HEAD)) is None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_should_deny_a_merge_whose_numbered_pass_found_findings_at_the_open_reviewed_head() -> None:
|
|
338
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
339
|
+
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 2)
|
|
340
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_should_ignore_an_open_receipt_whose_head_is_not_a_commit(receipt_folder: Path) -> None:
|
|
344
|
+
heads_folder = receipt_folder / "heads"
|
|
345
|
+
heads_folder.mkdir()
|
|
346
|
+
(heads_folder / "acme__tools__a.json").write_text('{"head": "a", "findings": 0}', encoding="utf-8")
|
|
347
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_should_check_a_merge_chained_after_a_reviewed_open(
|
|
351
|
+
branch_checkout: tuple[Path, str],
|
|
352
|
+
) -> None:
|
|
353
|
+
folder, head = branch_checkout
|
|
354
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
355
|
+
command = (
|
|
356
|
+
"gh pr create --repo acme/tools --head feature --fill && "
|
|
357
|
+
f"gh pr merge 12 --repo acme/tools --match-head-commit {NEW_HEAD}"
|
|
358
|
+
)
|
|
359
|
+
assert "no clean review" in _reason(_bash_in(folder, command))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_should_read_the_pushed_branch_before_the_local_one(
|
|
363
|
+
branch_checkout: tuple[Path, str],
|
|
364
|
+
) -> None:
|
|
365
|
+
folder, local_head = branch_checkout
|
|
366
|
+
_git(
|
|
367
|
+
folder,
|
|
368
|
+
"-c",
|
|
369
|
+
"user.name=t",
|
|
370
|
+
"-c",
|
|
371
|
+
"user.email=t@example.com",
|
|
372
|
+
"commit",
|
|
373
|
+
"--quiet",
|
|
374
|
+
"--allow-empty",
|
|
375
|
+
"-m",
|
|
376
|
+
"pushed",
|
|
377
|
+
)
|
|
378
|
+
pushed_head = _git(folder, "rev-parse", "HEAD")
|
|
379
|
+
_git(folder, "update-ref", "refs/remotes/origin/feature", pushed_head)
|
|
380
|
+
_git(folder, "reset", "--quiet", "--hard", local_head)
|
|
381
|
+
merge_review_receipt.record_open_review("acme/tools", local_head, 0)
|
|
382
|
+
assert f"head {pushed_head}" in _reason(_create_tool(folder))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_should_not_match_an_open_receipt_on_a_short_pinned_head() -> None:
|
|
386
|
+
merge_review_receipt.record_open_review("acme/tools", CLEAN_HEAD, 0)
|
|
387
|
+
assert "no clean review" in _reason(_merge_tool(CLEAN_HEAD[:3]))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_should_allow_an_open_in_an_exempt_repository_with_an_unread_head(
|
|
391
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
392
|
+
) -> None:
|
|
393
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES", "acme/tools")
|
|
394
|
+
assert _reason(_create_tool(tmp_path, head="missing-branch")) is None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_should_skip_options_quoted_inside_the_title_or_body(
|
|
398
|
+
branch_checkout: tuple[Path, str],
|
|
399
|
+
) -> None:
|
|
400
|
+
folder, head = branch_checkout
|
|
401
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
402
|
+
command = (
|
|
403
|
+
"gh pr create --repo acme/tools --title 'use -R other/x' "
|
|
404
|
+
f"--body \"curl -H 'Accept: x'; -H {NEW_HEAD}\" --head feature"
|
|
405
|
+
)
|
|
406
|
+
assert _reason(_bash_in(folder, command)) is None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_should_read_the_head_in_the_folder_a_cd_moves_to(
|
|
410
|
+
branch_checkout: tuple[Path, str], tmp_path: Path
|
|
411
|
+
) -> None:
|
|
412
|
+
folder, head = branch_checkout
|
|
413
|
+
merge_review_receipt.record_open_review("acme/tools", head, 0)
|
|
414
|
+
elsewhere = tmp_path / "elsewhere"
|
|
415
|
+
elsewhere.mkdir()
|
|
416
|
+
assert _reason(_bash_in(elsewhere, f'cd "{folder}" && gh pr create --fill')) is None
|
|
417
|
+
assert "names no repository or head commit" in _reason(
|
|
418
|
+
_bash_in(folder, f'cd "{elsewhere}" && gh pr create --repo acme/tools --fill')
|
|
419
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Behavioral tests for reading a pull request open from a PreToolUse payload.
|
|
2
|
+
|
|
3
|
+
gh pr create --repo acme/tools --title "--head evil" -> acme/tools, quoted option ignored
|
|
4
|
+
mcp create_pull_request {owner, repo} -> acme/tools, no head
|
|
5
|
+
gh pr merge 12 -> no open
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from blocking.merge_review_gate_parts.open_request import OpenRequest, open_request_from
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _shell_payload(command: str, folder: Path) -> dict[str, object]:
|
|
16
|
+
return {"tool_name": "Bash", "tool_input": {"command": command}, "cwd": str(folder)}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_should_read_the_repository_and_ignore_options_quoted_in_the_title(tmp_path: Path) -> None:
|
|
20
|
+
payload = _shell_payload('gh pr create --repo acme/tools --title "--head evil"', tmp_path)
|
|
21
|
+
assert open_request_from(payload) == OpenRequest(owner="acme", repo="tools", head=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_should_read_the_create_tool_with_no_head_branch(tmp_path: Path) -> None:
|
|
25
|
+
payload = {
|
|
26
|
+
"tool_name": "mcp__github__create_pull_request",
|
|
27
|
+
"tool_input": {"owner": "acme", "repo": "tools"},
|
|
28
|
+
"cwd": str(tmp_path),
|
|
29
|
+
}
|
|
30
|
+
assert open_request_from(payload) == OpenRequest(owner="acme", repo="tools", head=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_should_return_none_for_a_command_that_opens_nothing(tmp_path: Path) -> None:
|
|
34
|
+
assert open_request_from(_shell_payload("gh pr merge 12", tmp_path)) is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_should_name_the_slug_from_owner_and_repo() -> None:
|
|
38
|
+
assert OpenRequest(owner="acme", repo="tools", head=None).slug == "acme/tools"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Behavioral tests for the merge review receipt readers.
|
|
2
|
+
|
|
3
|
+
open receipt {head: <full sha>, findings: 0}, head <short sha> -> clean
|
|
4
|
+
open receipt {head: <full sha>, findings: 1}, head <short sha> -> not clean
|
|
5
|
+
numbered receipt path -> lowercased owner and repo
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from blocking.merge_review_gate_parts.receipts import (
|
|
15
|
+
has_clean_open_review,
|
|
16
|
+
open_receipt_path,
|
|
17
|
+
receipt_path,
|
|
18
|
+
same_commit,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
HEAD = "abc1234def5678abc1234def5678abc1234def56"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def receipt_folder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
26
|
+
monkeypatch.setenv("CLAUDE_MERGE_REVIEW_RECEIPT_DIR", str(tmp_path))
|
|
27
|
+
return tmp_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write_open_receipt(finding_count: int) -> None:
|
|
31
|
+
path = open_receipt_path("acme", "tools", HEAD)
|
|
32
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path.write_text(json.dumps({"head": HEAD, "findings": finding_count}), encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_should_find_a_clean_open_review_by_short_head_under_any_owner() -> None:
|
|
37
|
+
_write_open_receipt(0)
|
|
38
|
+
assert has_clean_open_review("Tools", HEAD[:7]) is True
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_should_reject_an_open_review_that_counts_findings() -> None:
|
|
42
|
+
_write_open_receipt(1)
|
|
43
|
+
assert has_clean_open_review("tools", HEAD[:7]) is False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_should_reject_a_head_that_is_not_a_commit() -> None:
|
|
47
|
+
_write_open_receipt(0)
|
|
48
|
+
assert has_clean_open_review("tools", "main") is False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_should_lowercase_owner_and_repo_in_the_numbered_receipt_path(receipt_folder: Path) -> None:
|
|
52
|
+
assert receipt_path("Acme", "Tools", 12) == receipt_folder / "acme__tools__12.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_should_match_commits_by_prefix_in_either_order() -> None:
|
|
56
|
+
assert same_commit(HEAD[:7].upper(), HEAD) is True
|
|
57
|
+
assert same_commit(HEAD, "def5678") is False
|
|
@@ -20,7 +20,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
20
20
|
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on that same thread that posted after the last user prompt and that no successful resolve of the thread has spent. A resolve is a `resolve_review_thread` call, which names the thread by `threadID`; a `pull_request_review_write` call with method `resolve_thread`, which names it by `threadId`; a shell command with a `gh api graphql` `resolveReviewThread` mutation, which names it by a literal `PRRT_` id; or a shell call to `pulls/<n>/ccr/comments/<id>/resolve`, which names it by a comment id. A graphql resolve that names no `PRRT_` id is denied. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`, which names its thread by `PRRT_` id. A reply and a resolve by comment id match when they name the same comment, or when a successful tool result in the transcript, such as `pull_request_read` `get_review_comments`, lists the comment under the thread by its `discussion_r` anchor or `databaseId`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
|
|
21
21
|
- `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
|
|
22
22
|
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
23
|
-
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. 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. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
|
|
23
|
+
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. 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. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes. It also denies a pull request open, through `gh pr create`, `pull_request.py create` or a `create_pull_request` tool, until a pass recorded as `merge_review_receipt.py <owner/name> open <head> 0` covers the head the pull request opens with. A merge pinned to that same head passes on the open receipt.
|
|
24
24
|
- `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
|
|
25
25
|
- `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.
|
|
26
26
|
- `blocking/send_user_file_worktree_root_gate.py` denies a `SendUserFile` call that names a file outside the session's working directory, the `cwd` in the hook payload. The desktop file pane opens a sent file only under that folder and shows "Couldn't find this file" for any other path. The reason lists each outside file and names `<cwd>\.scratch\` as the folder to copy it into.
|
|
@@ -43,7 +43,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
43
43
|
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__resolve_review_thread|mcp__.*__pull_request_review_write`, timeout `10` seconds in `hooks.json`.
|
|
44
44
|
- `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
|
|
45
45
|
- `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
|
|
46
|
-
- `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`.
|
|
46
|
+
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
47
47
|
- `blocking/merge_over_review_offer_gate.py` runs on `PreToolUse`, matcher `AskUserQuestion|mcp__.*__ask_decision`, timeout `10` seconds in `hooks.json`.
|
|
48
48
|
- `blocking/artifact_dark_mode_gate.py` runs on `PreToolUse`, matcher `Artifact`, timeout `60` seconds in `hooks.json`.
|
|
49
49
|
- `blocking/send_user_file_worktree_root_gate.py` runs on `PreToolUse`, matcher `SendUserFile`, timeout `10` seconds in `hooks.json`.
|
|
@@ -70,7 +70,7 @@ Preconditions:
|
|
|
70
70
|
- **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.
|
|
71
71
|
- **Verify before commit.** Input is `git add app.py; git commit -m change` in a temporary repository with `.agents/skills/verify`, and a transcript with no `verify` load after its last prompt. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_verify_before_commit_gate.py -q`. The adjacent test observes a denial, then a silent allow after a `verify` Skill call or a `/verify` prompt. A load before the last prompt still denies. A docs-only commit, a tests-only commit, and a repository with no verify skill pass.
|
|
72
72
|
- **Eval write.** Input is a Write to `evals/cases.json` with no build-eval call in the transcript. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_build_eval_before_eval_write_gate.py -q`. The adjacent test observes a denial, then an allow once the call appears; a write outside an eval path passes.
|
|
73
|
-
- **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.
|
|
73
|
+
- **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. An open with no clean open receipt at its head is denied; an open reviewed clean at its head, and a merge pinned to that head, pass.
|
|
74
74
|
- **Merge offer.** Input is a decision card or AskUserQuestion that offers "Merge now", the merge queue, or a yes to "Merge it now?" while any part of the card names open findings or unaddressed review comments. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_merge_over_review_offer_gate.py -q`. The adjacent test observes a denial for each, including an offer and findings in separate questions; a card with no merge offer, a card about the merge review check itself, a card about shipping a docs change, and a merge offer after a clean review pass.
|
|
75
75
|
- **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.
|
|
76
76
|
- **Sent file root.** Input is a `SendUserFile` call for a file on `Y:\` or in the session scratchpad, with `cwd` set to a worktree. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_send_user_file_worktree_root_gate.py -q`. The adjacent test observes a denial that names the file and `<cwd>\.scratch\`; a file under `<cwd>\.scratch\` passes.
|
package/hooks/hooks.json
CHANGED
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
]
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
|
-
"matcher": "Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)",
|
|
136
|
+
"matcher": "Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge)",
|
|
137
137
|
"hooks": [
|
|
138
138
|
{
|
|
139
139
|
"type": "command",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Named constants for merge_review_gate, the clean-review merge gate."""
|
|
1
|
+
"""Named constants for merge_review_gate, the clean-review open and merge gate."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -10,12 +10,39 @@ PERMISSION_DECISION_REASON_KEY = "permissionDecisionReason"
|
|
|
10
10
|
|
|
11
11
|
MERGE_TOOL_SUFFIX = "__merge_pull_request"
|
|
12
12
|
AUTO_MERGE_TOOL_SUFFIX = "__enable_pr_auto_merge"
|
|
13
|
+
CREATE_TOOL_SUFFIX = "__create_pull_request"
|
|
13
14
|
ALL_SHELL_TOOL_NAMES = frozenset({"Bash", "PowerShell"})
|
|
14
15
|
|
|
15
16
|
OWNER_FIELD = "owner"
|
|
16
17
|
REPO_FIELD = "repo"
|
|
17
18
|
NUMBER_FIELD = "pullNumber"
|
|
18
19
|
EXPECTED_HEAD_FIELD = "expectedHeadSha"
|
|
20
|
+
HEAD_BRANCH_FIELD = "head"
|
|
21
|
+
CWD_FIELD = "cwd"
|
|
22
|
+
|
|
23
|
+
GH_PR_CREATE_PATTERN = re.compile(r"(?:^|[\s;&|(])gh(?:\.exe)?\s+pr\s+create\b(?P<rest>[^;&|]*)")
|
|
24
|
+
PULL_REQUEST_SCRIPT_CREATE_PATTERN = re.compile(
|
|
25
|
+
r"pull_request\.py[\"']?\s+create\b(?P<rest>[^;&|]*)"
|
|
26
|
+
)
|
|
27
|
+
HEAD_OPTION_PATTERN = re.compile(r"(?<!\S)(?:-H|--head)(?:=|\s+)[\"']?(?P<head>[^\s\"']+)")
|
|
28
|
+
CHANGE_FOLDER_PATTERN = re.compile(
|
|
29
|
+
r"(?:^|[;&|(]\s*)(?:cd|pushd|Set-Location|sl)\s+(?:-Path\s+|-LiteralPath\s+)?"
|
|
30
|
+
r"(?P<folder>\"[^\"]*\"|'[^']*'|[^\s;&|)]+)",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
33
|
+
CREATE_TEXT_OPTION_PATTERN = re.compile(
|
|
34
|
+
r"(?<!\S)(?:-t|--title|-b|--body|-F|--body-file)(?:=|\s+)(?:\"[^\"]*\"|'[^']*'|\S+)"
|
|
35
|
+
)
|
|
36
|
+
ORIGIN_URL_PATTERN = re.compile(
|
|
37
|
+
r"github\.com[:/](?P<owner>[\w.-]+)/(?P<repo>[\w.-]+?)(?:\.git)?/?$"
|
|
38
|
+
)
|
|
39
|
+
GIT_TIMEOUT_SECONDS = 3
|
|
40
|
+
ALL_ORIGIN_URL_ARGUMENTS = ("remote", "get-url", "origin")
|
|
41
|
+
OPEN_HEAD_PATTERN = re.compile(r"[0-9a-fA-F]{7,40}")
|
|
42
|
+
OPEN_RECEIPT_HEAD_PATTERN = re.compile(r"[0-9a-fA-F]{40}")
|
|
43
|
+
REPOSITORY_SLUG_PATTERN = re.compile(r"[\w.-]+/[\w.-]+")
|
|
44
|
+
REMOTE_BRANCH_PREFIX = "origin/"
|
|
45
|
+
CURRENT_HEAD_REFERENCE = "HEAD"
|
|
19
46
|
|
|
20
47
|
GH_PR_MERGE_PATTERN = re.compile(r"(?:^|[\s;&|(])gh(?:\.exe)?\s+pr\s+merge\b(?P<rest>[^;&|]*)")
|
|
21
48
|
GH_API_MERGE_PATTERN = re.compile(
|
|
@@ -44,6 +71,10 @@ DEFAULT_REVIEW_COMMAND = "/code-review high --max-findings all"
|
|
|
44
71
|
RECEIPT_DIRECTORY_ENVIRONMENT_VARIABLE = "CLAUDE_MERGE_REVIEW_RECEIPT_DIR"
|
|
45
72
|
ALL_RECEIPT_DIRECTORY_SEGMENTS = (".claude", "merge-reviews")
|
|
46
73
|
RECEIPT_FILE_NAME_TEMPLATE = "{owner}__{repo}__{number}.json"
|
|
74
|
+
OPEN_RECEIPT_DIRECTORY_NAME = "heads"
|
|
75
|
+
OPEN_RECEIPT_FILE_NAME_TEMPLATE = "{owner}__{repo}__{head}.json"
|
|
76
|
+
OPEN_RECEIPT_GLOB_TEMPLATE = "*__{repo}__*.json"
|
|
77
|
+
OPEN_TARGET_WORD = "open"
|
|
47
78
|
REPOSITORY_SEPARATOR = ","
|
|
48
79
|
RECEIPT_HEAD_KEY = "head"
|
|
49
80
|
RECEIPT_FINDINGS_KEY = "findings"
|
|
@@ -84,3 +115,17 @@ UNKNOWN_TARGET_REASON = (
|
|
|
84
115
|
"This merge names no repository and pull request number the review gate can read. "
|
|
85
116
|
"Name both, such as `gh pr merge 12 --repo owner/name --match-head-commit <sha>`."
|
|
86
117
|
)
|
|
118
|
+
NO_CLEAN_OPEN_REVIEW_REASON_TEMPLATE = (
|
|
119
|
+
"{slug} has no clean review for head {head}, which this pull request would open with. "
|
|
120
|
+
"Review the branch diff with `{review_command}`, fix and push every finding, and review "
|
|
121
|
+
"again until a pass reports zero findings. Record that pass with `"
|
|
122
|
+
+ RECORD_COMMAND
|
|
123
|
+
+ " {slug} "
|
|
124
|
+
+ OPEN_TARGET_WORD
|
|
125
|
+
+ " {head} 0`, then open the pull request. The merge accepts that receipt while the "
|
|
126
|
+
"head stays {head}."
|
|
127
|
+
)
|
|
128
|
+
UNKNOWN_OPEN_TARGET_REASON = (
|
|
129
|
+
"This pull request names no repository or head commit the review gate can read. Run it "
|
|
130
|
+
"from the branch checkout, or pass `--repo owner/name` and `--head <pushed branch>`."
|
|
131
|
+
)
|
package/package.json
CHANGED
|
@@ -13,8 +13,14 @@ clean once the rulings are posted and linked::
|
|
|
13
13
|
python3 merge_review_receipt.py owner/name 12 def5678 2 --ruled-out 2 --rulings <url>
|
|
14
14
|
-> CLEAN owner/name#12 def5678 (2 findings ruled out: <url>)
|
|
15
15
|
|
|
16
|
+
A pass run before the pull request exists names ``open`` in place of the
|
|
17
|
+
number, and records the head it reviewed::
|
|
18
|
+
|
|
19
|
+
python3 merge_review_receipt.py owner/name open def5678 0 -> CLEAN owner/name open def5678
|
|
20
|
+
|
|
16
21
|
``hooks/blocking/merge_review_gate.py`` allows a merge pinned to the head of
|
|
17
|
-
the newest pass only when that pass is clean.
|
|
22
|
+
the newest pass only when that pass is clean. It allows a pull request open
|
|
23
|
+
whose head has a clean ``open`` pass, and a merge pinned to that same head.
|
|
18
24
|
"""
|
|
19
25
|
|
|
20
26
|
from __future__ import annotations
|
|
@@ -30,12 +36,19 @@ hooks_directory = str(Path(__file__).resolve().parent.parent / "hooks")
|
|
|
30
36
|
if hooks_directory not in sys.path:
|
|
31
37
|
sys.path.insert(0, hooks_directory)
|
|
32
38
|
|
|
33
|
-
from blocking.
|
|
39
|
+
from blocking.merge_review_gate_parts.receipts import (
|
|
40
|
+
is_clean_receipt,
|
|
41
|
+
open_receipt_path,
|
|
42
|
+
receipt_path,
|
|
43
|
+
)
|
|
34
44
|
from hooks_constants.merge_review_gate_constants import (
|
|
45
|
+
OPEN_RECEIPT_HEAD_PATTERN,
|
|
46
|
+
OPEN_TARGET_WORD,
|
|
35
47
|
RECEIPT_FINDINGS_KEY,
|
|
36
48
|
RECEIPT_HEAD_KEY,
|
|
37
49
|
RECEIPT_RULED_OUT_KEY,
|
|
38
50
|
RECEIPT_RULINGS_KEY,
|
|
51
|
+
REPOSITORY_SLUG_PATTERN,
|
|
39
52
|
)
|
|
40
53
|
|
|
41
54
|
|
|
@@ -64,9 +77,29 @@ def record_review(
|
|
|
64
77
|
return _write_receipt(slug, number, receipt)
|
|
65
78
|
|
|
66
79
|
|
|
80
|
+
def record_open_review(slug: str, head: str, finding_count: int) -> Path:
|
|
81
|
+
"""Write the receipt for one review pass run before the pull request opens.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
slug: Repository as ``owner/name``.
|
|
85
|
+
head: The commit the pass reviewed.
|
|
86
|
+
finding_count: How many findings the pass reported.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The receipt path written.
|
|
90
|
+
"""
|
|
91
|
+
owner, repo = slug.split("/", 1)
|
|
92
|
+
return _write_receipt_at(
|
|
93
|
+
open_receipt_path(owner, repo, head), _receipt_fields(head, finding_count, 0, "")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
67
97
|
def _write_receipt(slug: str, number: int, all_receipt_fields: dict[str, object]) -> Path:
|
|
68
98
|
owner, repo = slug.split("/", 1)
|
|
69
|
-
|
|
99
|
+
return _write_receipt_at(receipt_path(owner, repo, number), all_receipt_fields)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_receipt_at(path: Path, all_receipt_fields: dict[str, object]) -> Path:
|
|
70
103
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
104
|
path.write_text(json.dumps(all_receipt_fields) + "\n", encoding="utf-8")
|
|
72
105
|
return path
|
|
@@ -94,7 +127,9 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
|
|
|
94
127
|
"""
|
|
95
128
|
parser = argparse.ArgumentParser(prog="merge_review_receipt")
|
|
96
129
|
parser.add_argument("slug", help="Repository as owner/name.")
|
|
97
|
-
parser.add_argument(
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"number", help=f"Pull request number, or {OPEN_TARGET_WORD} before the pull request opens."
|
|
132
|
+
)
|
|
98
133
|
parser.add_argument("head", help="The head commit the pass reviewed.")
|
|
99
134
|
parser.add_argument("findings", type=int, help="Findings the pass reported.")
|
|
100
135
|
parser.add_argument(
|
|
@@ -103,6 +138,23 @@ def main(all_arguments: Sequence[str], stdout: TextIO = sys.stdout) -> int:
|
|
|
103
138
|
parser.add_argument("--rulings", default="", help="Link to the posted rulings.")
|
|
104
139
|
parsed = parser.parse_args(all_arguments)
|
|
105
140
|
rulings = parsed.rulings.strip()
|
|
141
|
+
if not REPOSITORY_SLUG_PATTERN.fullmatch(parsed.slug) or ".." in parsed.slug:
|
|
142
|
+
parser.error("slug must be owner/name")
|
|
143
|
+
if parsed.number == OPEN_TARGET_WORD:
|
|
144
|
+
if parsed.ruled_out or rulings:
|
|
145
|
+
parser.error(f"an {OPEN_TARGET_WORD} pass is clean only at zero findings")
|
|
146
|
+
if parsed.findings < 0:
|
|
147
|
+
parser.error("findings must be zero or more")
|
|
148
|
+
if not OPEN_RECEIPT_HEAD_PATTERN.fullmatch(parsed.head):
|
|
149
|
+
parser.error(f"an {OPEN_TARGET_WORD} pass needs the full 40 character commit")
|
|
150
|
+
record_open_review(parsed.slug, parsed.head, parsed.findings)
|
|
151
|
+
verdict = "CLEAN" if parsed.findings == 0 else "REVIEWED"
|
|
152
|
+
count_note = "" if parsed.findings == 0 else f" {parsed.findings} findings"
|
|
153
|
+
stdout.write(f"{verdict} {parsed.slug} {OPEN_TARGET_WORD} {parsed.head}{count_note}\n")
|
|
154
|
+
return 0 if parsed.findings == 0 else 1
|
|
155
|
+
if not parsed.number.isdigit():
|
|
156
|
+
parser.error(f"number must be a pull request number or {OPEN_TARGET_WORD}")
|
|
157
|
+
parsed.number = int(parsed.number)
|
|
106
158
|
if parsed.ruled_out < 0 or parsed.ruled_out > parsed.findings:
|
|
107
159
|
parser.error("--ruled-out must be between 0 and the finding count")
|
|
108
160
|
if parsed.ruled_out and not rulings:
|
|
@@ -122,3 +122,56 @@ def test_should_report_reviewed_when_the_rulings_link_points_at_another_pull_req
|
|
|
122
122
|
)
|
|
123
123
|
assert exit_code == 1
|
|
124
124
|
assert verdict_stream.getvalue() == f"REVIEWED acme/tools#12 {HEAD} 2 findings\n"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_should_record_a_clean_open_pass_under_heads(receipt_folder: Path) -> None:
|
|
128
|
+
verdict_stream = StringIO()
|
|
129
|
+
assert merge_review_receipt.main(["acme/tools", "open", HEAD, "0"], verdict_stream) == 0
|
|
130
|
+
assert verdict_stream.getvalue() == f"CLEAN acme/tools open {HEAD}\n"
|
|
131
|
+
receipt = json.loads((receipt_folder / "heads" / f"acme__tools__{HEAD}.json").read_text())
|
|
132
|
+
assert receipt == {"head": HEAD, "findings": 0}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_record_open_review_writes_the_head_receipt_in_lowercase(receipt_folder: Path) -> None:
|
|
136
|
+
path = merge_review_receipt.record_open_review("Acme/Tools", HEAD.upper(), 2)
|
|
137
|
+
assert path == receipt_folder / "heads" / f"acme__tools__{HEAD}.json"
|
|
138
|
+
assert json.loads(path.read_text(encoding="utf-8")) == {"head": HEAD.upper(), "findings": 2}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_should_exit_one_for_an_open_pass_with_findings() -> None:
|
|
142
|
+
verdict_stream = StringIO()
|
|
143
|
+
assert merge_review_receipt.main(["acme/tools", "open", HEAD, "2"], verdict_stream) == 1
|
|
144
|
+
assert verdict_stream.getvalue() == f"REVIEWED acme/tools open {HEAD} 2 findings\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize(
|
|
148
|
+
"all_arguments",
|
|
149
|
+
[
|
|
150
|
+
["acme/tools", "open", HEAD, "-1"],
|
|
151
|
+
["acme/tools", "open", HEAD, "2", "--ruled-out", "2", "--rulings", RULINGS_LINK],
|
|
152
|
+
["acme/tools", "twelve", HEAD, "0"],
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
def test_should_refuse_an_open_pass_it_cannot_record(
|
|
156
|
+
all_arguments: list[str], receipt_folder: Path
|
|
157
|
+
) -> None:
|
|
158
|
+
with pytest.raises(SystemExit):
|
|
159
|
+
merge_review_receipt.main(all_arguments, StringIO())
|
|
160
|
+
assert list(receipt_folder.iterdir()) == []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize("head", ["abc", "abc1234", "../../escape", "zzzzzzz"])
|
|
164
|
+
def test_should_refuse_an_open_pass_whose_head_is_not_a_commit(head: str, receipt_folder: Path) -> None:
|
|
165
|
+
with pytest.raises(SystemExit):
|
|
166
|
+
merge_review_receipt.main(["acme/tools", "open", head, "0"], StringIO())
|
|
167
|
+
assert list(receipt_folder.iterdir()) == []
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize("slug", ["owner-repo", "a/b/../c", "a/.."])
|
|
171
|
+
@pytest.mark.parametrize("number", ["open", "12"])
|
|
172
|
+
def test_should_refuse_a_slug_that_is_not_owner_and_name(
|
|
173
|
+
slug: str, number: str, receipt_folder: Path
|
|
174
|
+
) -> None:
|
|
175
|
+
with pytest.raises(SystemExit):
|
|
176
|
+
merge_review_receipt.main([slug, number, HEAD, "0"], StringIO())
|
|
177
|
+
assert list(receipt_folder.iterdir()) == []
|