claude-dev-env 8.98.8 → 8.98.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/blocking/resolve_thread_reply_gate.py +203 -41
- package/hooks/blocking/test_resolve_thread_reply_gate.py +341 -25
- package/hooks/features/conduct-gates.md +3 -3
- package/hooks/hooks.json +1 -1
- package/hooks/hooks_constants/resolve_thread_reply_gate_constants.py +18 -3
- package/package.json +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
"""Deny a review thread resolve until a reply posted after the last user prompt and
|
|
1
|
+
"""Deny a review thread resolve until a reply on that thread posted after the last user prompt and its last resolve."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import sys
|
|
6
|
-
from collections.abc import Iterable
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
@@ -15,12 +16,25 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import SHELL_TOOL_NAMES
|
|
|
15
16
|
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
16
17
|
from hooks_constants.resolve_thread_reply_gate_constants import (
|
|
17
18
|
ALL_REPLY_TOOL_NAME_SUFFIXES,
|
|
19
|
+
ALL_THREAD_ID_INPUT_KEYS,
|
|
20
|
+
COMMENT_ANCHOR_PATTERN,
|
|
21
|
+
COMMENT_ID_INPUT_KEY,
|
|
18
22
|
MISSING_REPLY_REASON,
|
|
19
23
|
RESOLVE_TOOL_NAME_PATTERN,
|
|
24
|
+
RESOLVE_THREAD_METHOD,
|
|
25
|
+
REVIEW_WRITE_METHOD_KEY,
|
|
26
|
+
REVIEW_WRITE_TOOL_NAME_PATTERN,
|
|
20
27
|
SHELL_COMMAND_KEY,
|
|
21
28
|
SHELL_REPLY_COMMAND_PATTERN,
|
|
29
|
+
SHELL_REPLY_COMMENT_ID_PATTERN,
|
|
30
|
+
SHELL_RESOLVE_COMMAND_PATTERN,
|
|
31
|
+
SHELL_RESOLVE_COMMENT_ID_PATTERN,
|
|
32
|
+
TEXT_BLOCK_TYPE,
|
|
33
|
+
TEXT_FIELD,
|
|
34
|
+
THREAD_ID_PATTERN,
|
|
22
35
|
)
|
|
23
36
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
37
|
+
from hooks_constants.transcript_skill_scan_constants import IS_ERROR_FIELD, TOOL_RESULT_BLOCK_TYPE
|
|
24
38
|
from transcript_skill_scan import TranscriptToolCall, tool_calls_since_last_prompt
|
|
25
39
|
|
|
26
40
|
|
|
@@ -33,6 +47,51 @@ def is_resolve_call(tool_name: object) -> bool:
|
|
|
33
47
|
return isinstance(tool_name, str) and RESOLVE_TOOL_NAME_PATTERN.match(tool_name) is not None
|
|
34
48
|
|
|
35
49
|
|
|
50
|
+
def _is_review_write_resolve(tool_name: object, all_tool_input_fields: dict[str, object]) -> bool:
|
|
51
|
+
return (
|
|
52
|
+
isinstance(tool_name, str)
|
|
53
|
+
and REVIEW_WRITE_TOOL_NAME_PATTERN.match(tool_name) is not None
|
|
54
|
+
and all_tool_input_fields.get(REVIEW_WRITE_METHOD_KEY) == RESOLVE_THREAD_METHOD
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _shell_command(tool_name: object, all_tool_input_fields: dict[str, object]) -> str | None:
|
|
59
|
+
command = all_tool_input_fields.get(SHELL_COMMAND_KEY)
|
|
60
|
+
return command if tool_name in SHELL_TOOL_NAMES and isinstance(command, str) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolved_thread_ids(tool_name: object, all_tool_input_fields: dict[str, object]) -> frozenset[str] | None:
|
|
64
|
+
"""Return the thread ids or comment ids a call resolves, or None when the call resolves no review thread.
|
|
65
|
+
|
|
66
|
+
::
|
|
67
|
+
|
|
68
|
+
mcp__github__resolve_review_thread {"threadID": "PRRT_a"} -> {"PRRT_a"}
|
|
69
|
+
mcp__github__pull_request_review_write
|
|
70
|
+
{"method": "resolve_thread", "threadId": "PRRT_a"} -> {"PRRT_a"}
|
|
71
|
+
Bash "gh api graphql ... resolveReviewThread ... PRRT_a" -> {"PRRT_a"}
|
|
72
|
+
Bash "gh api graphql ... resolveReviewThread ... $thread" -> {}
|
|
73
|
+
Bash "gh api -X POST .../pulls/7/ccr/comments/9/resolve" -> {"9"}
|
|
74
|
+
Bash "gh pr view 7" -> None
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
tool_name: The tool name from a hook payload or transcript.
|
|
78
|
+
all_tool_input_fields: The tool input from a hook payload or transcript.
|
|
79
|
+
"""
|
|
80
|
+
if is_resolve_call(tool_name) or _is_review_write_resolve(tool_name, all_tool_input_fields):
|
|
81
|
+
return frozenset(
|
|
82
|
+
each_value
|
|
83
|
+
for each_value in (all_tool_input_fields.get(each_key) for each_key in ALL_THREAD_ID_INPUT_KEYS)
|
|
84
|
+
if isinstance(each_value, str) and each_value
|
|
85
|
+
)
|
|
86
|
+
command = _shell_command(tool_name, all_tool_input_fields)
|
|
87
|
+
if command is None:
|
|
88
|
+
return None
|
|
89
|
+
all_resolved_comment_ids = frozenset(SHELL_RESOLVE_COMMENT_ID_PATTERN.findall(command))
|
|
90
|
+
if not all_resolved_comment_ids and SHELL_RESOLVE_COMMAND_PATTERN.search(command) is None:
|
|
91
|
+
return None
|
|
92
|
+
return frozenset(THREAD_ID_PATTERN.findall(command)) | all_resolved_comment_ids
|
|
93
|
+
|
|
94
|
+
|
|
36
95
|
def is_reply_call(tool_call: TranscriptToolCall) -> bool:
|
|
37
96
|
"""Return whether a transcript tool call replies on a review thread.
|
|
38
97
|
|
|
@@ -48,66 +107,165 @@ def is_reply_call(tool_call: TranscriptToolCall) -> bool:
|
|
|
48
107
|
"""
|
|
49
108
|
if tool_call.name.endswith(ALL_REPLY_TOOL_NAME_SUFFIXES):
|
|
50
109
|
return True
|
|
51
|
-
command = tool_call.tool_input
|
|
52
|
-
return (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
110
|
+
command = _shell_command(tool_call.name, tool_call.tool_input)
|
|
111
|
+
return command is not None and SHELL_REPLY_COMMAND_PATTERN.search(command) is not None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _reply_comment_ids(all_tool_input_fields: dict[str, object]) -> frozenset[str]:
|
|
115
|
+
comment_id = all_tool_input_fields.get(COMMENT_ID_INPUT_KEY)
|
|
116
|
+
if isinstance(comment_id, (int, float)) and not isinstance(comment_id, bool):
|
|
117
|
+
return frozenset({str(int(comment_id))})
|
|
118
|
+
if isinstance(comment_id, str) and comment_id.isdigit():
|
|
119
|
+
return frozenset({comment_id})
|
|
120
|
+
return frozenset()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _reply_targets(tool_call: TranscriptToolCall) -> tuple[frozenset[str], frozenset[str]]:
|
|
124
|
+
command = _shell_command(tool_call.name, tool_call.tool_input)
|
|
125
|
+
if command is None:
|
|
126
|
+
return frozenset(), _reply_comment_ids(tool_call.tool_input)
|
|
127
|
+
return frozenset(THREAD_ID_PATTERN.findall(command)), frozenset(SHELL_REPLY_COMMENT_ID_PATTERN.findall(command))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _result_content_texts(result_content: object) -> list[str]:
|
|
131
|
+
if isinstance(result_content, str):
|
|
132
|
+
return [result_content]
|
|
133
|
+
all_parts = result_content if isinstance(result_content, list) else []
|
|
134
|
+
return [
|
|
135
|
+
each_part[TEXT_FIELD]
|
|
136
|
+
for each_part in all_parts
|
|
137
|
+
if isinstance(each_part, dict)
|
|
138
|
+
and each_part.get("type") == TEXT_BLOCK_TYPE
|
|
139
|
+
and isinstance(each_part.get(TEXT_FIELD), str)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _successful_result_texts(all_entry_fields: dict[str, object]) -> Iterator[str]:
|
|
144
|
+
message = all_entry_fields.get("message")
|
|
145
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
146
|
+
for each_block in content if isinstance(content, list) else []:
|
|
147
|
+
if not isinstance(each_block, dict) or each_block.get("type") != TOOL_RESULT_BLOCK_TYPE:
|
|
148
|
+
continue
|
|
149
|
+
if each_block.get(IS_ERROR_FIELD) is not True:
|
|
150
|
+
yield from _result_content_texts(each_block.get("content"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _thread_ids_by_comment_id_in_text(text: str) -> dict[str, str]:
|
|
154
|
+
all_thread_matches = list(THREAD_ID_PATTERN.finditer(text))
|
|
155
|
+
if not all_thread_matches:
|
|
156
|
+
return {}
|
|
157
|
+
all_span_ends = [each_match.start() for each_match in all_thread_matches[1:]] + [len(text)]
|
|
158
|
+
return {
|
|
159
|
+
each_anchor.group(1): each_thread_match.group()
|
|
160
|
+
for each_thread_match, each_span_end in zip(all_thread_matches, all_span_ends, strict=True)
|
|
161
|
+
for each_anchor in COMMENT_ANCHOR_PATTERN.finditer(text, each_thread_match.end(), each_span_end)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def thread_ids_by_comment_id(all_transcript_lines: Iterable[str]) -> dict[str, str]:
|
|
166
|
+
"""Return each review comment id mapped to its thread id, read from successful tool results.
|
|
167
|
+
|
|
168
|
+
::
|
|
169
|
+
|
|
170
|
+
get_review_comments {"id": "PRRT_a", "comments": [{"html_url": "...#discussion_r9"}]} -> {"9": "PRRT_a"}
|
|
171
|
+
gh api graphql {"id": "PRRT_a", "comments": {"nodes": [{"databaseId": 9}]}} -> {"9": "PRRT_a"}
|
|
57
172
|
|
|
173
|
+
A comment id belongs to the nearest thread id before it in the same result text.
|
|
58
174
|
|
|
59
|
-
|
|
60
|
-
|
|
175
|
+
Args:
|
|
176
|
+
all_transcript_lines: JSON transcript entries, one per line.
|
|
177
|
+
"""
|
|
178
|
+
thread_id_by_comment_id: dict[str, str] = {}
|
|
179
|
+
for each_line in all_transcript_lines:
|
|
180
|
+
try:
|
|
181
|
+
entry_fields = json.loads(each_line)
|
|
182
|
+
except ValueError:
|
|
183
|
+
continue
|
|
184
|
+
if not isinstance(entry_fields, dict):
|
|
185
|
+
continue
|
|
186
|
+
for each_text in _successful_result_texts(entry_fields):
|
|
187
|
+
thread_id_by_comment_id.update(_thread_ids_by_comment_id_in_text(each_text))
|
|
188
|
+
return thread_id_by_comment_id
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _thread_keys(all_ids: Iterable[str], all_thread_ids_by_comment_id: dict[str, str]) -> set[str]:
|
|
192
|
+
return {all_thread_ids_by_comment_id.get(each_id, each_id) for each_id in all_ids}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _keys_after_call(
|
|
196
|
+
all_answered_keys: set[str], tool_call: TranscriptToolCall, all_thread_ids_by_comment_id: dict[str, str]
|
|
197
|
+
) -> set[str]:
|
|
198
|
+
if not tool_call.succeeded:
|
|
199
|
+
return all_answered_keys
|
|
200
|
+
spent_thread_ids = resolved_thread_ids(tool_call.name, tool_call.tool_input)
|
|
201
|
+
if spent_thread_ids is not None:
|
|
202
|
+
return all_answered_keys - _thread_keys(spent_thread_ids, all_thread_ids_by_comment_id)
|
|
203
|
+
if not is_reply_call(tool_call):
|
|
204
|
+
return all_answered_keys
|
|
205
|
+
reply_thread_ids, reply_comment_ids = _reply_targets(tool_call)
|
|
206
|
+
return all_answered_keys | _thread_keys(reply_thread_ids | reply_comment_ids, all_thread_ids_by_comment_id)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _answered_thread_keys(
|
|
210
|
+
all_calls: Iterable[TranscriptToolCall], all_thread_ids_by_comment_id: dict[str, str]
|
|
211
|
+
) -> set[str]:
|
|
212
|
+
answered_thread_keys: set[str] = set()
|
|
213
|
+
for each_call in all_calls:
|
|
214
|
+
answered_thread_keys = _keys_after_call(answered_thread_keys, each_call, all_thread_ids_by_comment_id)
|
|
215
|
+
return answered_thread_keys
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def has_posted_reply_on_threads(all_transcript_lines: Iterable[str], all_thread_ids: frozenset[str]) -> bool:
|
|
219
|
+
"""Return whether each named thread has a reply that succeeded since the last user prompt and its last resolve.
|
|
61
220
|
|
|
62
221
|
::
|
|
63
222
|
|
|
64
|
-
prompt, reply ok, resolve pending
|
|
65
|
-
prompt, reply
|
|
66
|
-
prompt, resolve
|
|
67
|
-
prompt, reply ok, resolve ok, resolve pending
|
|
68
|
-
prompt, reply ok
|
|
223
|
+
prompt, read, reply ok on thread A, resolve A pending -> True
|
|
224
|
+
prompt, read, reply ok on thread A, resolve B pending -> False
|
|
225
|
+
prompt, reply refused by a hook, resolve A -> False
|
|
226
|
+
prompt, reply ok on A, resolve A ok, resolve A pending -> False
|
|
227
|
+
prompt, reply ok on A and on B, resolve A ok, resolve B pending -> True
|
|
228
|
+
no thread ids -> False
|
|
69
229
|
|
|
70
|
-
|
|
230
|
+
A successful resolve spends the replies on its threads. A failed or denied
|
|
71
231
|
resolve spends none, so a retry after a resolve error needs no second reply.
|
|
232
|
+
A reply by comment id counts for the thread that a successful tool result,
|
|
233
|
+
such as ``pull_request_read`` ``get_review_comments``, lists the comment under,
|
|
234
|
+
and for a resolve that names the same comment id.
|
|
72
235
|
|
|
73
236
|
Args:
|
|
74
237
|
all_transcript_lines: JSON transcript entries, one per line.
|
|
238
|
+
all_thread_ids: The thread ids, or comment ids, the pending resolve names.
|
|
75
239
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return
|
|
240
|
+
all_lines = list(all_transcript_lines)
|
|
241
|
+
thread_id_by_comment_id = thread_ids_by_comment_id(all_lines)
|
|
242
|
+
answered_thread_keys = _answered_thread_keys(tool_calls_since_last_prompt(all_lines), thread_id_by_comment_id)
|
|
243
|
+
return bool(all_thread_ids) and _thread_keys(all_thread_ids, thread_id_by_comment_id) <= answered_thread_keys
|
|
80
244
|
|
|
81
245
|
|
|
82
|
-
def
|
|
83
|
-
if not tool_call.succeeded:
|
|
84
|
-
return unspent_reply_count
|
|
85
|
-
if is_resolve_call(tool_call.name):
|
|
86
|
-
return max(0, unspent_reply_count - 1)
|
|
87
|
-
if is_reply_call(tool_call):
|
|
88
|
-
return unspent_reply_count + 1
|
|
89
|
-
return unspent_reply_count
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _transcript_shows_reply(transcript_path: str) -> bool | None:
|
|
246
|
+
def _transcript_shows_reply(transcript_path: str, all_thread_ids: frozenset[str]) -> bool | None:
|
|
93
247
|
try:
|
|
94
248
|
with open(transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY) as transcript:
|
|
95
|
-
return
|
|
249
|
+
return has_posted_reply_on_threads(transcript, all_thread_ids)
|
|
96
250
|
except OSError:
|
|
97
251
|
return None
|
|
98
252
|
|
|
99
253
|
|
|
100
254
|
def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | None:
|
|
101
|
-
"""Return a deny for a review thread resolve with no posted reply before it, else None.
|
|
255
|
+
"""Return a deny for a review thread resolve with no posted reply on that thread before it, else None.
|
|
102
256
|
|
|
103
257
|
::
|
|
104
258
|
|
|
105
|
-
resolve after a reply that posted
|
|
106
|
-
resolve after a reply
|
|
107
|
-
resolve
|
|
108
|
-
|
|
109
|
-
resolve
|
|
110
|
-
|
|
259
|
+
resolve after a reply on the thread that posted -> None
|
|
260
|
+
resolve after a reply on another thread -> MISSING_REPLY_REASON
|
|
261
|
+
resolve after a reply a hook refused -> MISSING_REPLY_REASON
|
|
262
|
+
resolve with no reply this turn -> MISSING_REPLY_REASON
|
|
263
|
+
second resolve after one reply -> MISSING_REPLY_REASON
|
|
264
|
+
gh api graphql resolveReviewThread, no reply -> MISSING_REPLY_REASON
|
|
265
|
+
gh api .../ccr/comments/9/resolve, no reply -> MISSING_REPLY_REASON
|
|
266
|
+
pull_request_review_write resolve_thread, no reply -> MISSING_REPLY_REASON
|
|
267
|
+
resolve with an unreadable transcript -> None
|
|
268
|
+
any other tool or shell command -> None
|
|
111
269
|
|
|
112
270
|
The gate reads the calling agent's own transcript: a subagent's file when
|
|
113
271
|
the payload names one, else the session transcript.
|
|
@@ -115,12 +273,16 @@ def decision_for(all_payload_fields: dict[str, object]) -> dict[str, object] | N
|
|
|
115
273
|
Args:
|
|
116
274
|
all_payload_fields: The parsed PreToolUse input.
|
|
117
275
|
"""
|
|
118
|
-
|
|
276
|
+
tool_input = all_payload_fields.get("tool_input")
|
|
277
|
+
all_thread_ids = resolved_thread_ids(
|
|
278
|
+
all_payload_fields.get("tool_name"), tool_input if isinstance(tool_input, dict) else {}
|
|
279
|
+
)
|
|
280
|
+
if all_thread_ids is None:
|
|
119
281
|
return None
|
|
120
282
|
all_paths = transcript_paths(all_payload_fields)
|
|
121
283
|
if not all_paths:
|
|
122
284
|
return None
|
|
123
|
-
shows_reply = _transcript_shows_reply(all_paths[-1])
|
|
285
|
+
shows_reply = _transcript_shows_reply(all_paths[-1], all_thread_ids)
|
|
124
286
|
if shows_reply is None or shows_reply:
|
|
125
287
|
return None
|
|
126
288
|
return deny_decision(MISSING_REPLY_REASON)
|
|
@@ -16,6 +16,11 @@ from transcript_skill_scan import TranscriptToolCall
|
|
|
16
16
|
GATE_PATH = Path(__file__).resolve().parent / "resolve_thread_reply_gate.py"
|
|
17
17
|
RESOLVE_TOOL_NAME = "mcp__github__resolve_review_thread"
|
|
18
18
|
REPLY_TOOL_NAME = "mcp__github__add_reply_to_pull_request_comment"
|
|
19
|
+
READ_TOOL_NAME = "mcp__github__pull_request_read"
|
|
20
|
+
FIRST_THREAD_ID = "PRRT_kwDOfirst"
|
|
21
|
+
SECOND_THREAD_ID = "PRRT_kwDOsecond"
|
|
22
|
+
FIRST_COMMENT_ID = 350000001
|
|
23
|
+
SECOND_COMMENT_ID = 350000005
|
|
19
24
|
HOOK_DENIAL_TEXT = "PreToolUse:mcp__github__add_reply_to_pull_request_comment hook error: too many sentences"
|
|
20
25
|
|
|
21
26
|
|
|
@@ -33,17 +38,60 @@ def _result(tool_use_id: str, is_error: bool = False, text: str = "ok") -> str:
|
|
|
33
38
|
return json.dumps({"type": "user", "message": {"content": [tool_result]}})
|
|
34
39
|
|
|
35
40
|
|
|
41
|
+
def _threads_read(tool_use_id: str = "read-1") -> list[str]:
|
|
42
|
+
all_threads = [
|
|
43
|
+
{
|
|
44
|
+
"id": each_thread_id,
|
|
45
|
+
"is_resolved": False,
|
|
46
|
+
"comments": [
|
|
47
|
+
{"path": "a.py", "html_url": f"https://github.com/o/r/pull/7#discussion_r{each_comment_id}"},
|
|
48
|
+
{"path": "a.py", "html_url": f"https://github.com/o/r/pull/7#discussion_r{each_comment_id + 1}"},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
for each_thread_id, each_comment_id in ((FIRST_THREAD_ID, FIRST_COMMENT_ID), (SECOND_THREAD_ID, SECOND_COMMENT_ID))
|
|
52
|
+
]
|
|
53
|
+
return [
|
|
54
|
+
_call(tool_use_id, READ_TOOL_NAME, {"method": "get_review_comments", "pullNumber": 7}),
|
|
55
|
+
_result(tool_use_id, text=json.dumps({"review_threads": all_threads, "totalCount": 2})),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _reply(tool_use_id: str, comment_id: int = FIRST_COMMENT_ID) -> str:
|
|
60
|
+
return _call(tool_use_id, REPLY_TOOL_NAME, {"commentId": comment_id, "body": "Fixed in `abc1234`."})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve(tool_use_id: str, thread_id: str = FIRST_THREAD_ID) -> str:
|
|
64
|
+
return _call(tool_use_id, RESOLVE_TOOL_NAME, {"threadID": thread_id})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _shell_resolve_command(thread_id: str = FIRST_THREAD_ID) -> str:
|
|
68
|
+
return (
|
|
69
|
+
"gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: \""
|
|
70
|
+
+ thread_id
|
|
71
|
+
+ "\"}) { thread { isResolved } } }'"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
36
75
|
def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
37
76
|
transcript_path = tmp_path / "session.jsonl"
|
|
38
77
|
transcript_path.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
|
|
39
78
|
return transcript_path
|
|
40
79
|
|
|
41
80
|
|
|
42
|
-
def _resolve_payload(transcript_path: Path) -> dict[str, object]:
|
|
81
|
+
def _resolve_payload(transcript_path: Path, thread_id: str = FIRST_THREAD_ID) -> dict[str, object]:
|
|
43
82
|
return {
|
|
44
83
|
"hook_event_name": "PreToolUse",
|
|
45
84
|
"tool_name": RESOLVE_TOOL_NAME,
|
|
46
|
-
"tool_input": {"
|
|
85
|
+
"tool_input": {"threadID": thread_id},
|
|
86
|
+
"transcript_path": str(transcript_path),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _shell_resolve_payload(transcript_path: Path, command: str) -> dict[str, object]:
|
|
91
|
+
return {
|
|
92
|
+
"hook_event_name": "PreToolUse",
|
|
93
|
+
"tool_name": "Bash",
|
|
94
|
+
"tool_input": {"command": command},
|
|
47
95
|
"transcript_path": str(transcript_path),
|
|
48
96
|
}
|
|
49
97
|
|
|
@@ -62,9 +110,10 @@ def should_allow_a_resolve_after_a_reply_that_posted(tmp_path: Path) -> None:
|
|
|
62
110
|
tmp_path,
|
|
63
111
|
[
|
|
64
112
|
_prompt("Address the review threads."),
|
|
65
|
-
|
|
113
|
+
*_threads_read(),
|
|
114
|
+
_reply("reply-1"),
|
|
66
115
|
_result("reply-1"),
|
|
67
|
-
|
|
116
|
+
_resolve("resolve-1"),
|
|
68
117
|
],
|
|
69
118
|
)
|
|
70
119
|
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
@@ -75,9 +124,10 @@ def should_deny_a_resolve_after_a_reply_a_hook_refused(tmp_path: Path) -> None:
|
|
|
75
124
|
tmp_path,
|
|
76
125
|
[
|
|
77
126
|
_prompt("Address the review threads."),
|
|
78
|
-
|
|
127
|
+
*_threads_read(),
|
|
128
|
+
_reply("reply-1"),
|
|
79
129
|
_result("reply-1", is_error=True, text=HOOK_DENIAL_TEXT),
|
|
80
|
-
|
|
130
|
+
_resolve("resolve-1"),
|
|
81
131
|
],
|
|
82
132
|
)
|
|
83
133
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
@@ -86,7 +136,7 @@ def should_deny_a_resolve_after_a_reply_a_hook_refused(tmp_path: Path) -> None:
|
|
|
86
136
|
def should_deny_a_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
87
137
|
transcript_path = _write_transcript(
|
|
88
138
|
tmp_path,
|
|
89
|
-
[_prompt("Address the review threads."),
|
|
139
|
+
[_prompt("Address the review threads."), *_threads_read(), _resolve("resolve-1")],
|
|
90
140
|
)
|
|
91
141
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
92
142
|
|
|
@@ -96,42 +146,243 @@ def should_deny_a_second_resolve_after_one_reply(tmp_path: Path) -> None:
|
|
|
96
146
|
tmp_path,
|
|
97
147
|
[
|
|
98
148
|
_prompt("Address the review threads."),
|
|
99
|
-
|
|
149
|
+
*_threads_read(),
|
|
150
|
+
_reply("reply-1"),
|
|
100
151
|
_result("reply-1"),
|
|
101
|
-
|
|
152
|
+
_resolve("resolve-1"),
|
|
102
153
|
_result("resolve-1"),
|
|
103
|
-
|
|
154
|
+
_resolve("resolve-2"),
|
|
104
155
|
],
|
|
105
156
|
)
|
|
106
157
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
107
158
|
|
|
108
159
|
|
|
109
|
-
def
|
|
160
|
+
def should_allow_a_second_resolve_after_a_reply_on_each_thread(tmp_path: Path) -> None:
|
|
110
161
|
transcript_path = _write_transcript(
|
|
111
162
|
tmp_path,
|
|
112
163
|
[
|
|
113
164
|
_prompt("Address the review threads."),
|
|
114
|
-
|
|
165
|
+
*_threads_read(),
|
|
166
|
+
_reply("reply-1"),
|
|
115
167
|
_result("reply-1"),
|
|
116
|
-
|
|
168
|
+
_reply("reply-2", SECOND_COMMENT_ID),
|
|
117
169
|
_result("reply-2"),
|
|
118
|
-
|
|
170
|
+
_resolve("resolve-1"),
|
|
119
171
|
_result("resolve-1"),
|
|
120
|
-
|
|
172
|
+
_resolve("resolve-2", SECOND_THREAD_ID),
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
assert gate.decision_for(_resolve_payload(transcript_path, SECOND_THREAD_ID)) is None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def should_deny_a_resolve_on_a_thread_the_reply_did_not_answer(tmp_path: Path) -> None:
|
|
179
|
+
transcript_path = _write_transcript(
|
|
180
|
+
tmp_path,
|
|
181
|
+
[
|
|
182
|
+
_prompt("Address the review threads."),
|
|
183
|
+
*_threads_read(),
|
|
184
|
+
_reply("reply-1"),
|
|
185
|
+
_result("reply-1"),
|
|
186
|
+
_resolve("resolve-1", SECOND_THREAD_ID),
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
decision = gate.decision_for(_resolve_payload(transcript_path, SECOND_THREAD_ID))
|
|
190
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def should_deny_a_second_resolve_of_one_thread_after_replies_on_two_threads(tmp_path: Path) -> None:
|
|
194
|
+
transcript_path = _write_transcript(
|
|
195
|
+
tmp_path,
|
|
196
|
+
[
|
|
197
|
+
_prompt("Address the review threads."),
|
|
198
|
+
*_threads_read(),
|
|
199
|
+
_reply("reply-1"),
|
|
200
|
+
_result("reply-1"),
|
|
201
|
+
_reply("reply-2", SECOND_COMMENT_ID),
|
|
202
|
+
_result("reply-2"),
|
|
203
|
+
_resolve("resolve-1"),
|
|
204
|
+
_result("resolve-1"),
|
|
205
|
+
_resolve("resolve-2"),
|
|
206
|
+
],
|
|
207
|
+
)
|
|
208
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def should_match_a_reply_on_a_later_comment_in_the_thread(tmp_path: Path) -> None:
|
|
212
|
+
transcript_path = _write_transcript(
|
|
213
|
+
tmp_path,
|
|
214
|
+
[
|
|
215
|
+
_prompt("Address the review threads."),
|
|
216
|
+
*_threads_read(),
|
|
217
|
+
_reply("reply-1", SECOND_COMMENT_ID + 1),
|
|
218
|
+
_result("reply-1"),
|
|
219
|
+
_resolve("resolve-1", SECOND_THREAD_ID),
|
|
220
|
+
],
|
|
221
|
+
)
|
|
222
|
+
assert gate.decision_for(_resolve_payload(transcript_path, SECOND_THREAD_ID)) is None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def should_read_the_thread_map_from_before_the_last_prompt(tmp_path: Path) -> None:
|
|
226
|
+
transcript_path = _write_transcript(
|
|
227
|
+
tmp_path,
|
|
228
|
+
[
|
|
229
|
+
_prompt("List the review threads."),
|
|
230
|
+
*_threads_read(),
|
|
231
|
+
_prompt("Reply and resolve."),
|
|
232
|
+
_reply("reply-1"),
|
|
233
|
+
_result("reply-1"),
|
|
234
|
+
_resolve("resolve-1"),
|
|
121
235
|
],
|
|
122
236
|
)
|
|
123
237
|
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
124
238
|
|
|
125
239
|
|
|
240
|
+
def should_deny_a_resolve_when_no_read_ties_the_reply_to_the_thread(tmp_path: Path) -> None:
|
|
241
|
+
transcript_path = _write_transcript(
|
|
242
|
+
tmp_path,
|
|
243
|
+
[
|
|
244
|
+
_prompt("Address the review threads."),
|
|
245
|
+
_reply("reply-1"),
|
|
246
|
+
_result("reply-1"),
|
|
247
|
+
_resolve("resolve-1"),
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def should_allow_a_resolve_after_a_graphql_reply_on_the_thread(tmp_path: Path) -> None:
|
|
254
|
+
reply_command = (
|
|
255
|
+
"gh api graphql -f query='mutation { addPullRequestReviewThreadReply(input: "
|
|
256
|
+
f'{{pullRequestReviewThreadId: "{FIRST_THREAD_ID}", body: "Fixed."}}) {{ comment {{ id }} }} }}\''
|
|
257
|
+
)
|
|
258
|
+
transcript_path = _write_transcript(
|
|
259
|
+
tmp_path,
|
|
260
|
+
[
|
|
261
|
+
_prompt("Address the review threads."),
|
|
262
|
+
_call("reply-1", "Bash", {"command": reply_command}),
|
|
263
|
+
_result("reply-1"),
|
|
264
|
+
_resolve("resolve-1"),
|
|
265
|
+
],
|
|
266
|
+
)
|
|
267
|
+
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def should_deny_a_shell_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
271
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads."), *_threads_read()])
|
|
272
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _shell_resolve_command()))
|
|
273
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def should_allow_a_shell_resolve_after_a_rest_reply_on_the_thread(tmp_path: Path) -> None:
|
|
277
|
+
reply_command = f"gh api repos/o/r/pulls/7/comments/{FIRST_COMMENT_ID}/replies -f body=Fixed."
|
|
278
|
+
transcript_path = _write_transcript(
|
|
279
|
+
tmp_path,
|
|
280
|
+
[
|
|
281
|
+
_prompt("Address the review threads."),
|
|
282
|
+
*_threads_read(),
|
|
283
|
+
_call("reply-1", "Bash", {"command": reply_command}),
|
|
284
|
+
_result("reply-1"),
|
|
285
|
+
],
|
|
286
|
+
)
|
|
287
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, _shell_resolve_command())) is None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def should_deny_a_shell_resolve_on_a_thread_the_reply_did_not_answer(tmp_path: Path) -> None:
|
|
291
|
+
transcript_path = _write_transcript(
|
|
292
|
+
tmp_path,
|
|
293
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
294
|
+
)
|
|
295
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _shell_resolve_command(SECOND_THREAD_ID)))
|
|
296
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def should_deny_a_shell_resolve_that_names_no_thread_id(tmp_path: Path) -> None:
|
|
300
|
+
transcript_path = _write_transcript(
|
|
301
|
+
tmp_path,
|
|
302
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
303
|
+
)
|
|
304
|
+
command = "gh api graphql -f threadId=\"$thread\" -f query='mutation($threadId: ID!) { resolveReviewThread(input: {threadId: $threadId}) { thread { isResolved } } }'"
|
|
305
|
+
assert _deny_reason(gate.decision_for(_shell_resolve_payload(transcript_path, command))) == MISSING_REPLY_REASON
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def should_spend_the_reply_on_a_shell_resolve(tmp_path: Path) -> None:
|
|
309
|
+
transcript_path = _write_transcript(
|
|
310
|
+
tmp_path,
|
|
311
|
+
[
|
|
312
|
+
_prompt("Address the review threads."),
|
|
313
|
+
*_threads_read(),
|
|
314
|
+
_reply("reply-1"),
|
|
315
|
+
_result("reply-1"),
|
|
316
|
+
_call("resolve-1", "Bash", {"command": _shell_resolve_command()}),
|
|
317
|
+
_result("resolve-1"),
|
|
318
|
+
_resolve("resolve-2"),
|
|
319
|
+
],
|
|
320
|
+
)
|
|
321
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _ccr_resolve_command(comment_id: int = FIRST_COMMENT_ID) -> str:
|
|
325
|
+
return f"gh api -X POST repos/o/r/pulls/7/ccr/comments/{comment_id}/resolve"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def should_deny_a_ccr_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
329
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads.")])
|
|
330
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _ccr_resolve_command()))
|
|
331
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def should_allow_a_ccr_resolve_of_the_comment_the_reply_answered(tmp_path: Path) -> None:
|
|
335
|
+
transcript_path = _write_transcript(
|
|
336
|
+
tmp_path,
|
|
337
|
+
[_prompt("Address the review threads."), _reply("reply-1"), _result("reply-1")],
|
|
338
|
+
)
|
|
339
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, _ccr_resolve_command())) is None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def should_deny_a_ccr_resolve_of_a_comment_in_another_thread(tmp_path: Path) -> None:
|
|
343
|
+
transcript_path = _write_transcript(
|
|
344
|
+
tmp_path,
|
|
345
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
346
|
+
)
|
|
347
|
+
decision = gate.decision_for(_shell_resolve_payload(transcript_path, _ccr_resolve_command(SECOND_COMMENT_ID)))
|
|
348
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def should_spend_the_reply_on_a_ccr_resolve_of_the_same_thread(tmp_path: Path) -> None:
|
|
352
|
+
transcript_path = _write_transcript(
|
|
353
|
+
tmp_path,
|
|
354
|
+
[
|
|
355
|
+
_prompt("Address the review threads."),
|
|
356
|
+
*_threads_read(),
|
|
357
|
+
_reply("reply-1"),
|
|
358
|
+
_result("reply-1"),
|
|
359
|
+
_call("resolve-1", "Bash", {"command": _ccr_resolve_command(FIRST_COMMENT_ID + 1)}),
|
|
360
|
+
_result("resolve-1"),
|
|
361
|
+
_resolve("resolve-2"),
|
|
362
|
+
],
|
|
363
|
+
)
|
|
364
|
+
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def should_ignore_shell_commands_other_than_a_resolve(tmp_path: Path) -> None:
|
|
368
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads.")])
|
|
369
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, "gh pr view 7")) is None
|
|
370
|
+
unresolve_command = _shell_resolve_command().replace("resolveReviewThread", "unresolveReviewThread")
|
|
371
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, unresolve_command)) is None
|
|
372
|
+
ccr_unresolve_command = _ccr_resolve_command().replace("/resolve", "/unresolve")
|
|
373
|
+
assert gate.decision_for(_shell_resolve_payload(transcript_path, ccr_unresolve_command)) is None
|
|
374
|
+
|
|
375
|
+
|
|
126
376
|
def should_deny_a_resolve_whose_reply_came_before_the_last_prompt(tmp_path: Path) -> None:
|
|
127
377
|
transcript_path = _write_transcript(
|
|
128
378
|
tmp_path,
|
|
129
379
|
[
|
|
130
380
|
_prompt("Reply on the thread."),
|
|
131
|
-
|
|
381
|
+
*_threads_read(),
|
|
382
|
+
_reply("reply-1"),
|
|
132
383
|
_result("reply-1"),
|
|
133
|
-
_prompt("Now resolve the
|
|
134
|
-
|
|
384
|
+
_prompt("Now resolve the thread."),
|
|
385
|
+
_resolve("resolve-1"),
|
|
135
386
|
],
|
|
136
387
|
)
|
|
137
388
|
assert _deny_reason(gate.decision_for(_resolve_payload(transcript_path))) == MISSING_REPLY_REASON
|
|
@@ -142,11 +393,12 @@ def should_allow_a_retry_after_a_resolve_error(tmp_path: Path) -> None:
|
|
|
142
393
|
tmp_path,
|
|
143
394
|
[
|
|
144
395
|
_prompt("Address the review threads."),
|
|
145
|
-
|
|
396
|
+
*_threads_read(),
|
|
397
|
+
_reply("reply-1"),
|
|
146
398
|
_result("reply-1"),
|
|
147
|
-
|
|
399
|
+
_resolve("resolve-1"),
|
|
148
400
|
_result("resolve-1", is_error=True, text="Bad gateway"),
|
|
149
|
-
|
|
401
|
+
_resolve("resolve-2"),
|
|
150
402
|
],
|
|
151
403
|
)
|
|
152
404
|
assert gate.decision_for(_resolve_payload(transcript_path)) is None
|
|
@@ -177,16 +429,52 @@ def should_count_a_gh_api_reply_command_as_a_reply() -> None:
|
|
|
177
429
|
assert not gate.is_reply_call(TranscriptToolCall("mcp__github__add_issue_comment", {}, True))
|
|
178
430
|
|
|
179
431
|
|
|
432
|
+
def should_name_the_threads_a_call_resolves() -> None:
|
|
433
|
+
assert gate.resolved_thread_ids(RESOLVE_TOOL_NAME, {"threadID": FIRST_THREAD_ID}) == {FIRST_THREAD_ID}
|
|
434
|
+
assert gate.resolved_thread_ids(RESOLVE_TOOL_NAME, {"threadId": FIRST_THREAD_ID}) == {FIRST_THREAD_ID}
|
|
435
|
+
assert gate.resolved_thread_ids("Bash", {"command": _shell_resolve_command()}) == {FIRST_THREAD_ID}
|
|
436
|
+
assert gate.resolved_thread_ids("PowerShell", {"command": _shell_resolve_command()}) == {FIRST_THREAD_ID}
|
|
437
|
+
assert gate.resolved_thread_ids("Bash", {"command": _ccr_resolve_command()}) == {str(FIRST_COMMENT_ID)}
|
|
438
|
+
assert gate.resolved_thread_ids("Bash", {"command": "gh pr view 7"}) is None
|
|
439
|
+
assert gate.resolved_thread_ids(REPLY_TOOL_NAME, {"commentId": FIRST_COMMENT_ID}) is None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def should_map_comment_ids_to_threads_from_tool_results() -> None:
|
|
443
|
+
graphql_text = json.dumps(
|
|
444
|
+
{"nodes": [{"id": SECOND_THREAD_ID, "comments": {"nodes": [{"databaseId": SECOND_COMMENT_ID}]}}]}
|
|
445
|
+
)
|
|
446
|
+
graphql_result = json.dumps(
|
|
447
|
+
{
|
|
448
|
+
"type": "user",
|
|
449
|
+
"message": {
|
|
450
|
+
"content": [
|
|
451
|
+
{"type": "tool_result", "tool_use_id": "read-2", "content": [{"type": "text", "text": graphql_text}]}
|
|
452
|
+
]
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
refused_text = json.dumps({"id": SECOND_THREAD_ID, "html_url": "x#discussion_r1"})
|
|
457
|
+
thread_id_by_comment_id = gate.thread_ids_by_comment_id(
|
|
458
|
+
[*_threads_read(), graphql_result, _result("read-3", is_error=True, text=refused_text), "not json"]
|
|
459
|
+
)
|
|
460
|
+
assert thread_id_by_comment_id[str(FIRST_COMMENT_ID)] == FIRST_THREAD_ID
|
|
461
|
+
assert thread_id_by_comment_id[str(FIRST_COMMENT_ID + 1)] == FIRST_THREAD_ID
|
|
462
|
+
assert thread_id_by_comment_id[str(SECOND_COMMENT_ID)] == SECOND_THREAD_ID
|
|
463
|
+
assert "1" not in thread_id_by_comment_id
|
|
464
|
+
|
|
465
|
+
|
|
180
466
|
def should_report_a_posted_reply_from_transcript_lines() -> None:
|
|
181
|
-
all_lines = [_prompt("Go."),
|
|
182
|
-
assert gate.
|
|
183
|
-
assert not gate.
|
|
467
|
+
all_lines = [_prompt("Go."), *_threads_read(), _reply("reply-1"), _result("reply-1")]
|
|
468
|
+
assert gate.has_posted_reply_on_threads(all_lines, frozenset({FIRST_THREAD_ID}))
|
|
469
|
+
assert not gate.has_posted_reply_on_threads(all_lines, frozenset({SECOND_THREAD_ID}))
|
|
470
|
+
assert not gate.has_posted_reply_on_threads(all_lines[:-1], frozenset({FIRST_THREAD_ID}))
|
|
471
|
+
assert not gate.has_posted_reply_on_threads(all_lines, frozenset())
|
|
184
472
|
|
|
185
473
|
|
|
186
474
|
def should_print_the_deny_when_run_as_a_hook(tmp_path: Path) -> None:
|
|
187
475
|
transcript_path = _write_transcript(
|
|
188
476
|
tmp_path,
|
|
189
|
-
[_prompt("Address the review threads."),
|
|
477
|
+
[_prompt("Address the review threads."), _resolve("resolve-1")],
|
|
190
478
|
)
|
|
191
479
|
completed_process = subprocess.run(
|
|
192
480
|
[sys.executable, str(GATE_PATH)],
|
|
@@ -197,3 +485,31 @@ def should_print_the_deny_when_run_as_a_hook(tmp_path: Path) -> None:
|
|
|
197
485
|
)
|
|
198
486
|
assert completed_process.returncode == 0
|
|
199
487
|
assert _deny_reason(json.loads(completed_process.stdout)) == MISSING_REPLY_REASON
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _review_write_payload(transcript_path: Path, method: str, thread_id: str = FIRST_THREAD_ID) -> dict[str, object]:
|
|
491
|
+
return {
|
|
492
|
+
"hook_event_name": "PreToolUse",
|
|
493
|
+
"tool_name": "mcp__github__pull_request_review_write",
|
|
494
|
+
"tool_input": {"method": method, "threadId": thread_id, "owner": "o", "repo": "r", "pullNumber": 7},
|
|
495
|
+
"transcript_path": str(transcript_path),
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def should_deny_a_review_write_resolve_with_no_reply(tmp_path: Path) -> None:
|
|
500
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Address the review threads.")])
|
|
501
|
+
decision = gate.decision_for(_review_write_payload(transcript_path, "resolve_thread"))
|
|
502
|
+
assert _deny_reason(decision) == MISSING_REPLY_REASON
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def should_allow_a_review_write_resolve_after_a_reply_on_that_thread(tmp_path: Path) -> None:
|
|
506
|
+
transcript_path = _write_transcript(
|
|
507
|
+
tmp_path,
|
|
508
|
+
[_prompt("Address the review threads."), *_threads_read(), _reply("reply-1"), _result("reply-1")],
|
|
509
|
+
)
|
|
510
|
+
assert gate.decision_for(_review_write_payload(transcript_path, "resolve_thread")) is None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def should_leave_a_review_write_that_submits_a_review_alone(tmp_path: Path) -> None:
|
|
514
|
+
transcript_path = _write_transcript(tmp_path, [_prompt("Review the pull request.")])
|
|
515
|
+
assert gate.decision_for(_review_write_payload(transcript_path, "create")) is None
|
|
@@ -17,7 +17,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
17
17
|
- `blocking/step_note_gate.py` requires a status line before a tool call while its opt-in flag is on.
|
|
18
18
|
- `blocking/verify_before_acting.py` blocks a completed mutating call when the agent's reasoning contains an unchecked claim.
|
|
19
19
|
- `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.
|
|
20
|
-
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on
|
|
20
|
+
- `blocking/resolve_thread_reply_gate.py` denies a review thread resolve until the agent's own transcript shows a reply on that same thread that posted after the last user prompt and that no successful resolve of the thread has spent. A resolve is a `resolve_review_thread` call, which names the thread by `threadID`; a `pull_request_review_write` call with method `resolve_thread`, which names it by `threadId`; a shell command with a `gh api graphql` `resolveReviewThread` mutation, which names it by a literal `PRRT_` id; or a shell call to `pulls/<n>/ccr/comments/<id>/resolve`, which names it by a comment id. A graphql resolve that names no `PRRT_` id is denied. A reply is an `add_reply_to_pull_request_comment` call, or a `gh api` call to a comment's `replies` endpoint or to `addPullRequestReviewThreadReply`, which names its thread by `PRRT_` id. A reply and a resolve by comment id match when they name the same comment, or when a successful tool result in the transcript, such as `pull_request_read` `get_review_comments`, lists the comment under the thread by its `discussion_r` anchor or `databaseId`. A reply whose result is an error, such as a refusal from `blocking/reply_length_gate.py`, does not count. With no readable transcript, the resolve passes.
|
|
21
21
|
- `blocking/verify_before_commit_gate.py` denies a `git commit` in a repository with a `.claude/skills/verify` or `.agents/skills/verify` directory until the `verify` skill loads after the last user prompt, through a Skill call or the `/verify` command in that prompt. It reads the committed paths from the staged diff, the tracked changes for `-a`, and the paths of each `git add` earlier in the same command, and passes a commit whose paths are all docs, images, or test files.
|
|
22
22
|
- `blocking/build_eval_before_eval_write_gate.py` denies a write to an eval file until a Skill call named exactly `claude-api` with args `build-eval`, or the `/claude-api build-eval` command, appears in the transcript.
|
|
23
23
|
- `blocking/merge_review_gate.py` denies an agent's merge until a review pass on the merged head reported zero findings, or ruled out every finding and linked the posted rulings. It reads the receipt `scripts/merge_review_receipt.py` writes, requires the merge, auto-merge or merge-queue entry to be pinned to that head, and skips repositories listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`. Its deny reason names the review command `CLAUDE_MERGE_REVIEW_COMMAND` sets. The reason tells the agent to run that command for the first three passes and `/code-review low --fix <number>` for each later pass; the gate itself counts no passes.
|
|
@@ -40,7 +40,7 @@ This family stops a chat reply, edited message, or tool call when its payload br
|
|
|
40
40
|
- `blocking/step_note_gate.py` runs on `PreToolUse`, matcher `*`, timeout `15` seconds in `hooks.json`.
|
|
41
41
|
- `blocking/verify_before_acting.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|Agent|Task|apply_patch|Bash|PowerShell|mcp__.*`, timeout `10` seconds in `hooks.json`.
|
|
42
42
|
- `blocking/pr_lifecycle_skill_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(create_pull_request|merge_pull_request|enable_pr_auto_merge|update_pull_request|actions_run_trigger)`, timeout `10` seconds in `hooks.json`.
|
|
43
|
-
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `mcp__.*__resolve_review_thread`, timeout `10` seconds in `hooks.json`.
|
|
43
|
+
- `blocking/resolve_thread_reply_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__resolve_review_thread|mcp__.*__pull_request_review_write`, timeout `10` seconds in `hooks.json`.
|
|
44
44
|
- `blocking/verify_before_commit_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell`, timeout `30` seconds in `hooks.json`.
|
|
45
45
|
- `blocking/build_eval_before_eval_write_gate.py` runs on `PreToolUse`, matcher `Write|Edit|MultiEdit|NotebookEdit|apply_patch`, timeout `10` seconds in `hooks.json`.
|
|
46
46
|
- `blocking/merge_review_gate.py` runs on `PreToolUse`, matcher `Bash|PowerShell|mcp__.*__(merge_pull_request|enable_pr_auto_merge)`, timeout `10` seconds in `hooks.json`.
|
|
@@ -55,7 +55,7 @@ Preconditions:
|
|
|
55
55
|
- Run each command from the repository root with Python and pytest available. The tests create transcript and payload fixtures.
|
|
56
56
|
|
|
57
57
|
- **Reply length.** Input is a reply with four sentences, a plain pull request number, an abbreviation, two sentences with no widget this turn, or widget code with an anchor link. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_reply_length_gate.py -q`. The adjacent test observes a denial and its count, link, or mode reason. Widget code with no flowchart passes with a flowchart reminder in `additionalContext`.
|
|
58
|
-
- **Resolve reply.** Input is a transcript where a thread reply came back refused, or with no reply, or with one reply already spent on a resolve, then a resolve call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_resolve_thread_reply_gate.py -q`. The adjacent test observes a deny whose reason says to post the reply, confirm it posted, then resolve. A reply that posted before the resolve passes, and an unreadable transcript passes.
|
|
58
|
+
- **Resolve reply.** Input is a transcript where a thread reply came back refused, or with no reply, or with one reply already spent on a resolve, or with a reply on another thread, then a resolve call, a `pull_request_review_write` `resolve_thread` call, a graphql `resolveReviewThread` command, or a `ccr/comments/<id>/resolve` call. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_resolve_thread_reply_gate.py -q`. The adjacent test observes a deny whose reason says to post the reply, confirm it posted, then resolve. A reply that posted before the resolve passes, and an unreadable transcript passes.
|
|
59
59
|
- **Final reply.** Input is a closing reply that says "My guess is one to two hours." or cites a run no tool result shows. Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_final_reply_gate.py -q`. The adjacent test observes a `block` decision that quotes the sentence or the citation; a reply that cites a run a tool result shows passes. A reply with a plain-text `D:\Reports\.claude\report.html` is blocked; the same path in backticks or a fenced block passes.
|
|
60
60
|
- **Open tasks.** Input is a session whose task list still has "rotate the deploy key" pending and a closing reply of "Done.". Run `python -m pytest packages/claude-dev-env/hooks/blocking/test_open_tasks_stop_gate.py -q`. The adjacent test observes a `block` decision that names the item; a reply line `Blocked: rotate the deploy key - <what the user must give>` passes. The same file covers an interrupted ask, a continuation plan list that leaves items out, `Done:` and `Dropped:` lines, and a carried item that a later list marks completed.
|
|
61
61
|
- **Ask recorder.** Input is a `UserPromptSubmit` payload with a work prompt, a question, a `<task-notification>` envelope, and a polite request. Run `python -m pytest packages/claude-dev-env/hooks/session/test_open_task_ask_recorder.py -q`. The adjacent test observes `ask-1` and `ask-2` for the work prompt and the request, no ask for the other two, and empty stdout.
|
package/hooks/hooks.json
CHANGED
|
@@ -8,15 +8,30 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
8
8
|
)
|
|
9
9
|
|
|
10
10
|
RESOLVE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__resolve_review_thread$")
|
|
11
|
+
REVIEW_WRITE_TOOL_NAME_PATTERN = re.compile(r"^mcp__.+__pull_request_review_write$")
|
|
12
|
+
REVIEW_WRITE_METHOD_KEY = "method"
|
|
13
|
+
RESOLVE_THREAD_METHOD = "resolve_thread"
|
|
11
14
|
ALL_REPLY_TOOL_NAME_SUFFIXES = ("__add_reply_to_pull_request_comment",)
|
|
12
15
|
SHELL_COMMAND_KEY = "command"
|
|
13
16
|
SHELL_REPLY_COMMAND_PATTERN = re.compile(
|
|
14
17
|
r"\bgh\s+api\b.*(?:/comments/\d+/replies\b|addPullRequestReviewThreadReply)", re.DOTALL
|
|
15
18
|
)
|
|
19
|
+
SHELL_RESOLVE_COMMAND_PATTERN = re.compile(r"\bgh\s+api\s+graphql\b.*\bresolveReviewThread\b", re.DOTALL)
|
|
20
|
+
SHELL_RESOLVE_COMMENT_ID_PATTERN = re.compile(r"/pulls/\d+/ccr/comments/(\d+)/resolve\b")
|
|
21
|
+
ALL_THREAD_ID_INPUT_KEYS = ("threadID", "threadId")
|
|
22
|
+
COMMENT_ID_INPUT_KEY = "commentId"
|
|
23
|
+
THREAD_ID_PATTERN = re.compile(r"\bPRRT_[A-Za-z0-9_-]+")
|
|
24
|
+
COMMENT_ANCHOR_PATTERN = re.compile(r"(?:discussion_r|\"databaseId\"\s*:\s*)(\d+)")
|
|
25
|
+
SHELL_REPLY_COMMENT_ID_PATTERN = re.compile(r"/comments/(\d+)/replies\b")
|
|
26
|
+
TEXT_BLOCK_TYPE = "text"
|
|
27
|
+
TEXT_FIELD = "text"
|
|
16
28
|
MISSING_REPLY_REASON = (
|
|
17
|
-
"This review thread has no reply that posted since the last user prompt and the last
|
|
18
|
-
"
|
|
19
|
-
"
|
|
29
|
+
"This review thread has no reply on it that posted since the last user prompt and the last"
|
|
30
|
+
" resolve of the thread. Post the reply on this thread with add_reply_to_pull_request_comment,"
|
|
31
|
+
" or with gh api graphql addPullRequestReviewThreadReply and the thread's PRRT_ id, confirm its"
|
|
32
|
+
" result came back without an error, then resolve the thread by its PRRT_ id or by the comment id the reply answered. A comment id"
|
|
33
|
+
" ties to a thread only through a pull_request_read get_review_comments result in this session."
|
|
34
|
+
" A reply that a hook refused did not post; shorten it to"
|
|
20
35
|
f" at most {MAXIMUM_SENTENCE_COUNT} sentences of at most {MAXIMUM_WORDS_PER_SENTENCE} words each,"
|
|
21
36
|
" footer included, and send it again."
|
|
22
37
|
)
|