claude-dev-env 8.99.0 → 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/bin/install-pstack-plugin.mjs +55 -27
- package/bin/install-pstack-plugin.test.mjs +27 -2
- 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
|
@@ -91,12 +91,13 @@ const HOST_DEFINITIONS = Object.freeze({
|
|
|
91
91
|
* verb gets refresh commands: the marketplace add and plugin install leave an
|
|
92
92
|
* existing entry in place, and the update commands then pull the newest
|
|
93
93
|
* published version over it, so an installed plugin stays installed through
|
|
94
|
-
* the whole run. When
|
|
95
|
-
* verb, the removal commands run and take
|
|
96
|
-
* cached versions, and the marketplace clone,
|
|
97
|
-
* follow fetch the newest published version
|
|
98
|
-
*
|
|
99
|
-
* that marketplace once for the whole
|
|
94
|
+
* the whole run. When a refresh command fails for a reason only a rebuild
|
|
95
|
+
* fixes, or the host has no update verb, the removal commands run and take
|
|
96
|
+
* out each installed plugin, its cached versions, and the marketplace clone,
|
|
97
|
+
* and the install commands that follow fetch the newest published version
|
|
98
|
+
* into an empty slot. Removing a marketplace uninstalls each plugin it
|
|
99
|
+
* supplied, so the plan removes and adds that marketplace once for the whole
|
|
100
|
+
* set.
|
|
100
101
|
*
|
|
101
102
|
* Claude Code and Codex publish a marketplace under different plugin verbs, so
|
|
102
103
|
* the install verb belongs to the host and the marketplace and identifier
|
|
@@ -219,7 +220,12 @@ function runHostCommand(executable, commandArguments, options) {
|
|
|
219
220
|
env: { ...process.env, ...options.environment },
|
|
220
221
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
221
222
|
});
|
|
222
|
-
return {
|
|
223
|
+
return {
|
|
224
|
+
status: spawned.status,
|
|
225
|
+
stdout: spawned.stdout ?? '',
|
|
226
|
+
stderr: spawned.stderr ?? '',
|
|
227
|
+
error: spawned.error,
|
|
228
|
+
};
|
|
223
229
|
}
|
|
224
230
|
|
|
225
231
|
const COMMAND_NOT_FOUND_EXIT_CODE = 9009;
|
|
@@ -297,15 +303,33 @@ function lockedFolderDetail(allSpecs, host, homeDirectory, errorText, findLocker
|
|
|
297
303
|
}).join('');
|
|
298
304
|
}
|
|
299
305
|
|
|
306
|
+
const REBUILD_REQUIRED_PATTERN = /doesn't match its extraKnownMarketplaces entry|Source path does not exist/;
|
|
307
|
+
|
|
308
|
+
function commandFailureDetail(outcome) {
|
|
309
|
+
return singleLineDetail(outcome.stderr) || singleLineDetail(outcome.stdout)
|
|
310
|
+
|| outcome.error?.message || `exit ${outcome.status}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function failedCommandOutcome(allSpecs, host, homeDirectory, executable, commandArguments, outcome, findLockers) {
|
|
314
|
+
const detail = commandFailureDetail(outcome);
|
|
315
|
+
const lockAdvice = lockedFolderDetail(allSpecs, host, homeDirectory, detail, findLockers);
|
|
316
|
+
return {
|
|
317
|
+
host,
|
|
318
|
+
executable,
|
|
319
|
+
status: 'failed',
|
|
320
|
+
warning: `${executable} ${commandArguments.join(' ')} failed: ${detail}${lockAdvice}`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
300
324
|
function refreshInPlace(plan, executable, commandEnvironment, runCommand) {
|
|
301
325
|
for (const commandArguments of plan.refreshCommands) {
|
|
302
326
|
const outcome = runCommand(executable, [...commandArguments], {
|
|
303
327
|
environment: commandEnvironment,
|
|
304
328
|
});
|
|
305
|
-
if (namesAnAbsentCommand(outcome)) return 'absent';
|
|
306
|
-
if (outcome.status !== 0 || outcome.error) return 'failed';
|
|
329
|
+
if (namesAnAbsentCommand(outcome)) return { state: 'absent' };
|
|
330
|
+
if (outcome.status !== 0 || outcome.error) return { state: 'failed', commandArguments, outcome };
|
|
307
331
|
}
|
|
308
|
-
return 'refreshed';
|
|
332
|
+
return { state: 'refreshed' };
|
|
309
333
|
}
|
|
310
334
|
|
|
311
335
|
function removeOrphanedVersionsForHost(allSpecs, host, homeDirectory) {
|
|
@@ -326,11 +350,18 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
326
350
|
}
|
|
327
351
|
if (plan.refreshCommands.length > 0) {
|
|
328
352
|
const refreshOutcome = refreshInPlace(plan, executable, commandEnvironment, runCommand);
|
|
329
|
-
if (refreshOutcome === 'absent') return absentHostOutcome(allSpecs, host, executable);
|
|
330
|
-
if (refreshOutcome === 'refreshed') {
|
|
353
|
+
if (refreshOutcome.state === 'absent') return absentHostOutcome(allSpecs, host, executable);
|
|
354
|
+
if (refreshOutcome.state === 'refreshed') {
|
|
331
355
|
removeOrphanedVersionsForHost(allSpecs, host, homeDirectory);
|
|
332
356
|
return { host, executable, status: 'installed', warning: null };
|
|
333
357
|
}
|
|
358
|
+
const refreshOutput = `${refreshOutcome.outcome.stderr ?? ''}\n${refreshOutcome.outcome.stdout ?? ''}`;
|
|
359
|
+
if (!REBUILD_REQUIRED_PATTERN.test(refreshOutput)) {
|
|
360
|
+
return failedCommandOutcome(
|
|
361
|
+
allSpecs, host, homeDirectory, executable,
|
|
362
|
+
refreshOutcome.commandArguments, refreshOutcome.outcome, findLockers,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
334
365
|
}
|
|
335
366
|
for (const commandArguments of plan.removalCommands) {
|
|
336
367
|
const outcome = runCommand(executable, [...commandArguments], {
|
|
@@ -347,14 +378,9 @@ function installForHost(allSpecs, host, homeDirectory, environment, runCommand,
|
|
|
347
378
|
});
|
|
348
379
|
if (namesAnAbsentCommand(outcome)) return absentHostOutcome(allSpecs, host, executable);
|
|
349
380
|
if (outcome.status !== 0 || outcome.error) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
host,
|
|
354
|
-
executable,
|
|
355
|
-
status: 'failed',
|
|
356
|
-
warning: `${executable} ${commandArguments.join(' ')} failed: ${detail}${lockAdvice}`,
|
|
357
|
-
};
|
|
381
|
+
return failedCommandOutcome(
|
|
382
|
+
allSpecs, host, homeDirectory, executable, commandArguments, outcome, findLockers,
|
|
383
|
+
);
|
|
358
384
|
}
|
|
359
385
|
}
|
|
360
386
|
removeOrphanedVersionsForHost(allSpecs, host, homeDirectory);
|
|
@@ -385,12 +411,14 @@ export function restartNotice(allSpecs, allInstalledHosts) {
|
|
|
385
411
|
* plugin, both of which leave an existing entry alone, then updates the
|
|
386
412
|
* marketplace and each plugin. An installed plugin stays installed and enabled
|
|
387
413
|
* through the run, so a session that starts mid-run still loads it, and a
|
|
388
|
-
* failed
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
414
|
+
* failed refresh command reports the host failed and leaves the prior version
|
|
415
|
+
* in place. Two refresh errors need a rebuild: a source that no longer
|
|
416
|
+
* matches the settings entry, and a source path the sparse clone lacks. The
|
|
417
|
+
* host reads either from the failing refresh command's output, then falls
|
|
418
|
+
* back to the clean install Codex always runs: it removes every plugin in the
|
|
419
|
+
* set and then the marketplace, so an older version never survives beside the
|
|
420
|
+
* new one, and adds them again. A removal that finds nothing to remove exits
|
|
421
|
+
* non-zero on a fresh home, and the install goes on.
|
|
394
422
|
* Before each Claude marketplace add, the install deletes the half-made clone
|
|
395
423
|
* folder an interrupted earlier add left, so the add starts in an empty slot.
|
|
396
424
|
*
|
|
@@ -482,7 +510,7 @@ export function removeRetiredMarketplacePlugins(allRetiredSpecs, options, depend
|
|
|
482
510
|
);
|
|
483
511
|
if (namesAnAbsentCommand(outcome)) break;
|
|
484
512
|
if (outcome.status !== 0 || outcome.error) {
|
|
485
|
-
const detail =
|
|
513
|
+
const detail = commandFailureDetail(outcome);
|
|
486
514
|
failedRemovals.push({ pluginIdentifier: eachSpec.pluginIdentifier, detail });
|
|
487
515
|
continue;
|
|
488
516
|
}
|
|
@@ -117,6 +117,30 @@ test('a failed Claude refresh falls back to removing and adding the marketplace
|
|
|
117
117
|
]);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
test('a Claude refresh error printed on standard output still triggers the rebuild', () => {
|
|
121
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
122
|
+
commandArguments[2] === 'add' && runner.calls.length === 1
|
|
123
|
+
? { status: 1, stdout: "its source doesn't match its extraKnownMarketplaces entry", stderr: '' }
|
|
124
|
+
: { status: 0, stderr: '' }));
|
|
125
|
+
const outcome = installMarketplacePlugins([USAGE_WRAPUP_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
|
|
126
|
+
assert.equal(outcome.status, 'installed');
|
|
127
|
+
assert.ok(runner.calls.some(call => call.commandArguments.includes('uninstall')));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a Claude refresh that fails on the network keeps the installed plugin and reports the failure', () => {
|
|
131
|
+
const runner = recordingRunner((executable, commandArguments) => (
|
|
132
|
+
commandArguments[1] === 'update'
|
|
133
|
+
? { status: 1, stderr: 'Failed to update plugin: getaddrinfo ENOTFOUND github.com' }
|
|
134
|
+
: { status: 0, stderr: '' }));
|
|
135
|
+
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
|
|
136
|
+
assert.equal(outcome.status, 'failed');
|
|
137
|
+
assert.match(outcome.hosts[0].warning, /claude plugin update pstack@pstack-claude failed: .*ENOTFOUND/);
|
|
138
|
+
const allRemovalCalls = runner.calls.filter(call => (
|
|
139
|
+
call.commandArguments.includes('uninstall') || call.commandArguments.includes('remove')
|
|
140
|
+
));
|
|
141
|
+
assert.deepEqual(allRemovalCalls, []);
|
|
142
|
+
});
|
|
143
|
+
|
|
120
144
|
test('each host removes the installed plugin and marketplace before a clean install', () => {
|
|
121
145
|
assert.deepEqual(marketplacePluginsPlan([PSTACK_PLUGIN_SPEC], 'claude').removalCommands, [
|
|
122
146
|
['plugin', 'uninstall', PSTACK_PLUGIN_IDENTIFIER],
|
|
@@ -131,9 +155,10 @@ test('each host removes the installed plugin and marketplace before a clean inst
|
|
|
131
155
|
test('a removal that finds nothing installed still lets the install run', () => {
|
|
132
156
|
const runner = recordingRunner((executable, commandArguments) => (
|
|
133
157
|
commandArguments.includes('uninstall') || commandArguments.includes('remove')
|
|
134
|
-
|| commandArguments.includes('update')
|
|
135
158
|
? { status: 1, stderr: 'not installed' }
|
|
136
|
-
:
|
|
159
|
+
: commandArguments.includes('update')
|
|
160
|
+
? { status: 1, stderr: 'Source path does not exist: plugins/pstack' }
|
|
161
|
+
: { status: 0, stderr: '' }));
|
|
137
162
|
const outcome = installMarketplacePlugins([PSTACK_PLUGIN_SPEC], { ...ROOTS, hosts: ['claude'] }, runner);
|
|
138
163
|
assert.equal(outcome.status, 'installed');
|
|
139
164
|
assert.deepEqual(runner.calls.at(-1).commandArguments, ['plugin', 'install', PSTACK_PLUGIN_IDENTIFIER]);
|
|
@@ -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"]
|