claude-dev-env 8.91.0 → 8.92.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/e-code-review/SKILL.md +1 -1
- package/.agents/skills/efficient-review/SKILL.md +1 -1
- package/.agents/skills/efficient-review/reference/criteria.md +1 -1
- package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +4 -3
- package/bin/install-constants.mjs +1 -0
- package/bin/install.cursor-rules.test.mjs +2 -1
- package/hooks/advisory/owned_pr_review_loop.py +7 -1
- package/hooks/advisory/test_owned_pr_review_loop.py +25 -0
- package/hooks/blocking/owned_pr_merge_stop_gate.py +155 -0
- package/hooks/blocking/test_merge_review_gate.py +6 -0
- package/hooks/blocking/test_owned_pr_merge_stop_gate.py +163 -0
- package/hooks/features/bash-dispatchers.md +4 -1
- package/hooks/features/conduct-gates.md +1 -1
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +4 -2
- package/hooks/hooks_constants/owned_pr_merge_stop_gate_constants.py +37 -0
- package/hooks/hooks_constants/owned_pr_review_loop_constants.py +17 -2
- package/hooks/owned_pr_session_record.py +104 -0
- package/hooks/test_owned_pr_session_record.py +46 -0
- package/package.json +1 -1
- package/scripts/policy_lint/adapter_configuration.py +6 -0
- package/scripts/policy_lint/config/constants.py +1 -0
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
|
|
9
9
|
# e-code-review
|
|
10
10
|
|
|
11
|
-
**Pick a level, load its file, run it.** Each file has the same steps, angles, caps, and fields as the built-in `/code-review` recipe for that level. Only the wording differs, where this repo's prose rules require it.
|
|
11
|
+
**Pick a level, load its file, run it.** Each file has the same steps, angles, caps, and fields as the built-in `/code-review` recipe for that level. Only the wording differs, where this repo's prose rules require it. A question about `/code-review` asks about the built-in recipe, so answer it from the built-in text and use these files only for `/e-code-review`.
|
|
12
12
|
|
|
13
13
|
| Level | File | Shape |
|
|
14
14
|
|---|---|---|
|
|
@@ -21,7 +21,7 @@ When the plan runtime is `other`, the model is deferred. The running agent appli
|
|
|
21
21
|
|
|
22
22
|
## Loop
|
|
23
23
|
|
|
24
|
-
Check each finding against the code. Fix every one that holds. Run the review again until a pass reports none. The steps are in [reference/criteria.md](reference/criteria.md).
|
|
24
|
+
Check each finding against the code. Fix every one that holds. Run the review again until a pass reports none. Before a merge, [the pr-lifecycle review loop](../pr-lifecycle/reference/review-loop-before-merge.md) sets how many full passes run. The steps are in [reference/criteria.md](reference/criteria.md).
|
|
25
25
|
|
|
26
26
|
## Reading guide
|
|
27
27
|
|
|
@@ -18,4 +18,4 @@ An empty list is a valid review. End with a json block. Each finding has `file`,
|
|
|
18
18
|
|
|
19
19
|
## Loop
|
|
20
20
|
|
|
21
|
-
Fix every finding that holds, including a file the diff did not touch. Then run the review again. Stop when a pass reports no finding that holds.
|
|
21
|
+
Fix every finding that holds, including a file the diff did not touch. Then run the review again. Stop when a pass reports no finding that holds. Before a merge, [the pr-lifecycle review loop](../../pr-lifecycle/reference/review-loop-before-merge.md) sets how many full passes run.
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
A pull request the agent made merges only after a code review of its head comes back clean. Loop it:
|
|
4
4
|
|
|
5
|
-
1. Review the head with the command `CLAUDE_MERGE_REVIEW_COMMAND` names. Where it is unset, run `/code-review high --max-findings all` against the base.
|
|
5
|
+
1. Review the head with the command `CLAUDE_MERGE_REVIEW_COMMAND` names. Where it is unset, run `/code-review high --max-findings all` against the base. This is a full pass.
|
|
6
6
|
2. Record the pass: `python3 ~/.claude/scripts/merge_review_receipt.py <owner>/<name> <number> <head sha> <finding count>`.
|
|
7
7
|
3. Fix every valid finding, push, and review the new head. Answer each finding you rule out on the pull request with its file:line evidence.
|
|
8
|
-
4.
|
|
9
|
-
5.
|
|
8
|
+
4. The first three passes are full passes. Each later pass is `/code-review low --fix <number>`. It reads only the pull request's diff against its base and leaves its fixes uncommitted. Name the number: after a push, a bare `/code-review low` diffs the branch against its own upstream and sees nothing. Commit and push those fixes, then run the next pass on the new head. A pass that reported findings records its finding count, even when `--fix` fixed them all.
|
|
9
|
+
5. Repeat until a pass records `CLEAN`. A pass whose every finding is ruled out records `CLEAN` with `--ruled-out <finding count> --rulings <link to the rulings comment on this pull request>`.
|
|
10
|
+
6. Merge pinned to that head: `expectedHeadSha` on the merge tool, or `--match-head-commit` on `gh pr merge`.
|
|
10
11
|
|
|
11
12
|
`hooks/blocking/merge_review_gate.py` denies any other merge. In a merge-queue repository, enqueue with `gh pr merge <number> --repo <owner>/<name> --auto --match-head-commit <head sha>`; auto-merge that names no head is denied. A repository whose own CI and merge queue review and merge its pull requests is listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`, comma separated as `owner/name`, and skips the gate.
|
|
@@ -181,6 +181,7 @@ export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
|
|
|
181
181
|
]);
|
|
182
182
|
export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
|
|
183
183
|
'open_tasks_stop_gate.py',
|
|
184
|
+
'owned_pr_merge_stop_gate.py',
|
|
184
185
|
]);
|
|
185
186
|
export const ALL_CODEX_NATIVE_SESSION_START_HOOK_SCRIPT_NAMES = new Set([
|
|
186
187
|
'compaction_recall_prompt.py',
|
|
@@ -141,8 +141,9 @@ test('seeds one editable policy and one native Codex routing hook', () => {
|
|
|
141
141
|
assert.equal(allCodexPostToolUseCommands.length, 1, 'Codex gets the owned PR review loop and no other PostToolUse hook');
|
|
142
142
|
assert.match(allCodexPostToolUseCommands[0], /advisory\/owned_pr_review_loop\.py/);
|
|
143
143
|
const allCodexStopCommands = firstCodexHooks.hooks.Stop.flatMap(group => group.hooks.map(hook => hook.command));
|
|
144
|
-
assert.equal(allCodexStopCommands.length,
|
|
144
|
+
assert.equal(allCodexStopCommands.length, 2, 'Codex gets the open tasks and owned PR merge Stop gates and no other Stop hook');
|
|
145
145
|
assert.match(allCodexStopCommands[0], /open_tasks_stop_gate\.py/);
|
|
146
|
+
assert.match(allCodexStopCommands[1], /owned_pr_merge_stop_gate\.py/);
|
|
146
147
|
const allCodexSessionStartGroups = firstCodexHooks.hooks.SessionStart;
|
|
147
148
|
assert.deepEqual(allCodexSessionStartGroups.map(group => group.matcher), ['compact']);
|
|
148
149
|
const allCodexSessionStartCommands = allCodexSessionStartGroups.flatMap(group => group.hooks.map(hook => hook.command));
|
|
@@ -14,6 +14,9 @@ block to the agent's context::
|
|
|
14
14
|
3. Babysit. Follow the pstack babysit playbook ...
|
|
15
15
|
4. Ship. Follow the pstack shipping playbook ...
|
|
16
16
|
|
|
17
|
+
Each reported pull request also goes into the session record, so the owned
|
|
18
|
+
pull request Stop gate holds the turn until it merges.
|
|
19
|
+
|
|
17
20
|
The pull request URL in the tool response is the proof that a pull request
|
|
18
21
|
opened, so the hook needs no network call and reads the same on Claude Code
|
|
19
22
|
and Codex.
|
|
@@ -77,6 +80,7 @@ try:
|
|
|
77
80
|
all_wrapped_command_texts,
|
|
78
81
|
segment_program_and_arguments,
|
|
79
82
|
)
|
|
83
|
+
from owned_pr_session_record import record_pull_request
|
|
80
84
|
except ImportError as import_error:
|
|
81
85
|
raise ImportError(
|
|
82
86
|
"owned_pr_review_loop: cannot import its dependencies; "
|
|
@@ -336,7 +340,8 @@ def build_review_loop_context(all_pull_request_fields: dict[str, str]) -> str:
|
|
|
336
340
|
def main() -> None:
|
|
337
341
|
"""Add the review-loop instructions after a pull request opens on an owned repository.
|
|
338
342
|
|
|
339
|
-
Reads the PostToolUse payload from stdin.
|
|
343
|
+
Reads the PostToolUse payload from stdin. The pull request also goes into
|
|
344
|
+
the session record that the owned pull request Stop gate reads. Every quiet branch returns with
|
|
340
345
|
no output, so this hook can never alter or block a tool call.
|
|
341
346
|
"""
|
|
342
347
|
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
@@ -358,6 +363,7 @@ def main() -> None:
|
|
|
358
363
|
ALL_PRESET_REPOSITORY_DIGESTS,
|
|
359
364
|
):
|
|
360
365
|
return
|
|
366
|
+
record_pull_request(hook_payload.get("session_id"), pull_request)
|
|
361
367
|
write_post_tool_use_context_to_stdout(build_review_loop_context(pull_request))
|
|
362
368
|
|
|
363
369
|
|
|
@@ -281,12 +281,29 @@ def test_should_name_every_step_and_skill_in_the_context() -> None:
|
|
|
281
281
|
)
|
|
282
282
|
assert "jl-cmd-projects/claude-dev-env#42" in context
|
|
283
283
|
assert "efficient-review" in context
|
|
284
|
+
assert "three full passes" in context
|
|
285
|
+
assert "/code-review low --fix 42" in context
|
|
284
286
|
assert "gh pr ready 42 --repo jl-cmd-projects/claude-dev-env" in context
|
|
285
287
|
assert "poteto-mode/playbooks/babysit.md" in context
|
|
286
288
|
assert "poteto-mode/playbooks/shipping.md" in context
|
|
287
289
|
assert "merged" in context
|
|
288
290
|
|
|
289
291
|
|
|
292
|
+
def test_should_carry_the_owner_approval_to_merge() -> None:
|
|
293
|
+
context = owned_pr_review_loop.build_review_loop_context(
|
|
294
|
+
{
|
|
295
|
+
"owner": "jl-cmd-projects",
|
|
296
|
+
"repository": "claude-dev-env",
|
|
297
|
+
"number": "42",
|
|
298
|
+
"url": OWNED_PR_URL,
|
|
299
|
+
}
|
|
300
|
+
)
|
|
301
|
+
assert "The repository owner installed this hook" in context
|
|
302
|
+
assert "approves every step above, the merge included" in context
|
|
303
|
+
assert "without asking first" in context
|
|
304
|
+
assert "Your task now runs past opening this PR to its merge" in context
|
|
305
|
+
|
|
306
|
+
|
|
290
307
|
def test_main_should_emit_the_loop_after_gh_pr_create_on_an_owned_repository() -> None:
|
|
291
308
|
stdout = _run_hook(_shell_payload("gh pr create --fill", f"{OWNED_PR_URL}\n"))
|
|
292
309
|
payload = json.loads(stdout)
|
|
@@ -367,3 +384,11 @@ def test_hooks_json_should_route_shell_and_mcp_create_calls_to_the_hook() -> Non
|
|
|
367
384
|
"mcp__codex_apps__github_create_pull_request",
|
|
368
385
|
):
|
|
369
386
|
assert re.fullmatch(all_matchers[0], each_tool_name), each_tool_name
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_should_leave_a_held_pull_request_with_the_user() -> None:
|
|
390
|
+
context = owned_pr_review_loop.build_review_loop_context(
|
|
391
|
+
{"owner": "jl-cmd-projects", "repository": "claude-dev-env", "number": "42", "url": OWNED_PR_URL}
|
|
392
|
+
)
|
|
393
|
+
assert "When the user asked to hold this PR or to read it before it merges" in context
|
|
394
|
+
assert "Blocked: jl-cmd-projects/claude-dev-env#42 - the user holds the merge." in context
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stop hook that keeps a session working until its owned pull requests merge.
|
|
3
|
+
|
|
4
|
+
The owned pull request review-loop hook records each pull request the agent
|
|
5
|
+
opens on an owned repository outside the preset list. At the end of a turn
|
|
6
|
+
this gate asks GitHub for the state of each recorded pull request::
|
|
7
|
+
|
|
8
|
+
allow: every recorded pull request reads closed (merged or closed)
|
|
9
|
+
allow: the reply has "Blocked: acme/widgets#42 - the owner must approve"
|
|
10
|
+
block: acme/widgets#42 reads open and the reply names no blocker for it
|
|
11
|
+
|
|
12
|
+
A block sends the open pull requests back as the reason, so the model keeps
|
|
13
|
+
working the loop. Claude Code ends the turn after eight blocks in a row, and
|
|
14
|
+
Codex turns the reason into a continuation prompt. A state lookup that fails,
|
|
15
|
+
such as a missing ``gh`` or no network, counts as closed, so the gate never
|
|
16
|
+
holds a turn on a fact it could not read. The failure goes to stderr, where
|
|
17
|
+
the host shows it in the hook log.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
hooks_root_directory = str(Path(__file__).resolve().parent.parent)
|
|
29
|
+
if hooks_root_directory not in sys.path:
|
|
30
|
+
sys.path.insert(0, hooks_root_directory)
|
|
31
|
+
|
|
32
|
+
from hooks_constants.hook_block_logger import log_hook_block
|
|
33
|
+
from hooks_constants.owned_pr_merge_stop_gate_constants import (
|
|
34
|
+
ALLOW_EXIT_CODE,
|
|
35
|
+
BLOCK_DECISION,
|
|
36
|
+
BLOCK_REASON_PREFIX,
|
|
37
|
+
BLOCK_REASON_SUFFIX,
|
|
38
|
+
BLOCKED_LINE_PATTERN,
|
|
39
|
+
BLOCKED_TEXT_GROUP_INDEX,
|
|
40
|
+
BOLD_MARKER,
|
|
41
|
+
DECISION_KEY,
|
|
42
|
+
GH_API_SUBCOMMAND,
|
|
43
|
+
GH_JQ_OPTION,
|
|
44
|
+
GH_PROGRAM_NAME,
|
|
45
|
+
HOOK_EVENT_NAME,
|
|
46
|
+
LAST_ASSISTANT_MESSAGE_KEY,
|
|
47
|
+
LOOKUP_FAILURE_TEMPLATE,
|
|
48
|
+
NAME_END_PATTERN,
|
|
49
|
+
NAME_START_PATTERN,
|
|
50
|
+
OPEN_PULL_REQUEST_STATE,
|
|
51
|
+
PULL_REQUEST_ENDPOINT_TEMPLATE,
|
|
52
|
+
PULL_REQUEST_LINE_TEMPLATE,
|
|
53
|
+
PULL_REQUEST_REFERENCE_TEMPLATE,
|
|
54
|
+
PULL_REQUEST_STATE_QUERY,
|
|
55
|
+
REASON_KEY,
|
|
56
|
+
SESSION_ID_KEY,
|
|
57
|
+
STATE_QUERY_TIMEOUT_SECONDS,
|
|
58
|
+
)
|
|
59
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
60
|
+
from owned_pr_session_record import all_recorded_pull_requests
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_pull_request_open(all_pull_request_fields: dict[str, str]) -> bool:
|
|
64
|
+
"""Return True when GitHub reports the pull request as open.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
all_pull_request_fields: The owner, repository, number, and url fields.
|
|
68
|
+
"""
|
|
69
|
+
endpoint = PULL_REQUEST_ENDPOINT_TEMPLATE.format(**all_pull_request_fields)
|
|
70
|
+
try:
|
|
71
|
+
completed = subprocess.run(
|
|
72
|
+
[GH_PROGRAM_NAME, GH_API_SUBCOMMAND, endpoint, GH_JQ_OPTION, PULL_REQUEST_STATE_QUERY],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
timeout=STATE_QUERY_TIMEOUT_SECONDS,
|
|
76
|
+
check=False,
|
|
77
|
+
)
|
|
78
|
+
except (OSError, subprocess.TimeoutExpired) as lookup_error:
|
|
79
|
+
sys.stderr.write(LOOKUP_FAILURE_TEMPLATE.format(endpoint=endpoint, detail=lookup_error))
|
|
80
|
+
return False
|
|
81
|
+
if completed.returncode != 0:
|
|
82
|
+
sys.stderr.write(LOOKUP_FAILURE_TEMPLATE.format(endpoint=endpoint, detail=completed.stderr.strip()))
|
|
83
|
+
return completed.returncode == 0 and completed.stdout.strip() == OPEN_PULL_REQUEST_STATE
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_reported_blocked(all_pull_request_fields: dict[str, str], reply_text: str) -> bool:
|
|
87
|
+
"""Return True when a Blocked: line of the reply names the pull request.
|
|
88
|
+
|
|
89
|
+
The line names it by ``owner/repository#number`` or by its url. Bold
|
|
90
|
+
markers such as ``**Blocked:**`` are ignored.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
all_pull_request_fields: The owner, repository, number, and url fields.
|
|
94
|
+
reply_text: The closing reply of the turn.
|
|
95
|
+
"""
|
|
96
|
+
all_names = (
|
|
97
|
+
PULL_REQUEST_REFERENCE_TEMPLATE.format(**all_pull_request_fields).lower(),
|
|
98
|
+
all_pull_request_fields["url"].lower(),
|
|
99
|
+
)
|
|
100
|
+
all_blocked_texts = (
|
|
101
|
+
each_match.group(BLOCKED_TEXT_GROUP_INDEX).lower()
|
|
102
|
+
for each_match in map(BLOCKED_LINE_PATTERN.match, reply_text.replace(BOLD_MARKER, "").splitlines())
|
|
103
|
+
if each_match
|
|
104
|
+
)
|
|
105
|
+
all_name_patterns = [NAME_START_PATTERN + re.escape(each_name) + NAME_END_PATTERN for each_name in all_names]
|
|
106
|
+
return any(
|
|
107
|
+
re.search(each_pattern, each_text)
|
|
108
|
+
for each_text in all_blocked_texts
|
|
109
|
+
for each_pattern in all_name_patterns
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def unfinished_pull_requests(all_hook_input_fields: dict[str, object]) -> list[dict[str, str]]:
|
|
114
|
+
"""Return the recorded pull requests that are open and not reported blocked.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
all_hook_input_fields: The Stop hook payload.
|
|
118
|
+
"""
|
|
119
|
+
reply_value = all_hook_input_fields.get(LAST_ASSISTANT_MESSAGE_KEY)
|
|
120
|
+
reply_text = reply_value if isinstance(reply_value, str) else ""
|
|
121
|
+
return [
|
|
122
|
+
each_pull_request
|
|
123
|
+
for each_pull_request in all_recorded_pull_requests(
|
|
124
|
+
all_hook_input_fields.get(SESSION_ID_KEY)
|
|
125
|
+
)
|
|
126
|
+
if not is_reported_blocked(each_pull_request, reply_text)
|
|
127
|
+
and is_pull_request_open(each_pull_request)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def block_reason_for(all_open_pull_requests: list[dict[str, str]]) -> str:
|
|
132
|
+
"""Return the block reason that lists each open pull request.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
all_open_pull_requests: The pull requests that hold the turn.
|
|
136
|
+
"""
|
|
137
|
+
all_lines = (PULL_REQUEST_LINE_TEMPLATE.format(**each) for each in all_open_pull_requests)
|
|
138
|
+
return BLOCK_REASON_PREFIX + "".join(all_lines) + BLOCK_REASON_SUFFIX
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> int:
|
|
142
|
+
hook_input = read_hook_input_dictionary_from_stdin()
|
|
143
|
+
if hook_input is None:
|
|
144
|
+
return ALLOW_EXIT_CODE
|
|
145
|
+
all_open_pull_requests = unfinished_pull_requests(hook_input)
|
|
146
|
+
if not all_open_pull_requests:
|
|
147
|
+
return ALLOW_EXIT_CODE
|
|
148
|
+
block_reason = block_reason_for(all_open_pull_requests)
|
|
149
|
+
log_hook_block(Path(__file__).name, HOOK_EVENT_NAME, block_reason)
|
|
150
|
+
sys.stdout.write(json.dumps({DECISION_KEY: BLOCK_DECISION, REASON_KEY: block_reason}))
|
|
151
|
+
return ALLOW_EXIT_CODE
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|
|
@@ -178,6 +178,12 @@ def test_should_name_the_configured_review_command(monkeypatch: pytest.MonkeyPat
|
|
|
178
178
|
assert "`/team-review`" in _reason(_merge_tool(CLEAN_HEAD))
|
|
179
179
|
|
|
180
180
|
|
|
181
|
+
def test_should_name_the_low_fix_pass_after_three_full_reviews_in_the_deny_reason() -> None:
|
|
182
|
+
reason = _reason(_merge_tool(CLEAN_HEAD))
|
|
183
|
+
assert "three full passes" in reason
|
|
184
|
+
assert "`/code-review low --fix 12`" in reason
|
|
185
|
+
|
|
186
|
+
|
|
181
187
|
def test_should_allow_auto_merge_pinned_to_the_clean_head() -> None:
|
|
182
188
|
merge_review_receipt.record_review("acme/tools", 12, CLEAN_HEAD, 0)
|
|
183
189
|
command = f"gh pr merge 12 --repo acme/tools --auto --match-head-commit {CLEAN_HEAD}"
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Behavior tests for the owned pull request merge Stop gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import owned_pr_merge_stop_gate
|
|
13
|
+
import owned_pr_session_record
|
|
14
|
+
|
|
15
|
+
SESSION_ID = "session-abc"
|
|
16
|
+
PULL_REQUEST = {
|
|
17
|
+
"owner": "jl-cmd",
|
|
18
|
+
"repository": "snake-game",
|
|
19
|
+
"number": "9",
|
|
20
|
+
"url": "https://github.com/jl-cmd/snake-game/pull/9",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def isolated_temp_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
26
|
+
monkeypatch.setattr(owned_pr_session_record.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
27
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _gh_reports(
|
|
31
|
+
monkeypatch: pytest.MonkeyPatch, state_text: str, exit_code: int = 0
|
|
32
|
+
) -> list[list[str]]:
|
|
33
|
+
all_calls: list[list[str]] = []
|
|
34
|
+
|
|
35
|
+
def fake_run(all_arguments: list[str], **_: object) -> subprocess.CompletedProcess[str]:
|
|
36
|
+
all_calls.append(all_arguments)
|
|
37
|
+
return subprocess.CompletedProcess(all_arguments, exit_code, stdout=state_text, stderr="")
|
|
38
|
+
|
|
39
|
+
monkeypatch.setattr(owned_pr_merge_stop_gate.subprocess, "run", fake_run)
|
|
40
|
+
return all_calls
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run_gate(
|
|
44
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], reply_text: str
|
|
45
|
+
) -> str:
|
|
46
|
+
hook_input = {
|
|
47
|
+
"hook_event_name": "Stop",
|
|
48
|
+
"session_id": SESSION_ID,
|
|
49
|
+
"last_assistant_message": reply_text,
|
|
50
|
+
}
|
|
51
|
+
monkeypatch.setattr(
|
|
52
|
+
"sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8")))
|
|
53
|
+
)
|
|
54
|
+
assert owned_pr_merge_stop_gate.main() == 0
|
|
55
|
+
return capsys.readouterr().out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_main_should_block_while_a_recorded_pull_request_is_open(
|
|
59
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
60
|
+
) -> None:
|
|
61
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
62
|
+
all_calls = _gh_reports(monkeypatch, "open\n")
|
|
63
|
+
decision = json.loads(_run_gate(monkeypatch, capsys, "Draft PR is open."))
|
|
64
|
+
assert decision["decision"] == "block"
|
|
65
|
+
assert PULL_REQUEST["url"] in decision["reason"]
|
|
66
|
+
assert "/efficient-review" in decision["reason"]
|
|
67
|
+
assert all_calls == [["gh", "api", "repos/jl-cmd/snake-game/pulls/9", "--jq", ".state"]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_main_should_allow_once_the_pull_request_is_closed(
|
|
71
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
72
|
+
) -> None:
|
|
73
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
74
|
+
_gh_reports(monkeypatch, "closed\n")
|
|
75
|
+
assert _run_gate(monkeypatch, capsys, "Merged.") == ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_main_should_allow_a_blocked_line_that_names_the_pull_request(
|
|
79
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
80
|
+
) -> None:
|
|
81
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
82
|
+
_gh_reports(monkeypatch, "open\n")
|
|
83
|
+
reply_text = "Review is clean.\nBlocked: jl-cmd/snake-game#9 - the owner must approve the merge"
|
|
84
|
+
assert _run_gate(monkeypatch, capsys, reply_text) == ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_main_should_block_when_the_blocked_line_names_another_pull_request(
|
|
88
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
89
|
+
) -> None:
|
|
90
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
91
|
+
_gh_reports(monkeypatch, "open\n")
|
|
92
|
+
reply_text = "Blocked: jl-cmd/snake-game#90 - the owner must approve the merge"
|
|
93
|
+
assert json.loads(_run_gate(monkeypatch, capsys, reply_text))["decision"] == "block"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_main_should_allow_when_the_state_lookup_fails(
|
|
97
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
98
|
+
) -> None:
|
|
99
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
100
|
+
_gh_reports(monkeypatch, "", exit_code=1)
|
|
101
|
+
assert _run_gate(monkeypatch, capsys, "Draft PR is open.") == ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_main_should_allow_a_session_that_opened_no_owned_pull_request(
|
|
105
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
106
|
+
) -> None:
|
|
107
|
+
all_calls = _gh_reports(monkeypatch, "open\n")
|
|
108
|
+
assert _run_gate(monkeypatch, capsys, "Done.") == ""
|
|
109
|
+
assert all_calls == []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_is_pull_request_open_should_read_the_github_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
113
|
+
_gh_reports(monkeypatch, "open\n")
|
|
114
|
+
assert owned_pr_merge_stop_gate.is_pull_request_open(PULL_REQUEST)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_is_reported_blocked_should_accept_the_url() -> None:
|
|
118
|
+
reply_text = f"- Blocked: {PULL_REQUEST['url']} - needs the owner"
|
|
119
|
+
assert owned_pr_merge_stop_gate.is_reported_blocked(PULL_REQUEST, reply_text)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_unfinished_pull_requests_should_list_the_open_one(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
123
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
124
|
+
_gh_reports(monkeypatch, "open\n")
|
|
125
|
+
hook_input: dict[str, object] = {"session_id": SESSION_ID, "last_assistant_message": "Open."}
|
|
126
|
+
assert owned_pr_merge_stop_gate.unfinished_pull_requests(hook_input) == [PULL_REQUEST]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_block_reason_for_should_name_each_pull_request_and_the_blocked_form() -> None:
|
|
130
|
+
reason = owned_pr_merge_stop_gate.block_reason_for([PULL_REQUEST])
|
|
131
|
+
assert PULL_REQUEST["url"] in reason
|
|
132
|
+
assert "Blocked: <owner>/<repository>#<number>" in reason
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_main_should_report_a_failed_lookup_on_stderr(
|
|
136
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
137
|
+
) -> None:
|
|
138
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
139
|
+
_gh_reports(monkeypatch, "", exit_code=1)
|
|
140
|
+
hook_input = {"hook_event_name": "Stop", "session_id": SESSION_ID, "last_assistant_message": "Open."}
|
|
141
|
+
monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(json.dumps(hook_input).encode("utf-8"))))
|
|
142
|
+
owned_pr_merge_stop_gate.main()
|
|
143
|
+
assert "could not read repos/jl-cmd/snake-game/pulls/9" in capsys.readouterr().err
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_is_reported_blocked_should_accept_a_bold_blocked_label() -> None:
|
|
147
|
+
reply_text = "**Blocked:** jl-cmd/snake-game#9 - the owner must approve the merge"
|
|
148
|
+
assert owned_pr_merge_stop_gate.is_reported_blocked(PULL_REQUEST, reply_text)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_is_reported_blocked_should_skip_a_longer_owner_that_ends_with_the_name() -> None:
|
|
152
|
+
suffix_pull_request = {**PULL_REQUEST, "owner": "cmd", "url": "https://github.com/cmd/snake-game/pull/9"}
|
|
153
|
+
reply_text = "Blocked: jl-cmd/snake-game#9 - the owner must approve the merge"
|
|
154
|
+
assert not owned_pr_merge_stop_gate.is_reported_blocked(suffix_pull_request, reply_text)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_block_reason_for_should_name_a_user_hold_as_a_blocker() -> None:
|
|
158
|
+
assert "asked to hold the PR or to read it first" in owned_pr_merge_stop_gate.block_reason_for([PULL_REQUEST])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_is_reported_blocked_should_require_the_colon_after_blocked() -> None:
|
|
162
|
+
reply_text = "Blocked while CI runs on jl-cmd/snake-game#9."
|
|
163
|
+
assert not owned_pr_merge_stop_gate.is_reported_blocked(PULL_REQUEST, reply_text)
|
|
@@ -11,7 +11,8 @@ 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
|
+
- `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. It also records the pull request for the session in `owned_pr_session_record.py`.
|
|
15
|
+
- `blocking/owned_pr_merge_stop_gate.py` holds the end of a turn while a recorded pull request reads open on GitHub and the closing reply has no `Blocked:` line that names it. A failed state lookup passes.
|
|
15
16
|
|
|
16
17
|
## When it fires
|
|
17
18
|
|
|
@@ -23,6 +24,7 @@ This family rewrites a Git Bash command when path conversion would change a revi
|
|
|
23
24
|
- `blocking/bash_post_call_dispatcher.py` runs on `PostToolUse`, matcher `Bash|PowerShell`, timeout `60` seconds in `hooks.json`.
|
|
24
25
|
- `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
26
|
- `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`.
|
|
27
|
+
- `blocking/owned_pr_merge_stop_gate.py` runs on `Stop`, timeout `60` seconds in `hooks.json`. The installer also registers it on Codex `Stop` through `ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES`.
|
|
26
28
|
|
|
27
29
|
## Proving it
|
|
28
30
|
|
|
@@ -37,6 +39,7 @@ Preconditions:
|
|
|
37
39
|
- **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.
|
|
38
40
|
- **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
41
|
- **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.
|
|
42
|
+
- **Owned pull request merge gate.** Input is a `session_record_path` file that lists `jl-cmd/snake-game#9` and a `gh` stub that reports `open`. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_owned_pr_merge_stop_gate.py packages/claude-dev-env/hooks/test_owned_pr_session_record.py -q`. The adjacent test observes a `block` decision that names the pull request; `closed` and a `Blocked: jl-cmd/snake-game#9 - ...` line pass.
|
|
40
43
|
|
|
41
44
|
## Gotchas
|
|
42
45
|
|
|
@@ -17,7 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
17
17
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
18
18
|
- `blocking/pr_lifecycle_skill_gate.py` denies a commit, push, pull request action, or merge until the `pr-lifecycle` skill appears in the transcript since the last compaction.
|
|
19
19
|
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
20
|
-
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets.
|
|
20
|
+
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
|
|
21
21
|
- `blocking/merge_over_review_offer_gate.py` denies a decision card or question that offers a merge while it names open review findings, a red check, unaddressed review comments, or an override. It reads the whole card as one text: every question, header, context, reason, and option, whether the option is a string or a label with a consequence or description.
|
|
22
22
|
- `blocking/artifact_dark_mode_gate.py` denies an Artifact page publish when the page, inside the publish wrapper, keeps a light body or shows text under 4.5:1 contrast in dark mode. `blocking/artifact_dark_mode_render.cjs` renders the page in headless Chromium for it.
|
|
23
23
|
|
package/hooks/hooks.json
CHANGED
|
@@ -385,6 +385,11 @@
|
|
|
385
385
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/open_tasks_stop_gate.py",
|
|
386
386
|
"timeout": 10
|
|
387
387
|
},
|
|
388
|
+
{
|
|
389
|
+
"type": "command",
|
|
390
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/owned_pr_merge_stop_gate.py",
|
|
391
|
+
"timeout": 60
|
|
392
|
+
},
|
|
388
393
|
{
|
|
389
394
|
"type": "command",
|
|
390
395
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/session_title_turn_gate.py",
|
|
@@ -54,8 +54,10 @@ RECORD_COMMAND = "python3 ~/.claude/scripts/merge_review_receipt.py"
|
|
|
54
54
|
NO_CLEAN_REVIEW_REASON_TEMPLATE = (
|
|
55
55
|
"{slug}#{number} has no clean review on the head you are merging. Run the review "
|
|
56
56
|
"loop: review the pull request head with `{review_command}`, fix and push every "
|
|
57
|
-
"finding, and review again "
|
|
58
|
-
"
|
|
57
|
+
"finding, and review again. After three full passes, each later pass is "
|
|
58
|
+
"`/code-review low --fix {number}`, which names the pull request so the diff "
|
|
59
|
+
"covers its base: commit and push its fixes, then review the new head. "
|
|
60
|
+
"Repeat until a pass reports zero findings or every finding is ruled out with file:line "
|
|
59
61
|
"evidence. Record that pass with `"
|
|
60
62
|
+ RECORD_COMMAND
|
|
61
63
|
+ " {slug} {number} <head sha> <finding count>`, adding `--ruled-out <finding count> "
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Constants for the owned pull request merge Stop gate."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
HOOK_EVENT_NAME = "Stop"
|
|
6
|
+
SESSION_ID_KEY = "session_id"
|
|
7
|
+
LAST_ASSISTANT_MESSAGE_KEY = "last_assistant_message"
|
|
8
|
+
DECISION_KEY = "decision"
|
|
9
|
+
BLOCK_DECISION = "block"
|
|
10
|
+
REASON_KEY = "reason"
|
|
11
|
+
ALLOW_EXIT_CODE = 0
|
|
12
|
+
GH_PROGRAM_NAME = "gh"
|
|
13
|
+
GH_API_SUBCOMMAND = "api"
|
|
14
|
+
GH_JQ_OPTION = "--jq"
|
|
15
|
+
PULL_REQUEST_ENDPOINT_TEMPLATE = "repos/{owner}/{repository}/pulls/{number}"
|
|
16
|
+
PULL_REQUEST_STATE_QUERY = ".state"
|
|
17
|
+
OPEN_PULL_REQUEST_STATE = "open"
|
|
18
|
+
STATE_QUERY_TIMEOUT_SECONDS = 10
|
|
19
|
+
LOOKUP_FAILURE_TEMPLATE = "owned_pr_merge_stop_gate: could not read {endpoint}: {detail}\n"
|
|
20
|
+
BLOCKED_LINE_PATTERN: re.Pattern[str] = re.compile(
|
|
21
|
+
r"^\s*(?:[-*]\s*)?blocked\s*:(.*)$", re.IGNORECASE
|
|
22
|
+
)
|
|
23
|
+
BLOCKED_TEXT_GROUP_INDEX = 1
|
|
24
|
+
BOLD_MARKER = "**"
|
|
25
|
+
NAME_START_PATTERN = r"(?<![0-9A-Za-z_./-])"
|
|
26
|
+
NAME_END_PATTERN = r"(?![0-9A-Za-z/])"
|
|
27
|
+
PULL_REQUEST_REFERENCE_TEMPLATE = "{owner}/{repository}#{number}"
|
|
28
|
+
PULL_REQUEST_LINE_TEMPLATE = "\n- {url}"
|
|
29
|
+
BLOCK_REASON_PREFIX = "You opened these owned pull requests and they are still open:"
|
|
30
|
+
BLOCK_REASON_SUFFIX = (
|
|
31
|
+
"\nThe repository owner approves the whole owned PR loop, the merge included."
|
|
32
|
+
" Keep working now: run /efficient-review until a pass is clean, mark the PR ready,"
|
|
33
|
+
" babysit CI and review threads, then merge it and read it back as merged."
|
|
34
|
+
" End the turn early only with one line per pull request:"
|
|
35
|
+
" 'Blocked: <owner>/<repository>#<number> - <what only the owner can give>'."
|
|
36
|
+
" A user who asked to hold the PR or to read it first is such a blocker."
|
|
37
|
+
)
|
|
@@ -48,6 +48,9 @@ __all__ = [
|
|
|
48
48
|
"ALL_DECLARED_URL_KEYS",
|
|
49
49
|
"JSON_OBJECT_OPENER",
|
|
50
50
|
"REVIEW_LOOP_CONTEXT_TEMPLATE",
|
|
51
|
+
"SESSION_RECORD_FILE_TEMPLATE",
|
|
52
|
+
"SESSION_RECORD_TEMPORARY_PREFIX",
|
|
53
|
+
"ALL_PULL_REQUEST_FIELD_NAMES",
|
|
51
54
|
]
|
|
52
55
|
|
|
53
56
|
NAME_DIGEST_TEXT_ENCODING: str = "utf-8"
|
|
@@ -99,13 +102,21 @@ ALREADY_EXISTS_MARKER: str = "already exists"
|
|
|
99
102
|
ALL_DECLARED_URL_KEYS: tuple[str, ...] = ("html_url", "url")
|
|
100
103
|
JSON_OBJECT_OPENER: str = "{"
|
|
101
104
|
|
|
105
|
+
SESSION_RECORD_FILE_TEMPLATE: str = "owned-pr-loop-{session_file_id}.json"
|
|
106
|
+
SESSION_RECORD_TEMPORARY_PREFIX: str = "owned-pr-loop-"
|
|
107
|
+
ALL_PULL_REQUEST_FIELD_NAMES: tuple[str, ...] = ("owner", "repository", "number", "url")
|
|
108
|
+
|
|
102
109
|
REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
|
|
103
110
|
"=== OWNED PR REVIEW LOOP (context reminder, never a block) ===\n"
|
|
104
111
|
"You opened {owner}/{repository}#{number} {url}\n"
|
|
105
112
|
"This repository has no automated review, so you own the review and the merge.\n"
|
|
113
|
+
"Your task now runs past opening this PR to its merge, also when your brief\n"
|
|
114
|
+
"asked for a draft or stopped at opening it.\n"
|
|
106
115
|
"1. Review loop. Load the efficient-review skill (/efficient-review). Review the\n"
|
|
107
116
|
" branch, check each finding against the code, fix every one that holds, push,\n"
|
|
108
|
-
" and review again.
|
|
117
|
+
" and review again. After three full passes, each later pass is\n"
|
|
118
|
+
" /code-review low --fix {number}; commit and push its fixes, then review the new head.\n"
|
|
119
|
+
" Repeat until a pass reports no findings.\n"
|
|
109
120
|
"2. Mark ready. Once the loop is clean, CI is green, and no review thread is open,\n"
|
|
110
121
|
" mark the PR ready for review (gh pr ready {number} --repo {owner}/{repository},\n"
|
|
111
122
|
" or update_pull_request with draft false).\n"
|
|
@@ -116,5 +127,9 @@ REVIEW_LOOP_CONTEXT_TEMPLATE: str = (
|
|
|
116
127
|
"4. Ship. Follow the pstack shipping playbook (poteto-mode/playbooks/shipping.md)\n"
|
|
117
128
|
" to land the PR, or the pr-lifecycle skill outside poteto-mode. Read the PR\n"
|
|
118
129
|
" back and confirm it reads merged.\n"
|
|
119
|
-
"
|
|
130
|
+
"The repository owner installed this hook and\n"
|
|
131
|
+
"approves every step above, the merge included. Run each step yourself\n"
|
|
132
|
+
"without asking first. The task ends at a merged PR or a named blocker.\n"
|
|
133
|
+
"When the user asked to hold this PR or to read it before it merges, stop\n"
|
|
134
|
+
"after step 3 and end with: Blocked: {owner}/{repository}#{number} - the user holds the merge."
|
|
120
135
|
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Session record of the owned pull requests an agent opened.
|
|
2
|
+
|
|
3
|
+
The owned pull request review-loop hook writes each pull request it reports
|
|
4
|
+
into one JSON file per session in the system temp folder. The owned pull
|
|
5
|
+
request Stop gate reads the same file back::
|
|
6
|
+
|
|
7
|
+
owned-pr-loop-<session id>.json
|
|
8
|
+
[{"owner": "acme", "repository": "widgets", "number": "42",
|
|
9
|
+
"url": "https://github.com/acme/widgets/pull/42"}]
|
|
10
|
+
|
|
11
|
+
Both Claude Code and Codex send ``session_id`` to PostToolUse and Stop hooks,
|
|
12
|
+
so the file links the two hooks on either host.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from atomic_file_writer import write_text_atomically
|
|
22
|
+
from hooks_constants.atomic_file_writer_constants import ATOMIC_WRITE_TEMPORARY_SUFFIX
|
|
23
|
+
from hooks_constants.owned_pr_review_loop_constants import (
|
|
24
|
+
ALL_PULL_REQUEST_FIELD_NAMES,
|
|
25
|
+
NAME_DIGEST_TEXT_ENCODING,
|
|
26
|
+
SESSION_RECORD_FILE_TEMPLATE,
|
|
27
|
+
SESSION_RECORD_TEMPORARY_PREFIX,
|
|
28
|
+
)
|
|
29
|
+
from hooks_constants.session_file_id import session_file_id
|
|
30
|
+
|
|
31
|
+
__all__ = ["session_record_path", "all_recorded_pull_requests", "record_pull_request"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def session_record_path(session_id: object) -> Path:
|
|
35
|
+
"""Return the record file path for one session.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
39
|
+
"""
|
|
40
|
+
file_name = SESSION_RECORD_FILE_TEMPLATE.format(session_file_id=session_file_id(session_id))
|
|
41
|
+
return Path(tempfile.gettempdir()) / file_name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_session_id(session_id: object) -> bool:
|
|
45
|
+
return isinstance(session_id, str) and bool(session_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_pull_request_entry(entry: object) -> bool:
|
|
49
|
+
return isinstance(entry, dict) and all(
|
|
50
|
+
isinstance(entry.get(each_field), str) for each_field in ALL_PULL_REQUEST_FIELD_NAMES
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def all_recorded_pull_requests(session_id: object) -> list[dict[str, str]]:
|
|
55
|
+
"""Return the pull requests recorded for the session, oldest first.
|
|
56
|
+
|
|
57
|
+
A payload with no session id, or a missing, unreadable, or malformed
|
|
58
|
+
file, reads as an empty record.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
62
|
+
"""
|
|
63
|
+
if not _is_session_id(session_id):
|
|
64
|
+
return []
|
|
65
|
+
try:
|
|
66
|
+
record_text = session_record_path(session_id).read_text(encoding=NAME_DIGEST_TEXT_ENCODING)
|
|
67
|
+
all_entries = json.loads(record_text)
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return []
|
|
70
|
+
if not isinstance(all_entries, list):
|
|
71
|
+
return []
|
|
72
|
+
return [each_entry for each_entry in all_entries if _is_pull_request_entry(each_entry)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def record_pull_request(session_id: object, all_pull_request_fields: dict[str, str]) -> None:
|
|
76
|
+
"""Add one pull request to the session record, once per url.
|
|
77
|
+
|
|
78
|
+
A payload with no session id records nothing. A write that fails leaves
|
|
79
|
+
the record as it was, so the PostToolUse hook still adds its context.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
session_id: The ``session_id`` field of the hook payload.
|
|
83
|
+
all_pull_request_fields: The owner, repository, number, and url fields.
|
|
84
|
+
"""
|
|
85
|
+
if not _is_session_id(session_id):
|
|
86
|
+
return
|
|
87
|
+
all_entries = all_recorded_pull_requests(session_id)
|
|
88
|
+
if any(each_entry["url"] == all_pull_request_fields["url"] for each_entry in all_entries):
|
|
89
|
+
return
|
|
90
|
+
new_entry = {
|
|
91
|
+
each_field: all_pull_request_fields[each_field]
|
|
92
|
+
for each_field in ALL_PULL_REQUEST_FIELD_NAMES
|
|
93
|
+
}
|
|
94
|
+
try:
|
|
95
|
+
write_text_atomically(
|
|
96
|
+
session_record_path(session_id),
|
|
97
|
+
json.dumps([*all_entries, new_entry]),
|
|
98
|
+
NAME_DIGEST_TEXT_ENCODING,
|
|
99
|
+
SESSION_RECORD_TEMPORARY_PREFIX,
|
|
100
|
+
ATOMIC_WRITE_TEMPORARY_SUFFIX,
|
|
101
|
+
False,
|
|
102
|
+
)
|
|
103
|
+
except (OSError, UnicodeError):
|
|
104
|
+
return
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Behavior tests for the owned pull request session record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import owned_pr_session_record
|
|
10
|
+
|
|
11
|
+
SESSION_ID = "session-abc"
|
|
12
|
+
PULL_REQUEST = {
|
|
13
|
+
"owner": "jl-cmd",
|
|
14
|
+
"repository": "snake-game",
|
|
15
|
+
"number": "9",
|
|
16
|
+
"url": "https://github.com/jl-cmd/snake-game/pull/9",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def isolated_temp_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
22
|
+
monkeypatch.setattr(owned_pr_session_record.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_session_record_path_should_name_the_session_in_the_temp_folder(tmp_path: Path) -> None:
|
|
26
|
+
assert (
|
|
27
|
+
owned_pr_session_record.session_record_path("a/b c") == tmp_path / "owned-pr-loop-abc.json"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_record_pull_request_should_keep_one_entry_per_url() -> None:
|
|
32
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
33
|
+
owned_pr_session_record.record_pull_request(SESSION_ID, PULL_REQUEST)
|
|
34
|
+
assert owned_pr_session_record.all_recorded_pull_requests(SESSION_ID) == [PULL_REQUEST]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_record_pull_request_should_skip_a_payload_without_a_session_id(tmp_path: Path) -> None:
|
|
38
|
+
owned_pr_session_record.record_pull_request(None, PULL_REQUEST)
|
|
39
|
+
assert list(tmp_path.iterdir()) == []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_all_recorded_pull_requests_should_read_a_malformed_file_as_empty() -> None:
|
|
43
|
+
owned_pr_session_record.session_record_path(SESSION_ID).write_text(
|
|
44
|
+
"{not json", encoding="utf-8"
|
|
45
|
+
)
|
|
46
|
+
assert owned_pr_session_record.all_recorded_pull_requests(SESSION_ID) == []
|
package/package.json
CHANGED
|
@@ -140,6 +140,7 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
140
140
|
hooks/blocking/merge_over_review_offer_gate.py -> exempt
|
|
141
141
|
hooks/blocking/final_reply_gate.py -> exempt
|
|
142
142
|
hooks/blocking/open_tasks_stop_gate.py -> exempt
|
|
143
|
+
hooks/blocking/owned_pr_merge_stop_gate.py -> exempt
|
|
143
144
|
hooks/blocking/session_title_turn_gate.py -> exempt
|
|
144
145
|
hooks/blocking/some_new_blocker.py -> flagged
|
|
145
146
|
|
|
@@ -186,6 +187,11 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
|
|
|
186
187
|
``Blocked:`` or ``Open:``. It decides when a turn may end and no code or
|
|
187
188
|
safety policy.
|
|
188
189
|
|
|
190
|
+
The owned pull request Stop gate holds the end of a turn while a pull
|
|
191
|
+
request the session opened on an owned repository is still open and the
|
|
192
|
+
closing reply names no ``Blocked:`` line for it. It decides when a turn may
|
|
193
|
+
end and no code or safety policy.
|
|
194
|
+
|
|
189
195
|
Args:
|
|
190
196
|
registered_string: One command, path, script, or entrypoint string.
|
|
191
197
|
"""
|
|
@@ -79,6 +79,7 @@ ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
|
|
|
79
79
|
"blocking/merge_over_review_offer_gate.py",
|
|
80
80
|
"blocking/final_reply_gate.py",
|
|
81
81
|
"blocking/open_tasks_stop_gate.py",
|
|
82
|
+
"blocking/owned_pr_merge_stop_gate.py",
|
|
82
83
|
"blocking/session_title_turn_gate.py",
|
|
83
84
|
)
|
|
84
85
|
ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
|