claude-dev-env 8.88.7 → 8.89.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-constants.mjs +3 -0
- package/bin/install.cursor-rules.test.mjs +12 -1
- package/bin/install.mjs +18 -8
- package/hooks/advisory/owned_pr_review_loop.py +365 -0
- package/hooks/advisory/test_owned_pr_review_loop.py +369 -0
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/owned_pr_review_loop_constants.py +120 -0
- package/package.json +1 -1
|
@@ -182,6 +182,9 @@ export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
|
|
|
182
182
|
export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
|
|
183
183
|
'open_tasks_stop_gate.py',
|
|
184
184
|
]);
|
|
185
|
+
export const ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES = new Set([
|
|
186
|
+
'owned_pr_review_loop.py',
|
|
187
|
+
]);
|
|
185
188
|
export const CURSOR_HOOKS_FILE_NAME = 'hooks.json';
|
|
186
189
|
export const CURSOR_HOOKS_SCHEMA_VERSION = 1;
|
|
187
190
|
export const CURSOR_HOOK_TIMEOUT_SECONDS = 10;
|
|
@@ -135,7 +135,12 @@ test('seeds one editable policy and one native Codex routing hook', () => {
|
|
|
135
135
|
);
|
|
136
136
|
assert.equal(firstSpawnPromptGroups.length, 1);
|
|
137
137
|
assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
|
|
138
|
-
assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'Stop']);
|
|
138
|
+
assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'PostToolUse', 'Stop']);
|
|
139
|
+
const allCodexPostToolUseCommands = firstCodexHooks.hooks.PostToolUse.flatMap(
|
|
140
|
+
group => group.hooks.map(hook => hook.command),
|
|
141
|
+
);
|
|
142
|
+
assert.equal(allCodexPostToolUseCommands.length, 1, 'Codex gets the owned PR review loop and no other PostToolUse hook');
|
|
143
|
+
assert.match(allCodexPostToolUseCommands[0], /advisory\/owned_pr_review_loop\.py/);
|
|
139
144
|
const allCodexStopCommands = firstCodexHooks.hooks.Stop.flatMap(group => group.hooks.map(hook => hook.command));
|
|
140
145
|
assert.equal(allCodexStopCommands.length, 1, 'Codex gets the open tasks Stop gate and no other Stop hook');
|
|
141
146
|
assert.match(allCodexStopCommands[0], /open_tasks_stop_gate\.py/);
|
|
@@ -310,6 +315,12 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
|
|
|
310
315
|
eachGroup.hooks.some(eachHook => eachHook.command.includes('open_tasks_stop_gate.py')));
|
|
311
316
|
assert.equal(allPackageStopGroups.length, 1);
|
|
312
317
|
migratedHooks.hooks.Stop = migratedHooks.hooks.Stop.filter(eachGroup => !allPackageStopGroups.includes(eachGroup));
|
|
318
|
+
const allPackagePostToolUseCommands = migratedHooks.hooks.PostToolUse
|
|
319
|
+
.flatMap(eachGroup => eachGroup.hooks)
|
|
320
|
+
.map(eachHook => eachHook.command);
|
|
321
|
+
assert.equal(allPackagePostToolUseCommands.length, 1);
|
|
322
|
+
assert.match(allPackagePostToolUseCommands[0], /owned_pr_review_loop\.py/);
|
|
323
|
+
delete migratedHooks.hooks.PostToolUse;
|
|
313
324
|
assert.deepEqual(migratedHooks, { enabled: false, metadata: 'keep-file', hooks: expectedUserHooks });
|
|
314
325
|
assert.equal(readFileSync(configPath, 'utf8'), disabledConfig);
|
|
315
326
|
const firstInstall = readFileSync(codexHooksPath, 'utf8');
|
package/bin/install.mjs
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
PYTHON_PROBE_TIMEOUT_MILLISECONDS,
|
|
53
53
|
ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS,
|
|
54
54
|
ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
|
|
55
|
+
ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES,
|
|
55
56
|
} from './install-constants.mjs';
|
|
56
57
|
import {
|
|
57
58
|
PSTACK_PLUGIN_SPEC,
|
|
@@ -2245,15 +2246,17 @@ function mergeHooksAtPath(
|
|
|
2245
2246
|
return groupCount;
|
|
2246
2247
|
}
|
|
2247
2248
|
|
|
2248
|
-
function
|
|
2249
|
-
|
|
2250
|
-
return ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES.has(scriptName);
|
|
2249
|
+
function hookScriptName(hook) {
|
|
2250
|
+
return String(hook?.command ?? '').replace(/\\/g, '/').split('/').pop();
|
|
2251
2251
|
}
|
|
2252
2252
|
|
|
2253
|
-
function
|
|
2254
|
-
if (!Array.isArray(
|
|
2255
|
-
return
|
|
2256
|
-
.map(group => ({
|
|
2253
|
+
function selectCodexNativeGroupsByScriptName(groups, allScriptNames) {
|
|
2254
|
+
if (!Array.isArray(groups)) return [];
|
|
2255
|
+
return groups
|
|
2256
|
+
.map(group => ({
|
|
2257
|
+
...group,
|
|
2258
|
+
hooks: (group?.hooks ?? []).filter(hook => allScriptNames.has(hookScriptName(hook))),
|
|
2259
|
+
}))
|
|
2257
2260
|
.filter(group => group.hooks.length > 0);
|
|
2258
2261
|
}
|
|
2259
2262
|
|
|
@@ -2267,7 +2270,14 @@ function selectCodexNativeHooksConfig(hooksConfig) {
|
|
|
2267
2270
|
? preToolUseGroups.filter(group => ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS.has(group?.matcher))
|
|
2268
2271
|
: [],
|
|
2269
2272
|
UserPromptSubmit: Array.isArray(userPromptSubmitGroups) ? userPromptSubmitGroups : [],
|
|
2270
|
-
|
|
2273
|
+
PostToolUse: selectCodexNativeGroupsByScriptName(
|
|
2274
|
+
hooksConfig.hooks?.PostToolUse,
|
|
2275
|
+
ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES,
|
|
2276
|
+
),
|
|
2277
|
+
Stop: selectCodexNativeGroupsByScriptName(
|
|
2278
|
+
hooksConfig.hooks?.Stop,
|
|
2279
|
+
ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
|
|
2280
|
+
),
|
|
2271
2281
|
},
|
|
2272
2282
|
};
|
|
2273
2283
|
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse context reminder: own the review loop on a new owned pull request.
|
|
3
|
+
|
|
4
|
+
This hook never blocks. It runs after a shell call or a GitHub MCP
|
|
5
|
+
``create_pull_request`` call. When that call opened a pull request on a
|
|
6
|
+
repository we own, and the repository is not on the preset list of
|
|
7
|
+
repositories that run their own automated review, it adds one instruction
|
|
8
|
+
block to the agent's context::
|
|
9
|
+
|
|
10
|
+
=== OWNED PR REVIEW LOOP (context reminder, never a block) ===
|
|
11
|
+
You opened jl-cmd-projects/claude-dev-env#42 https://github.com/...
|
|
12
|
+
1. Review loop. Load the efficient-review skill ...
|
|
13
|
+
2. Mark ready. ...
|
|
14
|
+
3. Babysit. Follow the pstack babysit playbook ...
|
|
15
|
+
4. Ship. Follow the pstack shipping playbook ...
|
|
16
|
+
|
|
17
|
+
The pull request URL in the tool response is the proof that a pull request
|
|
18
|
+
opened, so the hook needs no network call and reads the same on Claude Code
|
|
19
|
+
and Codex.
|
|
20
|
+
|
|
21
|
+
Quiet branches: another tool, a shell command that opens no pull request,
|
|
22
|
+
a harness-reported non-zero exit, a response that says the pull request
|
|
23
|
+
already exists, a response with no pull request URL, an owner outside the
|
|
24
|
+
owned set, and a preset repository each emit nothing.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
import sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
_hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
37
|
+
if _hooks_root_directory not in sys.path:
|
|
38
|
+
sys.path.insert(0, _hooks_root_directory)
|
|
39
|
+
from hooks_constants.bash_post_call_dispatcher_constants import (
|
|
40
|
+
EXIT_CODE_ERROR_PREFIX,
|
|
41
|
+
)
|
|
42
|
+
from hooks_constants.owned_pr_review_loop_constants import (
|
|
43
|
+
ALL_DECLARED_URL_KEYS,
|
|
44
|
+
ALL_GH_API_BODY_OPTIONS,
|
|
45
|
+
ALL_GH_API_METHOD_OPTIONS,
|
|
46
|
+
ALL_GH_API_SHORT_OPTIONS,
|
|
47
|
+
ALL_GH_PR_CREATE_ACTIONS,
|
|
48
|
+
ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES,
|
|
49
|
+
ALL_OWNED_OWNER_DIGESTS,
|
|
50
|
+
ALL_PRESET_REPOSITORY_DIGESTS,
|
|
51
|
+
ALL_SHELL_COMMAND_INPUT_KEYS,
|
|
52
|
+
ALL_SHELL_TOOL_NAMES,
|
|
53
|
+
ALREADY_EXISTS_MARKER,
|
|
54
|
+
GH_API_POST_METHOD,
|
|
55
|
+
GH_API_PULLS_ENDPOINT_PATTERN,
|
|
56
|
+
GH_API_SUBCOMMAND,
|
|
57
|
+
GH_LONG_OPTION_PREFIX,
|
|
58
|
+
GH_OPTION_VALUE_SEPARATOR,
|
|
59
|
+
GH_PR_SUBCOMMAND,
|
|
60
|
+
GH_PROGRAM_NAME,
|
|
61
|
+
GH_SHORT_OPTION_LENGTH,
|
|
62
|
+
JSON_OBJECT_OPENER,
|
|
63
|
+
MCP_OWNER_INPUT_KEY,
|
|
64
|
+
MCP_REPOSITORY_INPUT_KEY,
|
|
65
|
+
MCP_TOOL_NAME_PREFIX,
|
|
66
|
+
NAME_DIGEST_TEXT_ENCODING,
|
|
67
|
+
PULL_REQUEST_URL_PATTERN,
|
|
68
|
+
REVIEW_LOOP_CONTEXT_TEMPLATE,
|
|
69
|
+
SHELL_ARGUMENT_SEPARATOR,
|
|
70
|
+
)
|
|
71
|
+
from hooks_constants.post_tool_use_context import (
|
|
72
|
+
write_post_tool_use_context_to_stdout,
|
|
73
|
+
)
|
|
74
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
75
|
+
from hooks_constants.shell_command_segments import command_tokens, split_into_segments
|
|
76
|
+
from hooks_constants.shell_command_wrappers import (
|
|
77
|
+
all_wrapped_command_texts,
|
|
78
|
+
segment_program_and_arguments,
|
|
79
|
+
)
|
|
80
|
+
except ImportError as import_error:
|
|
81
|
+
raise ImportError(
|
|
82
|
+
"owned_pr_review_loop: cannot import its dependencies; "
|
|
83
|
+
"ensure the hooks directory is importable."
|
|
84
|
+
) from import_error
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_gh_pr_create(all_arguments: list[str]) -> bool:
|
|
88
|
+
return (
|
|
89
|
+
all_arguments[:1] == [GH_PR_SUBCOMMAND]
|
|
90
|
+
and len(all_arguments) > 1
|
|
91
|
+
and all_arguments[1] in ALL_GH_PR_CREATE_ACTIONS
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _option_name_and_inline_value(argument: str) -> tuple[str, str | None]:
|
|
96
|
+
if argument.startswith(GH_LONG_OPTION_PREFIX):
|
|
97
|
+
option_name, separator, inline_value = argument.partition(GH_OPTION_VALUE_SEPARATOR)
|
|
98
|
+
return option_name, inline_value if separator else None
|
|
99
|
+
short_option_name = argument[:GH_SHORT_OPTION_LENGTH]
|
|
100
|
+
if short_option_name in ALL_GH_API_SHORT_OPTIONS and len(argument) > GH_SHORT_OPTION_LENGTH:
|
|
101
|
+
return short_option_name, argument[GH_SHORT_OPTION_LENGTH:]
|
|
102
|
+
return argument, None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_gh_api_pulls_post(all_arguments: list[str]) -> bool:
|
|
106
|
+
if all_arguments[:1] != [GH_API_SUBCOMMAND]:
|
|
107
|
+
return False
|
|
108
|
+
if not any(
|
|
109
|
+
GH_API_PULLS_ENDPOINT_PATTERN.fullmatch(each_argument) for each_argument in all_arguments
|
|
110
|
+
):
|
|
111
|
+
return False
|
|
112
|
+
explicit_method: str | None = None
|
|
113
|
+
sends_body = False
|
|
114
|
+
all_following_arguments = [*all_arguments[1:], ""]
|
|
115
|
+
for each_argument, each_following_argument in zip(all_arguments, all_following_arguments, strict=True):
|
|
116
|
+
option_name, inline_value = _option_name_and_inline_value(each_argument)
|
|
117
|
+
if option_name in ALL_GH_API_BODY_OPTIONS:
|
|
118
|
+
sends_body = True
|
|
119
|
+
if option_name in ALL_GH_API_METHOD_OPTIONS:
|
|
120
|
+
explicit_method = inline_value or each_following_argument
|
|
121
|
+
if explicit_method:
|
|
122
|
+
return explicit_method.upper() == GH_API_POST_METHOD
|
|
123
|
+
return sends_body
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _segment_opens_pull_request(all_segment_tokens: list[str]) -> bool:
|
|
127
|
+
program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
|
|
128
|
+
return program_name == GH_PROGRAM_NAME and (
|
|
129
|
+
_is_gh_pr_create(all_arguments) or _is_gh_api_pulls_post(all_arguments)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def command_creates_pull_request(command: str) -> bool:
|
|
134
|
+
"""Return True when any segment of the shell command opens a pull request.
|
|
135
|
+
|
|
136
|
+
Three shapes count::
|
|
137
|
+
|
|
138
|
+
gh pr create --fill
|
|
139
|
+
gh pr new --fill
|
|
140
|
+
gh api repos/acme/widgets/pulls --input pr.json
|
|
141
|
+
|
|
142
|
+
A ``gh api`` call counts only when it targets the pulls collection. An
|
|
143
|
+
explicit method (``-X POST``, ``-XPOST``, ``--method=POST``) decides
|
|
144
|
+
alone. With no method, a body (``-f``, ``-F``, ``--field``,
|
|
145
|
+
``--raw-field``, ``--input``, or the ``--name=value`` and ``-ftitle=t``
|
|
146
|
+
forms) means
|
|
147
|
+
POST, as it does for ``gh``. A ``pwsh -Command "..."``
|
|
148
|
+
wrapper is unwrapped and its inner text scanned the same way.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
command: The shell command text the agent ran.
|
|
152
|
+
"""
|
|
153
|
+
return any(
|
|
154
|
+
_segment_opens_pull_request(each_segment)
|
|
155
|
+
for each_command_text in all_wrapped_command_texts(command)
|
|
156
|
+
for each_segment in split_into_segments(command_tokens(each_command_text))
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_mcp_create_pull_request(tool_name: str) -> bool:
|
|
161
|
+
return tool_name.startswith(MCP_TOOL_NAME_PREFIX) and tool_name.endswith(
|
|
162
|
+
ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _shell_command(all_tool_input: dict[str, object]) -> str:
|
|
167
|
+
for each_key in ALL_SHELL_COMMAND_INPUT_KEYS:
|
|
168
|
+
command = all_tool_input.get(each_key)
|
|
169
|
+
if isinstance(command, str):
|
|
170
|
+
return command
|
|
171
|
+
if isinstance(command, list) and all(isinstance(each, str) for each in command):
|
|
172
|
+
return SHELL_ARGUMENT_SEPARATOR.join(command)
|
|
173
|
+
return ""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _response_text(tool_response: object) -> str:
|
|
177
|
+
if isinstance(tool_response, str):
|
|
178
|
+
return tool_response
|
|
179
|
+
return json.dumps(tool_response)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def tool_call_opens_pull_request(tool_name: str, all_tool_input: dict[str, object]) -> bool:
|
|
183
|
+
"""Return True when the tool call is a pull request creation.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
tool_name: The tool name from the hook payload, such as ``Bash``,
|
|
187
|
+
``exec_command``, or ``mcp__github__create_pull_request``.
|
|
188
|
+
all_tool_input: The tool input from the hook payload.
|
|
189
|
+
"""
|
|
190
|
+
if _is_mcp_create_pull_request(tool_name):
|
|
191
|
+
return True
|
|
192
|
+
if tool_name in ALL_SHELL_TOOL_NAMES:
|
|
193
|
+
return command_creates_pull_request(_shell_command(all_tool_input))
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _matches_for_input_repository(
|
|
198
|
+
all_url_matches: list[re.Match[str]], all_tool_input: dict[str, object]
|
|
199
|
+
) -> list[re.Match[str]]:
|
|
200
|
+
input_owner = str(all_tool_input.get(MCP_OWNER_INPUT_KEY) or "").lower()
|
|
201
|
+
input_repository = str(all_tool_input.get(MCP_REPOSITORY_INPUT_KEY) or "").lower()
|
|
202
|
+
if not (input_owner and input_repository):
|
|
203
|
+
return all_url_matches
|
|
204
|
+
return [
|
|
205
|
+
each_match
|
|
206
|
+
for each_match in all_url_matches
|
|
207
|
+
if each_match["owner"].lower() == input_owner
|
|
208
|
+
and each_match["repository"].lower() == input_repository
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _parsed_json_object_text(text: str) -> object:
|
|
213
|
+
stripped_text = text.strip()
|
|
214
|
+
if not stripped_text.startswith(JSON_OBJECT_OPENER):
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
return json.loads(stripped_text)
|
|
218
|
+
except json.JSONDecodeError:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _all_declared_url_matches(response_value: object) -> list[re.Match[str]]:
|
|
223
|
+
if isinstance(response_value, str):
|
|
224
|
+
return _all_declared_url_matches(_parsed_json_object_text(response_value))
|
|
225
|
+
if isinstance(response_value, list):
|
|
226
|
+
return [
|
|
227
|
+
each_match
|
|
228
|
+
for each_item in response_value
|
|
229
|
+
for each_match in _all_declared_url_matches(each_item)
|
|
230
|
+
]
|
|
231
|
+
if not isinstance(response_value, dict):
|
|
232
|
+
return []
|
|
233
|
+
all_own_matches = [
|
|
234
|
+
PULL_REQUEST_URL_PATTERN.fullmatch(str(response_value.get(each_key) or ""))
|
|
235
|
+
for each_key in ALL_DECLARED_URL_KEYS
|
|
236
|
+
]
|
|
237
|
+
return [each_match for each_match in all_own_matches if each_match] + [
|
|
238
|
+
each_match
|
|
239
|
+
for each_child in response_value.values()
|
|
240
|
+
for each_match in _all_declared_url_matches(each_child)
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _scanned_url_match(
|
|
245
|
+
all_tool_input: dict[str, object], tool_response: object
|
|
246
|
+
) -> re.Match[str] | None:
|
|
247
|
+
response_text = _response_text(tool_response)
|
|
248
|
+
if response_text.startswith(EXIT_CODE_ERROR_PREFIX):
|
|
249
|
+
return None
|
|
250
|
+
if ALREADY_EXISTS_MARKER in response_text.lower():
|
|
251
|
+
return None
|
|
252
|
+
all_url_matches = _matches_for_input_repository(
|
|
253
|
+
list(PULL_REQUEST_URL_PATTERN.finditer(response_text)), all_tool_input
|
|
254
|
+
)
|
|
255
|
+
return all_url_matches[-1] if all_url_matches else None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def opened_pull_request(
|
|
259
|
+
all_tool_input: dict[str, object], tool_response: object
|
|
260
|
+
) -> dict[str, str] | None:
|
|
261
|
+
"""Return the owner, repository, number, and url of the opened pull request.
|
|
262
|
+
|
|
263
|
+
A JSON response declares the new pull request in an ``html_url`` or
|
|
264
|
+
``url`` field, as the REST API and the MCP tools do. That field wins over
|
|
265
|
+
any URL the body cites. With no declared field, the hook scans the text
|
|
266
|
+
and takes the last URL, since ``gh pr create`` prints it last; a non-zero
|
|
267
|
+
exit or an "already exists" notice then means no new pull request. When
|
|
268
|
+
the tool input names an owner and a repository, only a URL for that
|
|
269
|
+
repository counts.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
all_tool_input: The tool input from the hook payload.
|
|
273
|
+
tool_response: The tool response from the hook payload.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
The pull request fields, or None when the response shows no new pull
|
|
277
|
+
request.
|
|
278
|
+
"""
|
|
279
|
+
all_declared_matches = _matches_for_input_repository(
|
|
280
|
+
_all_declared_url_matches(tool_response), all_tool_input
|
|
281
|
+
)
|
|
282
|
+
url_match = next(iter(all_declared_matches), None)
|
|
283
|
+
url_match = url_match or _scanned_url_match(all_tool_input, tool_response)
|
|
284
|
+
if url_match is None:
|
|
285
|
+
return None
|
|
286
|
+
return {**url_match.groupdict(), "url": url_match.group(0)}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def name_digest(name: str) -> str:
|
|
290
|
+
"""Return the SHA-256 digest of a name in its normalized form.
|
|
291
|
+
|
|
292
|
+
Normalizing keeps only ASCII letters and digits in lower case, so
|
|
293
|
+
``"Acme-Widgets/tools"`` and ``"acmewidgetstools"`` share one digest.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
name: An owner name or an ``owner/repository`` full name.
|
|
297
|
+
"""
|
|
298
|
+
normalized_name = "".join(
|
|
299
|
+
each_character
|
|
300
|
+
for each_character in name.lower()
|
|
301
|
+
if each_character.isascii() and each_character.isalnum()
|
|
302
|
+
)
|
|
303
|
+
return hashlib.sha256(normalized_name.encode(NAME_DIGEST_TEXT_ENCODING)).hexdigest()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def needs_review_loop(
|
|
307
|
+
owner: str,
|
|
308
|
+
repository: str,
|
|
309
|
+
all_owned_owner_digests: frozenset[str],
|
|
310
|
+
all_preset_repository_digests: frozenset[str],
|
|
311
|
+
) -> bool:
|
|
312
|
+
"""Return True for an owned repository outside the preset review list.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
owner: The GitHub owner of the repository.
|
|
316
|
+
repository: The repository name.
|
|
317
|
+
all_owned_owner_digests: Digests of the owners we own.
|
|
318
|
+
all_preset_repository_digests: Digests of the ``owner/repository``
|
|
319
|
+
names that run their own automated review.
|
|
320
|
+
"""
|
|
321
|
+
return (
|
|
322
|
+
name_digest(owner) in all_owned_owner_digests
|
|
323
|
+
and name_digest(f"{owner}/{repository}") not in all_preset_repository_digests
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def build_review_loop_context(all_pull_request_fields: dict[str, str]) -> str:
|
|
328
|
+
"""Build the review-loop instruction block for one opened pull request.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
all_pull_request_fields: The owner, repository, number, and url fields.
|
|
332
|
+
"""
|
|
333
|
+
return REVIEW_LOOP_CONTEXT_TEMPLATE.format(**all_pull_request_fields)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def main() -> None:
|
|
337
|
+
"""Add the review-loop instructions after a pull request opens on an owned repository.
|
|
338
|
+
|
|
339
|
+
Reads the PostToolUse payload from stdin. Every quiet branch returns with
|
|
340
|
+
no output, so this hook can never alter or block a tool call.
|
|
341
|
+
"""
|
|
342
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
343
|
+
if hook_payload is None:
|
|
344
|
+
return
|
|
345
|
+
tool_name = str(hook_payload.get("tool_name") or "")
|
|
346
|
+
tool_input = hook_payload.get("tool_input")
|
|
347
|
+
if not isinstance(tool_input, dict):
|
|
348
|
+
return
|
|
349
|
+
if not tool_call_opens_pull_request(tool_name, tool_input):
|
|
350
|
+
return
|
|
351
|
+
pull_request = opened_pull_request(tool_input, hook_payload.get("tool_response"))
|
|
352
|
+
if pull_request is None:
|
|
353
|
+
return
|
|
354
|
+
if not needs_review_loop(
|
|
355
|
+
pull_request["owner"],
|
|
356
|
+
pull_request["repository"],
|
|
357
|
+
ALL_OWNED_OWNER_DIGESTS,
|
|
358
|
+
ALL_PRESET_REPOSITORY_DIGESTS,
|
|
359
|
+
):
|
|
360
|
+
return
|
|
361
|
+
write_post_tool_use_context_to_stdout(build_review_loop_context(pull_request))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
main()
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Behavior tests for the owned pull request review-loop context hook.
|
|
2
|
+
|
|
3
|
+
The hook is a PostToolUse observer. It never blocks. These tests pin which
|
|
4
|
+
tool calls wake it, which repositories get the review-loop block, and that
|
|
5
|
+
every other path stays quiet. Each end-to-end test runs the script as a
|
|
6
|
+
subprocess with a Claude Code or Codex payload on stdin.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
advisory_directory = str(Path(__file__).resolve().parent)
|
|
21
|
+
if advisory_directory not in sys.path:
|
|
22
|
+
sys.path.insert(0, advisory_directory)
|
|
23
|
+
import owned_pr_review_loop
|
|
24
|
+
except ImportError as import_error:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"test_owned_pr_review_loop: cannot import its sibling modules; "
|
|
27
|
+
"ensure the advisory directory is importable."
|
|
28
|
+
) from import_error
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
HOOK_SCRIPT_PATH = Path(__file__).resolve().parent / "owned_pr_review_loop.py"
|
|
32
|
+
HOOKS_JSON_PATH = Path(__file__).resolve().parent.parent / "hooks.json"
|
|
33
|
+
OWNED_PR_URL = "https://github.com/jl-cmd-projects/claude-dev-env/pull/42"
|
|
34
|
+
FOREIGN_PR_URL = "https://github.com/acme/widgets/pull/3"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_hook(payload: dict[str, object]) -> str:
|
|
38
|
+
completed = subprocess.run(
|
|
39
|
+
[sys.executable, str(HOOK_SCRIPT_PATH)],
|
|
40
|
+
input=json.dumps(payload),
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
assert completed.returncode == 0, completed.stderr
|
|
46
|
+
return completed.stdout
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _shell_payload(
|
|
50
|
+
command: str, stdout: str, tool_name: str = "Bash", input_key: str = "command"
|
|
51
|
+
) -> dict[str, object]:
|
|
52
|
+
return {
|
|
53
|
+
"hook_event_name": "PostToolUse",
|
|
54
|
+
"tool_name": tool_name,
|
|
55
|
+
"tool_input": {input_key: command},
|
|
56
|
+
"tool_response": {"stdout": stdout, "stderr": ""},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _mcp_payload(tool_name: str, owner: str, repository: str, url: str) -> dict[str, object]:
|
|
61
|
+
return {
|
|
62
|
+
"hook_event_name": "PostToolUse",
|
|
63
|
+
"tool_name": tool_name,
|
|
64
|
+
"tool_input": {"owner": owner, "repo": repository, "title": "Add a hook"},
|
|
65
|
+
"tool_response": [{"type": "text", "text": json.dumps({"id": "1", "url": url})}],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _context_of(stdout: str) -> str:
|
|
70
|
+
return json.loads(stdout)["hookSpecificOutput"]["additionalContext"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
"command",
|
|
75
|
+
[
|
|
76
|
+
"gh pr create --title t --body b",
|
|
77
|
+
"gh pr new --fill",
|
|
78
|
+
"git push -u origin feature && gh pr create --fill",
|
|
79
|
+
'pwsh -Command "gh pr create --fill"',
|
|
80
|
+
"gh api repos/acme/widgets/pulls --input pr.json",
|
|
81
|
+
"gh api /repos/acme/widgets/pulls -f title=t -f head=x -f base=main",
|
|
82
|
+
"gh api -X POST repos/acme/widgets/pulls",
|
|
83
|
+
"gh api repos/acme/widgets/pulls --input=pr.json",
|
|
84
|
+
"gh api repos/acme/widgets/pulls --raw-field=title=t",
|
|
85
|
+
"gh api --method=POST repos/acme/widgets/pulls",
|
|
86
|
+
"gh api -XPOST repos/acme/widgets/pulls",
|
|
87
|
+
"gh api repos/acme/widgets/pulls -Ftitle=t -fhead=x",
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
def test_should_detect_gh_pr_create(command: str) -> None:
|
|
91
|
+
assert owned_pr_review_loop.command_creates_pull_request(command)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
"command",
|
|
96
|
+
[
|
|
97
|
+
"gh pr view 42",
|
|
98
|
+
"git push -u origin feature",
|
|
99
|
+
"gh pr",
|
|
100
|
+
"echo gh pr create",
|
|
101
|
+
"gh api repos/acme/widgets/pulls",
|
|
102
|
+
"gh api repos/acme/widgets/pulls/42 -X PATCH -f state=closed",
|
|
103
|
+
"gh api repos/acme/widgets/issues --input issue.json",
|
|
104
|
+
"gh api --method GET repos/acme/widgets/pulls -f state=open",
|
|
105
|
+
"gh api -X GET repos/acme/widgets/pulls -f state=open",
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
def test_should_not_detect_other_commands(command: str) -> None:
|
|
109
|
+
assert not owned_pr_review_loop.command_creates_pull_request(command)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize(
|
|
113
|
+
"tool_name",
|
|
114
|
+
[
|
|
115
|
+
"mcp__github__create_pull_request",
|
|
116
|
+
"mcp__codex_apps__github_create_pull_request",
|
|
117
|
+
],
|
|
118
|
+
)
|
|
119
|
+
def test_should_treat_mcp_create_tools_as_pull_request_creation(tool_name: str) -> None:
|
|
120
|
+
assert owned_pr_review_loop.tool_call_opens_pull_request(tool_name, {})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_should_read_the_codex_cmd_key_for_shell_tools() -> None:
|
|
124
|
+
assert owned_pr_review_loop.tool_call_opens_pull_request(
|
|
125
|
+
"exec_command", {"cmd": ["gh", "pr", "create", "--fill"]}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_should_ignore_other_mcp_tools() -> None:
|
|
130
|
+
assert not owned_pr_review_loop.tool_call_opens_pull_request(
|
|
131
|
+
"mcp__github__merge_pull_request", {}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_should_take_the_last_url_from_gh_output() -> None:
|
|
136
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
137
|
+
{"command": "gh pr create"},
|
|
138
|
+
{"stdout": f"Warning: see {FOREIGN_PR_URL}\n{OWNED_PR_URL}\n"},
|
|
139
|
+
)
|
|
140
|
+
assert pull_request == {
|
|
141
|
+
"owner": "jl-cmd-projects",
|
|
142
|
+
"repository": "claude-dev-env",
|
|
143
|
+
"number": "42",
|
|
144
|
+
"url": OWNED_PR_URL,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_should_take_the_url_for_the_mcp_input_repository() -> None:
|
|
149
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
150
|
+
{"owner": "JL-CMD-PROJECTS", "repo": "claude-dev-env"},
|
|
151
|
+
f"Opened {OWNED_PR_URL}; see also {FOREIGN_PR_URL}",
|
|
152
|
+
)
|
|
153
|
+
assert pull_request is not None
|
|
154
|
+
assert pull_request["number"] == "42"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_should_take_the_declared_url_over_urls_cited_in_the_body() -> None:
|
|
158
|
+
rest_response = json.dumps(
|
|
159
|
+
{
|
|
160
|
+
"html_url": OWNED_PR_URL,
|
|
161
|
+
"body": "Follows https://github.com/jl-cmd-projects/claude-dev-env/pull/7",
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
165
|
+
{"command": "gh api repos/jl-cmd-projects/claude-dev-env/pulls --input pr.json"},
|
|
166
|
+
{"stdout": rest_response},
|
|
167
|
+
)
|
|
168
|
+
assert pull_request is not None
|
|
169
|
+
assert pull_request["number"] == "42"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_should_take_the_declared_url_from_an_mcp_text_item() -> None:
|
|
173
|
+
mcp_response = [
|
|
174
|
+
{
|
|
175
|
+
"type": "text",
|
|
176
|
+
"text": json.dumps(
|
|
177
|
+
{
|
|
178
|
+
"url": OWNED_PR_URL,
|
|
179
|
+
"body": "Replaces https://github.com/jl-cmd-projects/claude-dev-env/pull/7",
|
|
180
|
+
}
|
|
181
|
+
),
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
185
|
+
{"owner": "jl-cmd-projects", "repo": "claude-dev-env"}, mcp_response
|
|
186
|
+
)
|
|
187
|
+
assert pull_request is not None
|
|
188
|
+
assert pull_request["number"] == "42"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_should_keep_a_declared_pull_request_whose_body_says_already_exists() -> None:
|
|
192
|
+
rest_response = json.dumps({"html_url": OWNED_PR_URL, "body": "The helper already exists."})
|
|
193
|
+
pull_request = owned_pr_review_loop.opened_pull_request(
|
|
194
|
+
{"command": "gh api repos/jl-cmd-projects/claude-dev-env/pulls --input pr.json"},
|
|
195
|
+
{"stdout": rest_response},
|
|
196
|
+
)
|
|
197
|
+
assert pull_request is not None
|
|
198
|
+
assert pull_request["url"] == OWNED_PR_URL
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_should_find_no_pull_request_after_a_failed_command() -> None:
|
|
202
|
+
assert (
|
|
203
|
+
owned_pr_review_loop.opened_pull_request(
|
|
204
|
+
{"command": "gh pr create"}, f"Error: Exit code 1\n{OWNED_PR_URL}"
|
|
205
|
+
)
|
|
206
|
+
is None
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_should_find_no_pull_request_when_one_already_exists() -> None:
|
|
211
|
+
assert (
|
|
212
|
+
owned_pr_review_loop.opened_pull_request(
|
|
213
|
+
{"command": "gh pr create"},
|
|
214
|
+
{"stdout": f'a pull request for branch "x" already exists:\n{OWNED_PR_URL}'},
|
|
215
|
+
)
|
|
216
|
+
is None
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_should_digest_names_in_normalized_form() -> None:
|
|
221
|
+
assert owned_pr_review_loop.name_digest("Acme-Widgets/Tools") == (
|
|
222
|
+
owned_pr_review_loop.name_digest("acmewidgetstools")
|
|
223
|
+
)
|
|
224
|
+
assert owned_pr_review_loop.name_digest("acme") != (
|
|
225
|
+
owned_pr_review_loop.name_digest("acmewidgets")
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@pytest.mark.parametrize(
|
|
230
|
+
("owner", "repository", "expected"),
|
|
231
|
+
[
|
|
232
|
+
("Acme-Widgets", "tools", True),
|
|
233
|
+
("acme-widgets", "site", True),
|
|
234
|
+
("Acme-Widgets", "reviewed", False),
|
|
235
|
+
("Other-Org", "tools", False),
|
|
236
|
+
],
|
|
237
|
+
)
|
|
238
|
+
def test_should_require_an_owned_repository_outside_the_preset_list(
|
|
239
|
+
owner: str, repository: str, expected: bool
|
|
240
|
+
) -> None:
|
|
241
|
+
all_owned_owner_digests = frozenset({owned_pr_review_loop.name_digest("acme-widgets")})
|
|
242
|
+
all_preset_repository_digests = frozenset(
|
|
243
|
+
{owned_pr_review_loop.name_digest("acme-widgets/reviewed")}
|
|
244
|
+
)
|
|
245
|
+
assert (
|
|
246
|
+
owned_pr_review_loop.needs_review_loop(
|
|
247
|
+
owner, repository, all_owned_owner_digests, all_preset_repository_digests
|
|
248
|
+
)
|
|
249
|
+
is expected
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@pytest.mark.parametrize(
|
|
254
|
+
("owner", "repository", "expected"),
|
|
255
|
+
[
|
|
256
|
+
("jl-cmd-projects", "claude-dev-env", True),
|
|
257
|
+
("jl-cmd", "claude-dev-env", True),
|
|
258
|
+
("acme", "widgets", False),
|
|
259
|
+
],
|
|
260
|
+
)
|
|
261
|
+
def test_should_apply_the_shipped_owner_list(owner: str, repository: str, expected: bool) -> None:
|
|
262
|
+
assert (
|
|
263
|
+
owned_pr_review_loop.needs_review_loop(
|
|
264
|
+
owner,
|
|
265
|
+
repository,
|
|
266
|
+
owned_pr_review_loop.ALL_OWNED_OWNER_DIGESTS,
|
|
267
|
+
owned_pr_review_loop.ALL_PRESET_REPOSITORY_DIGESTS,
|
|
268
|
+
)
|
|
269
|
+
is expected
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_should_name_every_step_and_skill_in_the_context() -> None:
|
|
274
|
+
context = owned_pr_review_loop.build_review_loop_context(
|
|
275
|
+
{
|
|
276
|
+
"owner": "jl-cmd-projects",
|
|
277
|
+
"repository": "claude-dev-env",
|
|
278
|
+
"number": "42",
|
|
279
|
+
"url": OWNED_PR_URL,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
assert "jl-cmd-projects/claude-dev-env#42" in context
|
|
283
|
+
assert "efficient-review" in context
|
|
284
|
+
assert "gh pr ready 42 --repo jl-cmd-projects/claude-dev-env" in context
|
|
285
|
+
assert "poteto-mode/playbooks/babysit.md" in context
|
|
286
|
+
assert "poteto-mode/playbooks/shipping.md" in context
|
|
287
|
+
assert "merged" in context
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_main_should_emit_the_loop_after_gh_pr_create_on_an_owned_repository() -> None:
|
|
291
|
+
stdout = _run_hook(_shell_payload("gh pr create --fill", f"{OWNED_PR_URL}\n"))
|
|
292
|
+
payload = json.loads(stdout)
|
|
293
|
+
assert payload["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
|
|
294
|
+
assert "efficient-review" in _context_of(stdout)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_main_should_emit_the_loop_after_a_gh_api_pulls_post() -> None:
|
|
298
|
+
rest_response = json.dumps(
|
|
299
|
+
{
|
|
300
|
+
"url": "https://api.github.com/repos/jl-cmd-projects/claude-dev-env/pulls/42",
|
|
301
|
+
"html_url": OWNED_PR_URL,
|
|
302
|
+
"_links": {"html": {"href": OWNED_PR_URL}},
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
stdout = _run_hook(
|
|
306
|
+
_shell_payload("gh api repos/jl-cmd-projects/claude-dev-env/pulls --input pr.json", rest_response)
|
|
307
|
+
)
|
|
308
|
+
assert "jl-cmd-projects/claude-dev-env#42" in _context_of(stdout)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_main_should_emit_the_loop_for_a_codex_shell_call() -> None:
|
|
312
|
+
stdout = _run_hook(
|
|
313
|
+
_shell_payload(
|
|
314
|
+
"gh pr create --fill", f"{OWNED_PR_URL}\n", tool_name="exec_command", input_key="cmd"
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
assert OWNED_PR_URL in _context_of(stdout)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@pytest.mark.parametrize(
|
|
321
|
+
"tool_name",
|
|
322
|
+
["mcp__github__create_pull_request", "mcp__codex_apps__github_create_pull_request"],
|
|
323
|
+
)
|
|
324
|
+
def test_main_should_emit_the_loop_after_an_mcp_create(tool_name: str) -> None:
|
|
325
|
+
stdout = _run_hook(
|
|
326
|
+
_mcp_payload(
|
|
327
|
+
tool_name,
|
|
328
|
+
"jl-cmd-projects",
|
|
329
|
+
"DeepStack",
|
|
330
|
+
"https://github.com/jl-cmd-projects/DeepStack/pull/9",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
assert "jl-cmd-projects/DeepStack#9" in _context_of(stdout)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_main_should_stay_quiet_for_a_repository_we_do_not_own() -> None:
|
|
337
|
+
assert _run_hook(_shell_payload("gh pr create --fill", f"{FOREIGN_PR_URL}\n")) == ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_main_should_stay_quiet_for_a_command_that_opens_no_pull_request() -> None:
|
|
341
|
+
assert _run_hook(_shell_payload("gh pr view 42", f"{OWNED_PR_URL}\n")) == ""
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_main_should_stay_quiet_for_a_failed_create() -> None:
|
|
345
|
+
payload = _shell_payload("gh pr create --fill", "")
|
|
346
|
+
payload["tool_response"] = "Error: Exit code 1\npull request create failed"
|
|
347
|
+
assert _run_hook(payload) == ""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_hooks_json_should_route_shell_and_mcp_create_calls_to_the_hook() -> None:
|
|
351
|
+
hooks_config = json.loads(HOOKS_JSON_PATH.read_text(encoding="utf-8"))
|
|
352
|
+
all_matchers = [
|
|
353
|
+
each_group["matcher"]
|
|
354
|
+
for each_group in hooks_config["hooks"]["PostToolUse"]
|
|
355
|
+
if any(
|
|
356
|
+
"advisory/owned_pr_review_loop.py" in each_hook.get("command", "")
|
|
357
|
+
for each_hook in each_group["hooks"]
|
|
358
|
+
)
|
|
359
|
+
]
|
|
360
|
+
assert len(all_matchers) == 1
|
|
361
|
+
for each_tool_name in (
|
|
362
|
+
"Bash",
|
|
363
|
+
"PowerShell",
|
|
364
|
+
"exec_command",
|
|
365
|
+
"shell_command",
|
|
366
|
+
"mcp__github__create_pull_request",
|
|
367
|
+
"mcp__codex_apps__github_create_pull_request",
|
|
368
|
+
):
|
|
369
|
+
assert re.fullmatch(all_matchers[0], each_tool_name), each_tool_name
|
|
@@ -11,6 +11,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
11
11
|
- `blocking/cloud_graphql_gate.py` denies the `gh` commands that call GraphQL (`gh api graphql` with any flags before the endpoint, `gh pr`, `gh issue`, `gh project` and `gh discussion` except `gh pr diff` and `gh pr list --web`, `gh status`, `gh org list`, `gh release list`, `gh release view` with a tag, `gh workflow run` without `--ref`, and a few `repo`, `label`, `search`, `ruleset` and `gist` commands, read past `if`, `!` and command substitutions) and an HTTP client or `python -c` style call to `api.github.com/graphql`, with or without a port, inside a cloud session, where the proxy answers GraphQL with HTTP 403, and names the REST routes to run instead.
|
|
12
12
|
- `blocking/bash_post_call_dispatcher.py` runs hosted observers and joins their context output without blocking the call.
|
|
13
13
|
- `advisory/pr_done_reminder.py` adds a pull request checklist after a successful push or pull request creation.
|
|
14
|
+
- `advisory/owned_pr_review_loop.py` adds review-loop instructions after the agent opens a pull request on an owned repository outside the preset list of repositories that run their own automated review: run efficient-review until clean, mark the pull request ready, babysit it, and ship it to merge.
|
|
14
15
|
|
|
15
16
|
## When it fires
|
|
16
17
|
|
|
@@ -21,6 +22,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
21
22
|
- `blocking/cloud_graphql_gate.py` runs inside that dispatcher on `PreToolUse`, matcher `Bash`, timeout `60` seconds, for the Bash and PowerShell tools, when `CLAUDE_CODE_REMOTE` is `true`.
|
|
22
23
|
- `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
|
|
23
24
|
- `advisory/pr_done_reminder.py` runs inside that dispatcher on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds. `ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES` selects `Bash` and `PowerShell`.
|
|
25
|
+
- `advisory/owned_pr_review_loop.py` runs on `PostToolUse`, matcher `Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)`, timeout `10` seconds in `hooks.json`. It wakes on `gh pr create`, `gh pr new`, a POST to `gh api repos/<owner>/<repo>/pulls`, and an MCP pull request creation. The installer also registers it on Codex `PostToolUse` through `ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES`.
|
|
24
26
|
|
|
25
27
|
## Proving it
|
|
26
28
|
|
|
@@ -34,6 +36,7 @@ Preconditions:
|
|
|
34
36
|
- **Cloud GraphQL gate.** Input is a `gh api graphql` command with `CLAUDE_CODE_REMOTE=true`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_cloud_graphql_gate.py -q`. The adjacent test observes a denial that names the REST routes, and silence outside a cloud session.
|
|
35
37
|
- **Post-call dispatch.** Input is a Bash call that yields hosted context. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_bash_post_call_dispatcher.py -q`. The adjacent test observes a joined context payload and exit code zero.
|
|
36
38
|
- **Pull request reminder.** Input is a completed push or pull request creation. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_pr_done_reminder.py -q`. The adjacent test checks the reminder trigger and checklist verdict.
|
|
39
|
+
- **Owned pull request review loop.** Input is a pull request creation result on an owned repository. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_owned_pr_review_loop.py -q`. The adjacent test observes the review-loop block for owned repositories and silence for the preset and foreign repositories.
|
|
37
40
|
|
|
38
41
|
## Gotchas
|
|
39
42
|
|
package/hooks/hooks.json
CHANGED
|
@@ -456,6 +456,16 @@
|
|
|
456
456
|
}
|
|
457
457
|
]
|
|
458
458
|
},
|
|
459
|
+
{
|
|
460
|
+
"matcher": "Bash|PowerShell|exec_command|shell_command|mcp__.*__(create_pull_request|github_create_pull_request)",
|
|
461
|
+
"hooks": [
|
|
462
|
+
{
|
|
463
|
+
"type": "command",
|
|
464
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/advisory/owned_pr_review_loop.py",
|
|
465
|
+
"timeout": 10
|
|
466
|
+
}
|
|
467
|
+
]
|
|
468
|
+
},
|
|
459
469
|
{
|
|
460
470
|
"matcher": "Bash",
|
|
461
471
|
"hooks": [
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Constants for the owned pull request review-loop context hook.
|
|
2
|
+
|
|
3
|
+
The hook is a PostToolUse observer. It never blocks. After the agent opens a
|
|
4
|
+
pull request on a repository we own, outside the preset list of repositories
|
|
5
|
+
that run their own automated review, it tells the agent to own an efficient
|
|
6
|
+
review loop and drive the pull request through to merge.
|
|
7
|
+
|
|
8
|
+
Private owner and repository names stay out of this public tree. Each set holds the
|
|
9
|
+
SHA-256 digest of a name normalized to lower-case ASCII letters and digits,
|
|
10
|
+
the same form ``scripts/private_terms.py`` uses::
|
|
11
|
+
|
|
12
|
+
"Acme-Widgets" -> "acmewidgets"
|
|
13
|
+
"Acme-Widgets/tools" -> "acmewidgetstools"
|
|
14
|
+
|
|
15
|
+
To add an owner or a preset repository, append the digest of its normalized
|
|
16
|
+
form.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"NAME_DIGEST_TEXT_ENCODING",
|
|
25
|
+
"ALL_OWNED_OWNER_DIGESTS",
|
|
26
|
+
"ALL_PRESET_REPOSITORY_DIGESTS",
|
|
27
|
+
"ALL_SHELL_TOOL_NAMES",
|
|
28
|
+
"ALL_SHELL_COMMAND_INPUT_KEYS",
|
|
29
|
+
"SHELL_ARGUMENT_SEPARATOR",
|
|
30
|
+
"ALL_GH_PR_CREATE_ACTIONS",
|
|
31
|
+
"GH_PROGRAM_NAME",
|
|
32
|
+
"GH_PR_SUBCOMMAND",
|
|
33
|
+
"GH_API_SUBCOMMAND",
|
|
34
|
+
"GH_API_PULLS_ENDPOINT_PATTERN",
|
|
35
|
+
"ALL_GH_API_BODY_OPTIONS",
|
|
36
|
+
"ALL_GH_API_METHOD_OPTIONS",
|
|
37
|
+
"GH_API_POST_METHOD",
|
|
38
|
+
"GH_LONG_OPTION_PREFIX",
|
|
39
|
+
"GH_SHORT_OPTION_LENGTH",
|
|
40
|
+
"ALL_GH_API_SHORT_OPTIONS",
|
|
41
|
+
"GH_OPTION_VALUE_SEPARATOR",
|
|
42
|
+
"MCP_TOOL_NAME_PREFIX",
|
|
43
|
+
"ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES",
|
|
44
|
+
"MCP_OWNER_INPUT_KEY",
|
|
45
|
+
"MCP_REPOSITORY_INPUT_KEY",
|
|
46
|
+
"PULL_REQUEST_URL_PATTERN",
|
|
47
|
+
"ALREADY_EXISTS_MARKER",
|
|
48
|
+
"ALL_DECLARED_URL_KEYS",
|
|
49
|
+
"JSON_OBJECT_OPENER",
|
|
50
|
+
"REVIEW_LOOP_CONTEXT_TEMPLATE",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
NAME_DIGEST_TEXT_ENCODING: str = "utf-8"
|
|
54
|
+
ALL_OWNED_OWNER_DIGESTS: frozenset[str] = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"2e8b9d88060d471690c122e6843f3bec8c95116d643af2f5501e0881b10e83c4",
|
|
57
|
+
"3a92fc8d256dc1f50dc8794573f80de695516f0f0e5c235118bd0c18be3d4434",
|
|
58
|
+
"d526db5593d5986500e4770cb6018f2d33c459ed93657487958a6086e52e6147",
|
|
59
|
+
"95d4d8808440e130d0ee753e0b7530eba4534dd41a7320ad8b710a44d40441f4",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
ALL_PRESET_REPOSITORY_DIGESTS: frozenset[str] = frozenset(
|
|
63
|
+
{"d362bee4d80f5474921b917c127f2b0680c0a5261cb352e59de35cc87b00989f"}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
ALL_SHELL_TOOL_NAMES: frozenset[str] = frozenset(
|
|
67
|
+
{"Bash", "PowerShell", "exec_command", "shell_command"}
|
|
68
|
+
)
|
|
69
|
+
ALL_SHELL_COMMAND_INPUT_KEYS: tuple[str, ...] = ("command", "cmd")
|
|
70
|
+
SHELL_ARGUMENT_SEPARATOR: str = " "
|
|
71
|
+
GH_PROGRAM_NAME: str = "gh"
|
|
72
|
+
GH_PR_SUBCOMMAND: str = "pr"
|
|
73
|
+
ALL_GH_PR_CREATE_ACTIONS: frozenset[str] = frozenset({"create", "new"})
|
|
74
|
+
GH_API_SUBCOMMAND: str = "api"
|
|
75
|
+
GH_API_PULLS_ENDPOINT_PATTERN: re.Pattern[str] = re.compile(r"/?repos/[^/\s]+/[^/\s]+/pulls")
|
|
76
|
+
ALL_GH_API_BODY_OPTIONS: frozenset[str] = frozenset(
|
|
77
|
+
{"-f", "-F", "--field", "--raw-field", "--input"}
|
|
78
|
+
)
|
|
79
|
+
ALL_GH_API_METHOD_OPTIONS: frozenset[str] = frozenset({"-X", "--method"})
|
|
80
|
+
GH_API_POST_METHOD: str = "POST"
|
|
81
|
+
GH_LONG_OPTION_PREFIX: str = "--"
|
|
82
|
+
GH_SHORT_OPTION_LENGTH: int = 2
|
|
83
|
+
ALL_GH_API_SHORT_OPTIONS: frozenset[str] = frozenset({"-f", "-F", "-X"})
|
|
84
|
+
GH_OPTION_VALUE_SEPARATOR: str = "="
|
|
85
|
+
|
|
86
|
+
MCP_TOOL_NAME_PREFIX: str = "mcp__"
|
|
87
|
+
ALL_MCP_CREATE_PULL_REQUEST_TOOL_SUFFIXES: tuple[str, ...] = (
|
|
88
|
+
"__create_pull_request",
|
|
89
|
+
"__github_create_pull_request",
|
|
90
|
+
)
|
|
91
|
+
MCP_OWNER_INPUT_KEY: str = "owner"
|
|
92
|
+
MCP_REPOSITORY_INPUT_KEY: str = "repo"
|
|
93
|
+
|
|
94
|
+
PULL_REQUEST_URL_PATTERN: re.Pattern[str] = re.compile(
|
|
95
|
+
r"https://github\.com/(?P<owner>[A-Za-z0-9][A-Za-z0-9-]*)/"
|
|
96
|
+
r"(?P<repository>[A-Za-z0-9._-]+)/pull/(?P<number>\d+)"
|
|
97
|
+
)
|
|
98
|
+
ALREADY_EXISTS_MARKER: str = "already exists"
|
|
99
|
+
ALL_DECLARED_URL_KEYS: tuple[str, ...] = ("html_url", "url")
|
|
100
|
+
JSON_OBJECT_OPENER: str = "{"
|
|
101
|
+
|
|
102
|
+
REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
|
|
103
|
+
"=== OWNED PR REVIEW LOOP (context reminder, never a block) ===\n"
|
|
104
|
+
"You opened {owner}/{repository}#{number} {url}\n"
|
|
105
|
+
"This repository has no automated review, so you own the review and the merge.\n"
|
|
106
|
+
"1. Review loop. Load the efficient-review skill (/efficient-review). Review the\n"
|
|
107
|
+
" branch, check each finding against the code, fix every one that holds, push,\n"
|
|
108
|
+
" and review again. Repeat until a pass reports no findings.\n"
|
|
109
|
+
"2. Mark ready. Once the loop is clean, CI is green, and no review thread is open,\n"
|
|
110
|
+
" mark the PR ready for review (gh pr ready {number} --repo {owner}/{repository},\n"
|
|
111
|
+
" or update_pull_request with draft false).\n"
|
|
112
|
+
"3. Babysit. Follow the pstack babysit playbook (poteto-mode/playbooks/babysit.md,\n"
|
|
113
|
+
" or the standalone babysit skill outside poteto-mode). Fix failing CI, resolve\n"
|
|
114
|
+
" merge conflicts, and answer review threads until the PR is green and mergeable.\n"
|
|
115
|
+
" Any new code goes back through step 1 before the next push.\n"
|
|
116
|
+
"4. Ship. Follow the pstack shipping playbook (poteto-mode/playbooks/shipping.md)\n"
|
|
117
|
+
" to land the PR, or the pr-lifecycle skill outside poteto-mode. Read the PR\n"
|
|
118
|
+
" back and confirm it reads merged.\n"
|
|
119
|
+
"Run each step yourself. The task ends at a merged PR or a named blocker."
|
|
120
|
+
)
|