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.
@@ -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 any refresh command fails, or the host has no update
95
- * verb, the removal commands run and take out each installed plugin, its
96
- * cached versions, and the marketplace clone, and the install commands that
97
- * follow fetch the newest published version into an empty slot. Removing a
98
- * marketplace uninstalls each plugin it supplied, so the plan removes and adds
99
- * that marketplace once for the whole set.
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 { status: spawned.status, stderr: spawned.stderr ?? '', error: spawned.error };
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
- const detail = singleLineDetail(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
351
- const lockAdvice = lockedFolderDetail(allSpecs, host, homeDirectory, detail, findLockers);
352
- return {
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 network step leaves the prior version in place. When a refresh
389
- * command fails, such as a marketplace add whose sparse paths changed, the
390
- * host falls back to the clean install Codex always runs: it removes every
391
- * plugin in the set and then the marketplace, so an older version never
392
- * survives beside the new one, and adds them again. A removal that finds
393
- * nothing to remove exits non-zero on a fresh home, and the install goes on.
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 = singleLineDetail(outcome.stderr) || outcome.error?.message || `exit ${outcome.status}`;
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
- : { status: 0, stderr: '' }));
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 the merged head came back clean.
1
+ """Deny an agent's pull request open or merge until a review of that head came back clean.
2
2
 
3
- The agent that drives a pull request loops a code review on it, fixing and
4
- pushing each finding, until one pass reports zero findings or every finding
5
- it reports is ruled out with a link to the posted rulings. The merge then
6
- names that exact head::
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 receipt_directory() -> Path:
105
- """Return the folder that holds one review receipt per pull request.
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
- owner: Repository owner.
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 receipt file path.
116
+ The deny reason, or None when the open may run.
126
117
  """
127
- return receipt_directory() / RECEIPT_FILE_NAME_TEMPLATE.format(
128
- owner=owner.casefold(), repo=repo.casefold(), number=number
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(request: MergeRequest) -> bool:
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 request.slug.casefold() in all_exempt
223
+ return slug.casefold() in all_exempt
225
224
 
226
225
 
227
- def _clean_head(request: MergeRequest) -> str | None:
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 not isinstance(receipt, dict) or not is_clean_receipt(
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
- findings 0 -> clean
254
- findings 2, ruled out 2, .../acme/tools/pull/12#issuecomment-9 -> clean
255
- findings 2, ruled out 2, .../acme/other/pull/12#issuecomment-9 -> not clean
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 _links_rulings_on(rulings: object, owner: str, repo: str, number: int) -> bool:
290
- rulings_match = RULINGS_LINK_PATTERN.fullmatch(rulings) if isinstance(rulings, str) else None
291
- return (
292
- rulings_match is not None
293
- and rulings_match["owner"].casefold() == owner.casefold()
294
- and rulings_match["repo"].casefold() == repo.casefold()
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 _same_commit(clean_head, request.pinned_head):
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 a merge with no clean review, else None.
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
- request = merge_request_from(all_payload_fields)
346
- if request is None:
347
- return None
348
- reason = deny_reason(request)
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"]