claude-dev-env 8.23.2 → 8.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/CODE_RULES.md +2 -0
- package/hooks/blocking/code_rules_comments.py +70 -17
- package/hooks/blocking/reply_length_gate.py +20 -1
- package/hooks/blocking/test_code_rules_enforcer_comment_string_awareness.py +44 -0
- package/hooks/blocking/test_reply_length_gate.py +44 -0
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
- package/hooks/hooks_constants/reply_length_gate_constants.py +5 -0
- package/package.json +1 -1
- package/rules/review-closure-is-a-check.md +2 -0
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +6 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +6 -0
- package/scripts/grok_worker_preflight.py +16 -4
- package/scripts/resolve_worker_spawn.py +30 -16
- package/scripts/review_closure.py +21 -4
- package/scripts/review_closure_github.py +10 -4
- package/scripts/review_closure_model.py +7 -1
- package/scripts/test_grok_worker_preflight.py +16 -0
- package/scripts/test_resolve_worker_spawn.py +47 -0
- package/scripts/tests/test_review_closure.py +22 -0
- package/scripts/tests/test_review_closure_github.py +20 -0
- package/scripts/tests/test_review_closure_model.py +18 -0
package/docs/CODE_RULES.md
CHANGED
|
@@ -13,6 +13,8 @@ Do not add code comments. Preserve existing comments. Docstrings remain allowed.
|
|
|
13
13
|
When a change touches code that an existing comment describes or is attached to, remove that comment in the same change and carry its meaning through clear names and structure. Leave comments tied to untouched code unchanged. Keep comment cleanup inside the requested task.
|
|
14
14
|
Production and tests follow one rule. Changed directive, TODO, FIXME, HACK, XXX, and type-ignore comments are removed rather than added or justified.
|
|
15
15
|
|
|
16
|
+
A keep marker is the one comment that may be added and kept: a comment that opens with `STEALTH: Keep`, or with a prefix the repository lists under `comment_keep_markers` in `.claude/policy-lint.json`.
|
|
17
|
+
|
|
16
18
|
---
|
|
17
19
|
|
|
18
20
|
## CORE PRINCIPLES
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Comment-presence and comment-change checks for Python and JavaScript sources."""
|
|
2
2
|
|
|
3
3
|
import difflib
|
|
4
|
+
import functools
|
|
4
5
|
import importlib
|
|
5
6
|
import io
|
|
7
|
+
import json
|
|
6
8
|
import sys
|
|
7
9
|
import tokenize
|
|
8
10
|
from collections import Counter
|
|
@@ -29,7 +31,11 @@ from hooks_constants.code_rules_enforcer_constants import ( # noqa: E402
|
|
|
29
31
|
ALL_TOKEN_ANCHORED_DIRECTIVE_BOUNDARY_CHARACTERS,
|
|
30
32
|
ALL_TOKEN_ANCHORED_EXEMPT_COMMENT_BODIES,
|
|
31
33
|
CHAINED_INLINE_COMMENT_PATTERN,
|
|
34
|
+
COMMENT_KEEP_MARKERS_KEY,
|
|
32
35
|
MAX_COMMENT_ISSUES,
|
|
36
|
+
REPOSITORY_LINT_SETTINGS_RELATIVE_PATH,
|
|
37
|
+
REPOSITORY_ROOT_MARKER_NAME,
|
|
38
|
+
SETTINGS_TEXT_ENCODING,
|
|
33
39
|
STEALTH_KEEP_COMMENT_MARKER,
|
|
34
40
|
)
|
|
35
41
|
_javascript_comment_scanner = importlib.import_module("javascript_comment_scanner")
|
|
@@ -153,27 +159,70 @@ def _python_comment_occurrences(
|
|
|
153
159
|
return all_occurrences, True
|
|
154
160
|
|
|
155
161
|
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
@functools.cache
|
|
163
|
+
def _repository_keep_markers(repository_root: Path) -> tuple[str, ...]:
|
|
164
|
+
settings_path = repository_root / REPOSITORY_LINT_SETTINGS_RELATIVE_PATH
|
|
165
|
+
try:
|
|
166
|
+
all_settings = json.loads(settings_path.read_text(encoding=SETTINGS_TEXT_ENCODING))
|
|
167
|
+
except (OSError, ValueError):
|
|
168
|
+
return ()
|
|
169
|
+
if not isinstance(all_settings, dict):
|
|
170
|
+
return ()
|
|
171
|
+
all_markers = all_settings.get(COMMENT_KEEP_MARKERS_KEY)
|
|
172
|
+
if not isinstance(all_markers, list):
|
|
173
|
+
return ()
|
|
174
|
+
return tuple(each for each in all_markers if isinstance(each, str) and each)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def comment_keep_markers(file_path: str) -> tuple[str, ...]:
|
|
178
|
+
"""Return the comment prefixes the comment-change check accepts for a file.
|
|
179
|
+
|
|
180
|
+
::
|
|
181
|
+
|
|
182
|
+
repo/.claude/policy-lint.json {"comment_keep_markers": ["VENDOR: Keep"]}
|
|
183
|
+
repo/app/main.py ("STEALTH: Keep", "VENDOR: Keep")
|
|
184
|
+
no settings file ("STEALTH: Keep",)
|
|
185
|
+
|
|
186
|
+
The repository that holds the file names its own markers. A missing or
|
|
187
|
+
unreadable settings file adds none.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
file_path: The path of the file under check.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The package's built-in marker, then the markers the file's repository
|
|
194
|
+
adds.
|
|
195
|
+
"""
|
|
196
|
+
for each_directory in Path(file_path).resolve().parents:
|
|
197
|
+
if (each_directory / REPOSITORY_ROOT_MARKER_NAME).exists():
|
|
198
|
+
return (
|
|
199
|
+
STEALTH_KEEP_COMMENT_MARKER,
|
|
200
|
+
*_repository_keep_markers(each_directory),
|
|
201
|
+
)
|
|
202
|
+
return (STEALTH_KEEP_COMMENT_MARKER,)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_keep_occurrence(comment_text: str, all_keep_markers: tuple[str, ...]) -> bool:
|
|
206
|
+
"""Return True for a comment that opens with an accepted keep marker.
|
|
158
207
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
208
|
+
A keep marker is meant to be freely added and to survive edits to the
|
|
209
|
+
line it sits on, unlike ``noqa`` or ``TODO``, which AGENTS.md requires to
|
|
210
|
+
be removed rather than added or justified. It is excluded here rather
|
|
211
|
+
than through the shared exempt-marker set that ``check_comment_changes``
|
|
212
|
+
deliberately still blocks on add.
|
|
164
213
|
"""
|
|
165
214
|
return comment_text.startswith("#") and comment_text[1:].lstrip().startswith(
|
|
166
|
-
|
|
215
|
+
all_keep_markers
|
|
167
216
|
)
|
|
168
217
|
|
|
169
218
|
|
|
170
|
-
def
|
|
171
|
-
all_occurrences: list[tuple[str, int, bool]]
|
|
219
|
+
def _without_keep_occurrences(
|
|
220
|
+
all_occurrences: list[tuple[str, int, bool]], all_keep_markers: tuple[str, ...]
|
|
172
221
|
) -> list[tuple[str, int, bool]]:
|
|
173
222
|
return [
|
|
174
223
|
each_occurrence
|
|
175
224
|
for each_occurrence in all_occurrences
|
|
176
|
-
if not
|
|
225
|
+
if not _is_keep_occurrence(each_occurrence[0], all_keep_markers)
|
|
177
226
|
]
|
|
178
227
|
|
|
179
228
|
|
|
@@ -181,8 +230,10 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
|
|
|
181
230
|
"""Check for comment additions or removals between old and new content.
|
|
182
231
|
|
|
183
232
|
Inline and standalone comments are blocking findings when added, except
|
|
184
|
-
``STEALTH: Keep``
|
|
185
|
-
|
|
233
|
+
keep-marker occurrences (``STEALTH: Keep`` and any marker the file's
|
|
234
|
+
repository names in ``.claude/policy-lint.json``), which are excluded
|
|
235
|
+
entirely so the marker can be added and can persist across edits to its
|
|
236
|
+
line.
|
|
186
237
|
Existing comments can be removed when the touched code no longer needs them.
|
|
187
238
|
|
|
188
239
|
When the file is Python and either *old_content* or *new_content* cannot
|
|
@@ -205,8 +256,9 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
|
|
|
205
256
|
old_occurrences = extract_javascript_comment_occurrences(old_content, True)
|
|
206
257
|
new_occurrences = extract_javascript_comment_occurrences(new_content, True)
|
|
207
258
|
|
|
208
|
-
|
|
209
|
-
|
|
259
|
+
all_keep_markers = comment_keep_markers(file_path)
|
|
260
|
+
old_occurrences = _without_keep_occurrences(old_occurrences, all_keep_markers)
|
|
261
|
+
new_occurrences = _without_keep_occurrences(new_occurrences, all_keep_markers)
|
|
210
262
|
|
|
211
263
|
old_occurrence_counts = Counter(
|
|
212
264
|
(each_text, is_inline)
|
|
@@ -294,8 +346,9 @@ def _retained_comment_issues(
|
|
|
294
346
|
(old_occurrences, old_tokenize_ok), (new_occurrences, new_tokenize_ok) = (_comment_occurrences(old_content, file_path, True), _comment_occurrences(new_content, file_path, True))
|
|
295
347
|
if not (old_tokenize_ok and new_tokenize_ok):
|
|
296
348
|
return []
|
|
297
|
-
|
|
298
|
-
|
|
349
|
+
all_keep_markers = comment_keep_markers(file_path)
|
|
350
|
+
old_occurrences = _without_keep_occurrences(old_occurrences, all_keep_markers)
|
|
351
|
+
new_occurrences = _without_keep_occurrences(new_occurrences, all_keep_markers)
|
|
299
352
|
all_changed_lines, all_deleted_lines, old_line_by_new_line = _line_diff_data(old_content, new_content)
|
|
300
353
|
old_line_by_key = {
|
|
301
354
|
each_key: [each_line for each_text, each_line, each_is_inline in old_occurrences if (each_text, each_is_inline) == each_key]
|
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
The gate reads the ``text`` field of the chat tools that post to the user.
|
|
5
5
|
It denies the call when the text holds more than MAXIMUM_SENTENCE_COUNT
|
|
6
6
|
sentences, or a sentence longer than MAXIMUM_WORDS_PER_SENTENCE words.
|
|
7
|
+
It also denies a pull request number the reader cannot open::
|
|
8
|
+
|
|
9
|
+
flag: Both land in PR 5256.
|
|
10
|
+
flag: It merged in #4347.
|
|
11
|
+
ok: Both land in [PR 5256](https://github.com/owner/repo/pull/5256).
|
|
7
12
|
Each non-empty line counts as its own sentence, so a list counts one
|
|
8
13
|
sentence per item. URLs, markdown link targets, inline code spans, and
|
|
9
14
|
fenced blocks carry no words.
|
|
@@ -34,6 +39,7 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
34
39
|
LINE_BREAK_PATTERN,
|
|
35
40
|
LINK_TARGET_PATTERN,
|
|
36
41
|
LONG_SENTENCE_MESSAGE,
|
|
42
|
+
MARKDOWN_LINK_PATTERN,
|
|
37
43
|
MAXIMUM_SENTENCE_COUNT,
|
|
38
44
|
MAXIMUM_WORDS_PER_SENTENCE,
|
|
39
45
|
RETRY_INSTRUCTION,
|
|
@@ -44,6 +50,8 @@ from hooks_constants.reply_length_gate_constants import (
|
|
|
44
50
|
TOO_MANY_SENTENCES_MESSAGE,
|
|
45
51
|
TOOL_INPUT_KEY,
|
|
46
52
|
TOOL_NAME_KEY,
|
|
53
|
+
UNLINKED_PULL_REQUEST_MESSAGE,
|
|
54
|
+
UNLINKED_PULL_REQUEST_PATTERN,
|
|
47
55
|
URL_PATTERN,
|
|
48
56
|
WORD_PATTERN,
|
|
49
57
|
WORD_SEPARATOR,
|
|
@@ -87,6 +95,17 @@ def length_violation(reply_text: str) -> str | None:
|
|
|
87
95
|
return None
|
|
88
96
|
|
|
89
97
|
|
|
98
|
+
def unlinked_pull_request_violation(reply_text: str) -> str | None:
|
|
99
|
+
"""Return the deny reason for a pull request number outside a link, or None."""
|
|
100
|
+
without_fences = FENCED_BLOCK_PATTERN.sub(" ", reply_text)
|
|
101
|
+
without_code = INLINE_CODE_PATTERN.sub(" ", without_fences)
|
|
102
|
+
without_links = MARKDOWN_LINK_PATTERN.sub(" ", without_code)
|
|
103
|
+
unlinked_match = UNLINKED_PULL_REQUEST_PATTERN.search(URL_PATTERN.sub(" ", without_links))
|
|
104
|
+
if unlinked_match is None:
|
|
105
|
+
return None
|
|
106
|
+
return UNLINKED_PULL_REQUEST_MESSAGE.format(reference=unlinked_match.group(0))
|
|
107
|
+
|
|
108
|
+
|
|
90
109
|
def main() -> int:
|
|
91
110
|
hook_input = read_hook_input_dictionary_from_stdin()
|
|
92
111
|
if hook_input is None:
|
|
@@ -100,7 +119,7 @@ def main() -> int:
|
|
|
100
119
|
reply_text = tool_input.get(TEXT_KEY)
|
|
101
120
|
if not isinstance(reply_text, str):
|
|
102
121
|
return ALLOW_EXIT_CODE
|
|
103
|
-
violation = length_violation(reply_text)
|
|
122
|
+
violation = length_violation(reply_text) or unlinked_pull_request_violation(reply_text)
|
|
104
123
|
if violation is None:
|
|
105
124
|
return ALLOW_EXIT_CODE
|
|
106
125
|
block_reason = violation + RETRY_INSTRUCTION
|
|
@@ -13,6 +13,7 @@ a string still flag.
|
|
|
13
13
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
|
+
import json
|
|
16
17
|
import sys
|
|
17
18
|
import importlib
|
|
18
19
|
from pathlib import Path
|
|
@@ -413,3 +414,46 @@ def test_check_comment_changes_accepts_distant_untouched_comment() -> None:
|
|
|
413
414
|
new_content = "# TODO #999: distant note\n\ntotal = 2\n"
|
|
414
415
|
issues = code_rules_enforcer.check_comment_changes(old_content, new_content, "totals.py")
|
|
415
416
|
assert not any("still on the changed lines" in each_issue for each_issue in issues)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _repository_file(tmp_path: Path, all_keep_markers: list[str] | None) -> str:
|
|
420
|
+
(tmp_path / ".git").mkdir()
|
|
421
|
+
if all_keep_markers is not None:
|
|
422
|
+
(tmp_path / ".claude").mkdir()
|
|
423
|
+
(tmp_path / ".claude" / "policy-lint.json").write_text(
|
|
424
|
+
json.dumps({"comment_keep_markers": all_keep_markers}), encoding="utf-8"
|
|
425
|
+
)
|
|
426
|
+
return str(tmp_path / "app" / "main.py")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_check_comment_changes_accepts_a_marker_the_repository_names(
|
|
430
|
+
tmp_path: Path,
|
|
431
|
+
) -> None:
|
|
432
|
+
file_path = _repository_file(tmp_path, ["VENDOR: Keep"])
|
|
433
|
+
issues = code_rules_enforcer.check_comment_changes(
|
|
434
|
+
"x = 1\n", "x = 1 # VENDOR: Keep -- reason\n", file_path
|
|
435
|
+
)
|
|
436
|
+
assert issues == []
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def test_check_comment_changes_flags_that_marker_in_a_repository_without_it(
|
|
440
|
+
tmp_path: Path,
|
|
441
|
+
) -> None:
|
|
442
|
+
file_path = _repository_file(tmp_path, None)
|
|
443
|
+
issues = code_rules_enforcer.check_comment_changes(
|
|
444
|
+
"x = 1\n", "x = 1 # VENDOR: Keep -- reason\n", file_path
|
|
445
|
+
)
|
|
446
|
+
assert len(issues) == 1
|
|
447
|
+
assert "comment added" in issues[0]
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def test_check_comment_changes_accepts_a_retained_repository_marker_on_a_touched_line(
|
|
451
|
+
tmp_path: Path,
|
|
452
|
+
) -> None:
|
|
453
|
+
file_path = _repository_file(tmp_path, ["VENDOR: Keep"])
|
|
454
|
+
issues = code_rules_enforcer.check_comment_changes(
|
|
455
|
+
"x = 1 # VENDOR: Keep -- reason\n",
|
|
456
|
+
"x = 2 # VENDOR: Keep -- reason\n",
|
|
457
|
+
file_path,
|
|
458
|
+
)
|
|
459
|
+
assert issues == []
|
|
@@ -191,3 +191,47 @@ def test_should_allow_a_call_without_text(
|
|
|
191
191
|
def test_should_allow_empty_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
192
192
|
monkeypatch.setattr("sys.stdin", io.TextIOWrapper(io.BytesIO(b"")))
|
|
193
193
|
assert reply_length_gate.main() == 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_should_deny_a_pr_number_with_no_link_and_name_it(
|
|
197
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
198
|
+
) -> None:
|
|
199
|
+
exit_code, stderr_text = run_gate(
|
|
200
|
+
monkeypatch, capsys, REPLY_TOOL_NAME, {"text": "Both land in PR 5256."}
|
|
201
|
+
)
|
|
202
|
+
assert exit_code == 2
|
|
203
|
+
assert "PR 5256" in stderr_text
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_should_deny_a_bare_hash_number(
|
|
207
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
208
|
+
) -> None:
|
|
209
|
+
exit_code, stderr_text = run_gate(
|
|
210
|
+
monkeypatch, capsys, POST_TOOL_NAME, {"text": "It merged in #4347."}
|
|
211
|
+
)
|
|
212
|
+
assert exit_code == 2
|
|
213
|
+
assert "#4347" in stderr_text
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_should_allow_a_pr_number_inside_its_link(
|
|
217
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
218
|
+
) -> None:
|
|
219
|
+
exit_code, stderr_text = run_gate(
|
|
220
|
+
monkeypatch,
|
|
221
|
+
capsys,
|
|
222
|
+
REPLY_TOOL_NAME,
|
|
223
|
+
{"text": "Both land in [PR 5256](https://github.com/owner/repo/pull/5256)."},
|
|
224
|
+
)
|
|
225
|
+
assert (exit_code, stderr_text) == (0, "")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_should_allow_a_bare_pull_request_url(
|
|
229
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
230
|
+
) -> None:
|
|
231
|
+
exit_code, _ = run_gate(
|
|
232
|
+
monkeypatch,
|
|
233
|
+
capsys,
|
|
234
|
+
REPLY_TOOL_NAME,
|
|
235
|
+
{"text": "Pull request: https://github.com/owner/repo/pull/5256"},
|
|
236
|
+
)
|
|
237
|
+
assert exit_code == 0
|
|
@@ -136,6 +136,10 @@ ALL_TOKEN_ANCHORED_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
|
|
|
136
136
|
)
|
|
137
137
|
ALL_TOKEN_ANCHORED_DIRECTIVE_BOUNDARY_CHARACTERS: frozenset[str] = frozenset({":"})
|
|
138
138
|
STEALTH_KEEP_COMMENT_MARKER: str = "STEALTH: Keep"
|
|
139
|
+
REPOSITORY_LINT_SETTINGS_RELATIVE_PATH: str = ".claude/policy-lint.json"
|
|
140
|
+
COMMENT_KEEP_MARKERS_KEY: str = "comment_keep_markers"
|
|
141
|
+
REPOSITORY_ROOT_MARKER_NAME: str = ".git"
|
|
142
|
+
SETTINGS_TEXT_ENCODING: str = "utf-8"
|
|
139
143
|
ALL_FREE_FORM_EXEMPT_COMMENT_BODIES: tuple[str, ...] = (
|
|
140
144
|
"type:",
|
|
141
145
|
"TODO",
|
|
@@ -15,6 +15,10 @@ FENCED_BLOCK_PATTERN = re.compile(r"```.*?(?:```|\Z)", re.DOTALL)
|
|
|
15
15
|
INLINE_CODE_PATTERN = re.compile(r"`[^`\n]*`")
|
|
16
16
|
LINK_TARGET_PATTERN = re.compile(r"\]\([^)\s]*\)")
|
|
17
17
|
URL_PATTERN = re.compile(r"(?:https?://|www\.)\S+")
|
|
18
|
+
MARKDOWN_LINK_PATTERN = re.compile(r"\[[^\]\n]*\]\([^)\s]*\)")
|
|
19
|
+
UNLINKED_PULL_REQUEST_PATTERN = re.compile(
|
|
20
|
+
r"\b(?:PRs?|pull requests?)\s*#?\d+|(?<![\w/&])#\d+", re.IGNORECASE
|
|
21
|
+
)
|
|
18
22
|
LINE_BREAK_PATTERN = re.compile(r"\n+")
|
|
19
23
|
SENTENCE_END_PATTERN = re.compile(r"(?<=[.!?])\s+")
|
|
20
24
|
WORD_PATTERN = re.compile(r"[A-Za-z0-9]+(?:['.,-][A-Za-z0-9]+)*")
|
|
@@ -23,6 +27,7 @@ WORD_SEPARATOR = " "
|
|
|
23
27
|
SENTENCE_PREVIEW_SUFFIX = "..."
|
|
24
28
|
RETRY_INSTRUCTION = " Cut the text and resend the call."
|
|
25
29
|
TOO_MANY_SENTENCES_MESSAGE = "Reply too long: {sentence_count} sentences, limit {sentence_limit}."
|
|
30
|
+
UNLINKED_PULL_REQUEST_MESSAGE = 'Pull request "{reference}" has no link. Write it as [PR N](https://github.com/<owner>/<repo>/pull/N).'
|
|
26
31
|
LONG_SENTENCE_MESSAGE = (
|
|
27
32
|
'Sentence too long: {word_count} words, limit {word_limit}: "{sentence_preview}".'
|
|
28
33
|
)
|
package/package.json
CHANGED
|
@@ -33,6 +33,8 @@ A red circle marks a finding a review states as blocking, so resolution in silen
|
|
|
33
33
|
|
|
34
34
|
The driving account is the one that opened the pull request. Where the agent comments under a second login, `--driver-login <login>` names it, repeatably.
|
|
35
35
|
|
|
36
|
+
A repository whose review bots post notices this package does not know passes each one's marker text with `--notice-marker <text>`, repeatably. A bot comment carrying that text closes itself, like the built-in notices above.
|
|
37
|
+
|
|
36
38
|
## Where the check runs
|
|
37
39
|
|
|
38
40
|
`.github/workflows/review-closure.yml` runs it here on a push to a pull request, on a submitted or dismissed review, on a review comment, and on a top-level comment posted or edited on a pull request. Each run reports on the pull request's head commit, so a finding posted after the last push still turns the check red.
|
|
@@ -131,6 +131,12 @@ EXIT_FALLTHROUGH: int = 1
|
|
|
131
131
|
CLI_ROLE_FLAG: str = "--role"
|
|
132
132
|
"""CLI flag naming the role whose agent definition set must be present."""
|
|
133
133
|
|
|
134
|
+
CLI_AGENT_FLAG: str = "--agent"
|
|
135
|
+
"""CLI flag naming the agent definition stem that serves an unregistered role."""
|
|
136
|
+
|
|
137
|
+
AGENT_DEFINITION_SUFFIX: str = ".md"
|
|
138
|
+
"""File suffix of an agent definition under ``agents/``."""
|
|
139
|
+
|
|
134
140
|
CLI_PING_FLAG: str = "--ping"
|
|
135
141
|
"""CLI flag that enables the opt-in cached live single-turn ping."""
|
|
136
142
|
|
|
@@ -203,6 +203,12 @@ DRIVER_LOGIN_ARGUMENT_HELP: str = (
|
|
|
203
203
|
)
|
|
204
204
|
"""Help text for the driver login argument."""
|
|
205
205
|
|
|
206
|
+
NOTICE_MARKER_ARGUMENT_HELP: str = (
|
|
207
|
+
"Text that marks a bot comment as a notice on this repository, beside the "
|
|
208
|
+
"built-in markers. Repeatable."
|
|
209
|
+
)
|
|
210
|
+
"""Help text for the notice marker argument."""
|
|
211
|
+
|
|
206
212
|
CLOSED_VERDICT_LABEL: str = "CLOSED"
|
|
207
213
|
"""Verdict label for a pull request with every finding answered."""
|
|
208
214
|
|
|
@@ -12,7 +12,8 @@ Checks run in order and stop at the first failure:
|
|
|
12
12
|
running workers.
|
|
13
13
|
3. ``claude-dev-env`` is installed for the requested role: the install manifest
|
|
14
14
|
exists in the user Claude config directory, and every agent definition file
|
|
15
|
-
that role needs is present under ``agents/``.
|
|
15
|
+
that role needs is present under ``agents/``. A caller that names its own
|
|
16
|
+
agent needs that one definition file.
|
|
16
17
|
|
|
17
18
|
Opt-in ``--ping`` runs one cached live single-turn call with a run-scoped TTL.
|
|
18
19
|
The cache file lives under the caller-supplied run state directory. Auth failure
|
|
@@ -54,6 +55,7 @@ from dataclasses import dataclass
|
|
|
54
55
|
from pathlib import Path
|
|
55
56
|
|
|
56
57
|
from dev_env_scripts_constants.grok_worker_constants import (
|
|
58
|
+
AGENT_DEFINITION_SUFFIX,
|
|
57
59
|
AGENTS_SUBDIRECTORY,
|
|
58
60
|
ALL_AGENT_FILENAMES_BY_ROLE,
|
|
59
61
|
ALL_AUTH_FAILURE_SIGNATURES,
|
|
@@ -277,14 +279,20 @@ def _is_grok_binary_resolvable() -> bool:
|
|
|
277
279
|
return preflight_which(GROK_BINARY_NAME) is not None
|
|
278
280
|
|
|
279
281
|
|
|
280
|
-
def
|
|
282
|
+
def _required_agent_filenames(role: str, agent: str | None) -> tuple[str, ...] | None:
|
|
283
|
+
if agent is not None:
|
|
284
|
+
return (f"{agent}{AGENT_DEFINITION_SUFFIX}",)
|
|
285
|
+
return ALL_AGENT_FILENAMES_BY_ROLE.get(role)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_claude_dev_env_config_present(role: str, agent: str | None = None) -> bool:
|
|
281
289
|
if not _install_manifest_path().is_file():
|
|
282
290
|
return False
|
|
283
291
|
try:
|
|
284
292
|
poteto_mode_skill_path()
|
|
285
293
|
except OSError:
|
|
286
294
|
return False
|
|
287
|
-
all_agent_filenames =
|
|
295
|
+
all_agent_filenames = _required_agent_filenames(role, agent)
|
|
288
296
|
if all_agent_filenames is None:
|
|
289
297
|
return False
|
|
290
298
|
agents_root = _agents_directory()
|
|
@@ -390,6 +398,7 @@ def run_preflight(
|
|
|
390
398
|
role: str,
|
|
391
399
|
should_ping: bool,
|
|
392
400
|
run_state_directory: Path,
|
|
401
|
+
agent: str | None = None,
|
|
393
402
|
) -> PreflightOutcome:
|
|
394
403
|
"""Evaluate whether tier 1 (grok headless) is usable.
|
|
395
404
|
|
|
@@ -398,6 +407,9 @@ def run_preflight(
|
|
|
398
407
|
should_ping: When True, run the opt-in cached live single-turn ping.
|
|
399
408
|
run_state_directory: Run-scoped directory for leader sockets and cache.
|
|
400
409
|
Best-effort mkdir when missing; callers need not create it first.
|
|
410
|
+
agent: The agent definition stem a caller names for its own role.
|
|
411
|
+
When set, that one definition file must be installed in place of
|
|
412
|
+
the role's registered set.
|
|
401
413
|
|
|
402
414
|
Returns:
|
|
403
415
|
The soft-gate outcome. Callers treat a non-usable outcome as fallthrough,
|
|
@@ -412,7 +424,7 @@ def run_preflight(
|
|
|
412
424
|
auth_outcome = _probe_grok_auth(run_state_directory)
|
|
413
425
|
if not auth_outcome.is_usable:
|
|
414
426
|
return auth_outcome
|
|
415
|
-
if not _is_claude_dev_env_config_present(role):
|
|
427
|
+
if not _is_claude_dev_env_config_present(role, agent):
|
|
416
428
|
return _fallthrough(REASON_CLAUDE_DEV_ENV_CONFIG_MISSING)
|
|
417
429
|
if should_ping:
|
|
418
430
|
return _probe_grok_ping(run_state_directory)
|
|
@@ -15,9 +15,9 @@ Walk order:
|
|
|
15
15
|
|
|
16
16
|
Import ``resolve_worker_spawn`` for the outcome object, or run as a CLI::
|
|
17
17
|
|
|
18
|
-
python resolve_worker_spawn.py --role <role> --
|
|
19
|
-
--cwd <dir> --timeout-seconds N
|
|
20
|
-
[--enable-claude-tier]
|
|
18
|
+
python resolve_worker_spawn.py --role <role> [--agent <stem>]
|
|
19
|
+
--prompt-file <path> --cwd <dir> --timeout-seconds N
|
|
20
|
+
--run-temp-dir <dir> [--enable-claude-tier]
|
|
21
21
|
"""
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
@@ -72,6 +72,7 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
|
72
72
|
ATTEMPT_KEY_OK,
|
|
73
73
|
ATTEMPT_KEY_REASON,
|
|
74
74
|
ATTEMPT_KEY_TIER,
|
|
75
|
+
CLI_AGENT_FLAG,
|
|
75
76
|
CLI_ENABLE_CLAUDE_TIER_FLAG,
|
|
76
77
|
CLI_ROLE_FLAG,
|
|
77
78
|
CLI_RUN_STATE_DIR_FLAG,
|
|
@@ -284,13 +285,12 @@ def _served_claude_outcome(
|
|
|
284
285
|
|
|
285
286
|
def _run_tier_claude_headless(
|
|
286
287
|
*,
|
|
287
|
-
|
|
288
|
+
agent_name: str,
|
|
288
289
|
prompt_file: Path,
|
|
289
290
|
working_directory: Path,
|
|
290
291
|
timeout_seconds: int,
|
|
291
292
|
all_attempts: list[SpawnAttempt],
|
|
292
293
|
) -> SpawnOutcome:
|
|
293
|
-
agent_name = _primary_agent_name_for_role(role)
|
|
294
294
|
all_claude_arguments = _build_claude_arguments(agent_name=agent_name)
|
|
295
295
|
try:
|
|
296
296
|
prompt_stdin = prompt_file.open(encoding=UTF8_ENCODING)
|
|
@@ -310,7 +310,7 @@ def _run_tier_claude_headless(
|
|
|
310
310
|
|
|
311
311
|
def _after_grok_fallthrough(
|
|
312
312
|
*,
|
|
313
|
-
|
|
313
|
+
agent_name: str,
|
|
314
314
|
prompt_file: Path,
|
|
315
315
|
working_directory: Path,
|
|
316
316
|
timeout_seconds: int,
|
|
@@ -325,7 +325,7 @@ def _after_grok_fallthrough(
|
|
|
325
325
|
all_attempts, returncode=fallthrough_returncode
|
|
326
326
|
)
|
|
327
327
|
return _run_tier_claude_headless(
|
|
328
|
-
|
|
328
|
+
agent_name=agent_name,
|
|
329
329
|
prompt_file=prompt_file,
|
|
330
330
|
working_directory=working_directory,
|
|
331
331
|
timeout_seconds=timeout_seconds,
|
|
@@ -335,13 +335,12 @@ def _after_grok_fallthrough(
|
|
|
335
335
|
|
|
336
336
|
def _run_tier_grok(
|
|
337
337
|
*,
|
|
338
|
-
|
|
338
|
+
agent_name: str,
|
|
339
339
|
prompt_file: Path,
|
|
340
340
|
working_directory: Path,
|
|
341
341
|
run_state_directory: Path,
|
|
342
342
|
timeout_seconds: int,
|
|
343
343
|
) -> GrokRunnerOutcome:
|
|
344
|
-
agent_name = _primary_agent_name_for_role(role)
|
|
345
344
|
return spawn_grok_runner(
|
|
346
345
|
prompt_file=prompt_file,
|
|
347
346
|
working_directory=working_directory,
|
|
@@ -354,7 +353,7 @@ def _run_tier_grok(
|
|
|
354
353
|
def _record_preflight_fallthrough(
|
|
355
354
|
preflight_outcome: PreflightOutcome,
|
|
356
355
|
*,
|
|
357
|
-
|
|
356
|
+
agent_name: str,
|
|
358
357
|
prompt_file: Path,
|
|
359
358
|
working_directory: Path,
|
|
360
359
|
timeout_seconds: int,
|
|
@@ -362,7 +361,7 @@ def _record_preflight_fallthrough(
|
|
|
362
361
|
) -> SpawnOutcome:
|
|
363
362
|
all_attempts = [_attempt(TIER_GROK, is_ok=False, reason=preflight_outcome.reason)]
|
|
364
363
|
return _after_grok_fallthrough(
|
|
365
|
-
|
|
364
|
+
agent_name=agent_name,
|
|
366
365
|
prompt_file=prompt_file,
|
|
367
366
|
working_directory=working_directory,
|
|
368
367
|
timeout_seconds=timeout_seconds,
|
|
@@ -387,7 +386,7 @@ def _record_grok_success(
|
|
|
387
386
|
def _record_grok_fallthrough(
|
|
388
387
|
grok_outcome: GrokRunnerOutcome,
|
|
389
388
|
*,
|
|
390
|
-
|
|
389
|
+
agent_name: str,
|
|
391
390
|
prompt_file: Path,
|
|
392
391
|
working_directory: Path,
|
|
393
392
|
timeout_seconds: int,
|
|
@@ -401,7 +400,7 @@ def _record_grok_fallthrough(
|
|
|
401
400
|
)
|
|
402
401
|
]
|
|
403
402
|
return _after_grok_fallthrough(
|
|
404
|
-
|
|
403
|
+
agent_name=agent_name,
|
|
405
404
|
prompt_file=prompt_file,
|
|
406
405
|
working_directory=working_directory,
|
|
407
406
|
timeout_seconds=timeout_seconds,
|
|
@@ -419,6 +418,7 @@ def resolve_worker_spawn(
|
|
|
419
418
|
timeout_seconds: int,
|
|
420
419
|
is_claude_tier_enabled: bool,
|
|
421
420
|
run_state_directory: Path,
|
|
421
|
+
agent: str | None = None,
|
|
422
422
|
) -> SpawnOutcome:
|
|
423
423
|
"""Walk the worker-spawn tiers and return the structured outcome.
|
|
424
424
|
|
|
@@ -433,6 +433,9 @@ def resolve_worker_spawn(
|
|
|
433
433
|
timeout_seconds: Timeout applied to each tier invocation.
|
|
434
434
|
is_claude_tier_enabled: When True, allow tier 3 on a Claude host.
|
|
435
435
|
run_state_directory: Run-scoped directory for leader sockets and cache.
|
|
436
|
+
agent: The agent definition stem that serves ``role``, named by a
|
|
437
|
+
caller whose role this package does not register. None maps
|
|
438
|
+
``role`` through the package's own role table.
|
|
436
439
|
|
|
437
440
|
Returns:
|
|
438
441
|
The dispatcher outcome including the ordered attempts trail.
|
|
@@ -443,15 +446,17 @@ def resolve_worker_spawn(
|
|
|
443
446
|
config-error exit code.
|
|
444
447
|
"""
|
|
445
448
|
require_timeout_within_bounds(timeout_seconds)
|
|
449
|
+
agent_name = agent or _primary_agent_name_for_role(role)
|
|
446
450
|
preflight_outcome: PreflightOutcome = spawn_preflight_runner(
|
|
447
451
|
role=role,
|
|
448
452
|
should_ping=False,
|
|
449
453
|
run_state_directory=run_state_directory,
|
|
454
|
+
agent=agent,
|
|
450
455
|
)
|
|
451
456
|
if not preflight_outcome.is_usable:
|
|
452
457
|
return _record_preflight_fallthrough(
|
|
453
458
|
preflight_outcome,
|
|
454
|
-
|
|
459
|
+
agent_name=agent_name,
|
|
455
460
|
prompt_file=prompt_file,
|
|
456
461
|
working_directory=working_directory,
|
|
457
462
|
timeout_seconds=timeout_seconds,
|
|
@@ -459,7 +464,7 @@ def resolve_worker_spawn(
|
|
|
459
464
|
)
|
|
460
465
|
|
|
461
466
|
grok_outcome = _run_tier_grok(
|
|
462
|
-
|
|
467
|
+
agent_name=agent_name,
|
|
463
468
|
prompt_file=prompt_file,
|
|
464
469
|
working_directory=working_directory,
|
|
465
470
|
run_state_directory=run_state_directory,
|
|
@@ -469,7 +474,7 @@ def resolve_worker_spawn(
|
|
|
469
474
|
return _record_grok_success(grok_outcome)
|
|
470
475
|
return _record_grok_fallthrough(
|
|
471
476
|
grok_outcome,
|
|
472
|
-
|
|
477
|
+
agent_name=agent_name,
|
|
473
478
|
prompt_file=prompt_file,
|
|
474
479
|
working_directory=working_directory,
|
|
475
480
|
timeout_seconds=timeout_seconds,
|
|
@@ -512,6 +517,14 @@ def _build_argument_parser() -> argparse.ArgumentParser:
|
|
|
512
517
|
default=DEFAULT_ROLE,
|
|
513
518
|
help="Worker role name for preflight; mapped to a primary agent for grok.",
|
|
514
519
|
)
|
|
520
|
+
parser.add_argument(
|
|
521
|
+
CLI_AGENT_FLAG,
|
|
522
|
+
default=None,
|
|
523
|
+
help=(
|
|
524
|
+
"Agent definition stem that serves the role, for a role this "
|
|
525
|
+
"package does not register."
|
|
526
|
+
),
|
|
527
|
+
)
|
|
515
528
|
parser.add_argument(
|
|
516
529
|
PROMPT_FILE_FLAG,
|
|
517
530
|
dest="prompt_file",
|
|
@@ -654,6 +667,7 @@ def main(all_command_arguments: list[str]) -> int:
|
|
|
654
667
|
timeout_seconds=parsed_arguments.timeout_seconds,
|
|
655
668
|
is_claude_tier_enabled=parsed_arguments.is_claude_tier_enabled,
|
|
656
669
|
run_state_directory=run_state_directory,
|
|
670
|
+
agent=parsed_arguments.agent,
|
|
657
671
|
)
|
|
658
672
|
except WorkerTimeoutOutOfBoundsError as bounds_error:
|
|
659
673
|
is_config_error = True
|
|
@@ -26,6 +26,7 @@ from dev_env_scripts_constants.review_closure_constants import (
|
|
|
26
26
|
DRIVER_LOGIN_ARGUMENT_HELP,
|
|
27
27
|
ERROR_EXIT_CODE,
|
|
28
28
|
FINDING_LINE_TEMPLATE,
|
|
29
|
+
NOTICE_MARKER_ARGUMENT_HELP,
|
|
29
30
|
NUMBER_ARGUMENT_HELP,
|
|
30
31
|
OPEN_EXIT_CODE,
|
|
31
32
|
SLUG_ARGUMENT_HELP,
|
|
@@ -60,7 +61,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
60
61
|
|
|
61
62
|
Returns:
|
|
62
63
|
A parser taking a repository slug, a pull request number, and any
|
|
63
|
-
number of extra driver logins.
|
|
64
|
+
number of extra driver logins and notice markers.
|
|
64
65
|
"""
|
|
65
66
|
parser = argparse.ArgumentParser(description=COMMAND_DESCRIPTION)
|
|
66
67
|
parser.add_argument("slug", help=SLUG_ARGUMENT_HELP)
|
|
@@ -71,11 +72,21 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
71
72
|
default=[],
|
|
72
73
|
help=DRIVER_LOGIN_ARGUMENT_HELP,
|
|
73
74
|
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--notice-marker",
|
|
77
|
+
action="append",
|
|
78
|
+
default=[],
|
|
79
|
+
help=NOTICE_MARKER_ARGUMENT_HELP,
|
|
80
|
+
)
|
|
74
81
|
return parser
|
|
75
82
|
|
|
76
83
|
|
|
77
84
|
def closure_report(
|
|
78
|
-
slug: str,
|
|
85
|
+
slug: str,
|
|
86
|
+
number: int,
|
|
87
|
+
all_extra_logins: Sequence[str],
|
|
88
|
+
token: str,
|
|
89
|
+
all_extra_notice_markers: Sequence[str] = (),
|
|
79
90
|
) -> ClosureReport:
|
|
80
91
|
"""Read one pull request and judge the findings on its head.
|
|
81
92
|
|
|
@@ -85,6 +96,8 @@ def closure_report(
|
|
|
85
96
|
all_extra_logins: Logins that count as the driving agent beside the
|
|
86
97
|
account that opened the pull request.
|
|
87
98
|
token: The GitHub token the reads authenticate with.
|
|
99
|
+
all_extra_notice_markers: Text that marks a bot comment as a notice on
|
|
100
|
+
this repository, beside the built-in markers.
|
|
88
101
|
|
|
89
102
|
Returns:
|
|
90
103
|
The verdict line and each finding that waits.
|
|
@@ -95,7 +108,7 @@ def closure_report(
|
|
|
95
108
|
all_pull_request_fields = read_pull_request(slug, number, token)
|
|
96
109
|
all_findings = all_open_findings(
|
|
97
110
|
read_review_threads(slug, number, token),
|
|
98
|
-
read_top_level_comments(slug, number, token),
|
|
111
|
+
read_top_level_comments(slug, number, token, all_extra_notice_markers),
|
|
99
112
|
driver_logins(all_pull_request_fields, all_extra_logins),
|
|
100
113
|
read_approvals_conclusion(slug, head_sha(all_pull_request_fields), token),
|
|
101
114
|
)
|
|
@@ -118,7 +131,11 @@ def main(all_arguments: Sequence[str]) -> int:
|
|
|
118
131
|
parsed = build_parser().parse_args(all_arguments)
|
|
119
132
|
try:
|
|
120
133
|
report = closure_report(
|
|
121
|
-
parsed.slug,
|
|
134
|
+
parsed.slug,
|
|
135
|
+
parsed.number,
|
|
136
|
+
parsed.driver_login,
|
|
137
|
+
github_token(),
|
|
138
|
+
parsed.notice_marker,
|
|
122
139
|
)
|
|
123
140
|
except GitHubError as failure:
|
|
124
141
|
print(failure, file=sys.stderr)
|
|
@@ -23,7 +23,7 @@ import json
|
|
|
23
23
|
import os
|
|
24
24
|
import urllib.error
|
|
25
25
|
import urllib.request
|
|
26
|
-
from collections.abc import Mapping
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
27
|
|
|
28
28
|
from dev_env_scripts_constants.review_closure_constants import (
|
|
29
29
|
ALL_THREAD_NODE_KEYS,
|
|
@@ -202,7 +202,10 @@ def read_review_comments_by_id(
|
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
def read_top_level_comments(
|
|
205
|
-
slug: str,
|
|
205
|
+
slug: str,
|
|
206
|
+
number: int,
|
|
207
|
+
token: str,
|
|
208
|
+
all_extra_notice_markers: Sequence[str] = (),
|
|
206
209
|
) -> tuple[TopLevelComment, ...]:
|
|
207
210
|
"""Read every top-level comment on a pull request.
|
|
208
211
|
|
|
@@ -210,6 +213,8 @@ def read_top_level_comments(
|
|
|
210
213
|
slug: The repository as ``owner/name``.
|
|
211
214
|
number: The pull request number.
|
|
212
215
|
token: The GitHub token the request authenticates with.
|
|
216
|
+
all_extra_notice_markers: Markers the calling repository adds to the
|
|
217
|
+
built-in notice markers.
|
|
213
218
|
|
|
214
219
|
Returns:
|
|
215
220
|
Each comment posted on the pull request itself, outside any review
|
|
@@ -223,7 +228,7 @@ def read_top_level_comments(
|
|
|
223
228
|
TOP_LEVEL_COMMENTS_ENDPOINT_TEMPLATE, slug, number, token
|
|
224
229
|
)
|
|
225
230
|
return tuple(
|
|
226
|
-
_parsed_top_level_comment(each_record)
|
|
231
|
+
_parsed_top_level_comment(each_record, all_extra_notice_markers)
|
|
227
232
|
for each_record in all_records
|
|
228
233
|
if isinstance(each_record, Mapping)
|
|
229
234
|
)
|
|
@@ -231,9 +236,10 @@ def read_top_level_comments(
|
|
|
231
236
|
|
|
232
237
|
def _parsed_top_level_comment(
|
|
233
238
|
all_comment_fields: Mapping[str, object],
|
|
239
|
+
all_extra_notice_markers: Sequence[str],
|
|
234
240
|
) -> TopLevelComment:
|
|
235
241
|
try:
|
|
236
|
-
return parse_top_level_comment(all_comment_fields)
|
|
242
|
+
return parse_top_level_comment(all_comment_fields, all_extra_notice_markers)
|
|
237
243
|
except ValueError as failure:
|
|
238
244
|
raise GitHubError(
|
|
239
245
|
UNREADABLE_TOP_LEVEL_COMMENT_TEMPLATE.format(record=all_comment_fields)
|
|
@@ -426,6 +426,7 @@ def comment_records_by_id(
|
|
|
426
426
|
|
|
427
427
|
def parse_top_level_comment(
|
|
428
428
|
all_comment_fields: Mapping[str, object],
|
|
429
|
+
all_extra_notice_markers: Sequence[str] = (),
|
|
429
430
|
) -> TopLevelComment:
|
|
430
431
|
"""Read one top-level comment from the REST answer.
|
|
431
432
|
|
|
@@ -434,6 +435,8 @@ def parse_top_level_comment(
|
|
|
434
435
|
|
|
435
436
|
Args:
|
|
436
437
|
all_comment_fields: The comment as the issue comments route reports it.
|
|
438
|
+
all_extra_notice_markers: Markers the calling repository adds to the
|
|
439
|
+
built-in ones.
|
|
437
440
|
|
|
438
441
|
Returns:
|
|
439
442
|
The comment in the shape the closure decision reads.
|
|
@@ -448,7 +451,10 @@ def parse_top_level_comment(
|
|
|
448
451
|
author_login=_comment_author_login(all_comment_fields),
|
|
449
452
|
is_bot=is_bot,
|
|
450
453
|
is_notice=is_bot
|
|
451
|
-
and any(
|
|
454
|
+
and any(
|
|
455
|
+
each_marker in body
|
|
456
|
+
for each_marker in (*ALL_NOTICE_COMMENT_MARKERS, *all_extra_notice_markers)
|
|
457
|
+
),
|
|
452
458
|
created_at=datetime.fromisoformat(str(all_comment_fields.get(CREATED_AT_KEY))),
|
|
453
459
|
updated_at=datetime.fromisoformat(str(all_comment_fields.get(UPDATED_AT_KEY))),
|
|
454
460
|
url=str(all_comment_fields.get(HTML_URL_KEY) or ""),
|
|
@@ -1125,3 +1125,19 @@ def test_ping_incidental_credit_and_port_429_are_not_usage_exhaustion(
|
|
|
1125
1125
|
assert outcome.is_usable is False
|
|
1126
1126
|
assert outcome.reason == REASON_GROK_AUTH_FAILED
|
|
1127
1127
|
assert outcome.reason != REASON_GROK_USAGE_EXHAUSTED
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def test_caller_named_agent_needs_its_own_definition_file(
|
|
1131
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
1132
|
+
) -> None:
|
|
1133
|
+
_write_install_layout(tmp_path)
|
|
1134
|
+
monkeypatch.setattr(preflight, "claude_config_home", lambda: tmp_path)
|
|
1135
|
+
|
|
1136
|
+
assert not preflight._is_claude_dev_env_config_present("vendor-role")
|
|
1137
|
+
assert not preflight._is_claude_dev_env_config_present(
|
|
1138
|
+
"vendor-role", "vendor-agent"
|
|
1139
|
+
)
|
|
1140
|
+
(tmp_path / AGENTS_SUBDIRECTORY / "vendor-agent.md").write_text(
|
|
1141
|
+
"# vendor-agent\n", encoding=UTF8_ENCODING
|
|
1142
|
+
)
|
|
1143
|
+
assert preflight._is_claude_dev_env_config_present("vendor-role", "vendor-agent")
|
|
@@ -32,6 +32,7 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
|
|
|
32
32
|
)
|
|
33
33
|
from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
|
|
34
34
|
AGENT_FLAG,
|
|
35
|
+
CLI_AGENT_FLAG,
|
|
35
36
|
ALL_AGENT_FILENAMES_BY_ROLE,
|
|
36
37
|
ATTEMPT_KEY_OK,
|
|
37
38
|
ATTEMPT_KEY_REASON,
|
|
@@ -1328,3 +1329,49 @@ def test_usage_limit_fallover_delivers_full_prompt_to_each_binary(
|
|
|
1328
1329
|
assert prompt_text_by_command["claude-profile-c"] == FIXTURE_PROMPT_TEXT
|
|
1329
1330
|
assert outcome.tier_used == TIER_CLAUDE_HEADLESS
|
|
1330
1331
|
assert outcome.is_ok is True
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def test_caller_named_agent_serves_an_unregistered_role(
|
|
1335
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1336
|
+
tmp_path: Path,
|
|
1337
|
+
capsys: pytest.CaptureFixture[str],
|
|
1338
|
+
) -> None:
|
|
1339
|
+
prompt_file, working_directory, run_state_directory = _paths(tmp_path)
|
|
1340
|
+
call_log = _install_seams(
|
|
1341
|
+
monkeypatch,
|
|
1342
|
+
grok_outcome=_grok_failure(CLASSIFICATION_AUTH_FAILURE),
|
|
1343
|
+
claude_outcome=_claude_served(),
|
|
1344
|
+
host_profile=HOST_PROFILE_CLAUDE,
|
|
1345
|
+
)
|
|
1346
|
+
all_preflight_keyword_arguments: list[dict[str, object]] = []
|
|
1347
|
+
|
|
1348
|
+
def recording_preflight(**keyword_arguments: object) -> PreflightOutcome:
|
|
1349
|
+
all_preflight_keyword_arguments.append(dict(keyword_arguments))
|
|
1350
|
+
return _usable_preflight()
|
|
1351
|
+
|
|
1352
|
+
monkeypatch.setattr(dispatcher, "spawn_preflight_runner", recording_preflight)
|
|
1353
|
+
|
|
1354
|
+
exit_code = dispatcher.main(
|
|
1355
|
+
[
|
|
1356
|
+
CLI_ROLE_FLAG,
|
|
1357
|
+
"vendor-role",
|
|
1358
|
+
CLI_AGENT_FLAG,
|
|
1359
|
+
"vendor-agent",
|
|
1360
|
+
PROMPT_FILE_FLAG,
|
|
1361
|
+
str(prompt_file),
|
|
1362
|
+
CWD_FLAG,
|
|
1363
|
+
str(working_directory),
|
|
1364
|
+
CLI_RUN_STATE_DIR_FLAG,
|
|
1365
|
+
str(run_state_directory),
|
|
1366
|
+
CLI_ENABLE_CLAUDE_TIER_FLAG,
|
|
1367
|
+
]
|
|
1368
|
+
)
|
|
1369
|
+
capsys.readouterr()
|
|
1370
|
+
|
|
1371
|
+
assert exit_code == 0
|
|
1372
|
+
assert all_preflight_keyword_arguments[0]["agent"] == "vendor-agent"
|
|
1373
|
+
assert call_log.grok_keyword_arguments is not None
|
|
1374
|
+
assert call_log.grok_keyword_arguments["agent_name"] == "vendor-agent"
|
|
1375
|
+
assert call_log.claude_arguments is not None
|
|
1376
|
+
agent_flag_index = call_log.claude_arguments.index(AGENT_FLAG)
|
|
1377
|
+
assert call_log.claude_arguments[agent_flag_index + 1] == "vendor-agent"
|
|
@@ -151,3 +151,25 @@ def should_print_the_url_of_a_waiting_top_level_comment(
|
|
|
151
151
|
|
|
152
152
|
assert status == 1
|
|
153
153
|
assert summary_url in capsys.readouterr().out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def should_pass_each_notice_marker_to_the_top_level_comment_read(
|
|
157
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
158
|
+
) -> None:
|
|
159
|
+
stub_reads(monkeypatch, (answered_thread(),))
|
|
160
|
+
all_received_markers: list[list[str]] = []
|
|
161
|
+
|
|
162
|
+
def read_comments(
|
|
163
|
+
slug: str, number: int, token: str, all_extra_notice_markers: list[str]
|
|
164
|
+
) -> tuple[model.TopLevelComment, ...]:
|
|
165
|
+
all_received_markers.append(all_extra_notice_markers)
|
|
166
|
+
return ()
|
|
167
|
+
|
|
168
|
+
monkeypatch.setattr(command, "read_top_level_comments", read_comments)
|
|
169
|
+
|
|
170
|
+
status = command.main(
|
|
171
|
+
[SLUG, "7", "--notice-marker", "<!-- a -->", "--notice-marker", "<!-- b -->"]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
assert status == 0
|
|
175
|
+
assert all_received_markers == [["<!-- a -->", "<!-- b -->"]]
|
|
@@ -289,3 +289,23 @@ def should_report_a_top_level_comment_without_timestamps(
|
|
|
289
289
|
|
|
290
290
|
with pytest.raises(GitHubError):
|
|
291
291
|
reader.read_top_level_comments("jl-cmd/claude-dev-env", 7, TOKEN)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def should_read_a_bot_comment_carrying_a_passed_marker_as_a_notice(
|
|
295
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
296
|
+
) -> None:
|
|
297
|
+
notice_record = {
|
|
298
|
+
**top_level_record(1),
|
|
299
|
+
"user": {"login": "qodo-code-review[bot]", "type": "Bot"},
|
|
300
|
+
"body": "<!-- vendor:trial-expiring -->\n\nYour trial ends soon.",
|
|
301
|
+
}
|
|
302
|
+
answer_with(
|
|
303
|
+
monkeypatch,
|
|
304
|
+
{"issues/7/comments?per_page=100&page=1": (200, [notice_record])},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
all_comments = reader.read_top_level_comments(
|
|
308
|
+
"jl-cmd/claude-dev-env", 7, TOKEN, ("<!-- vendor:trial-expiring -->",)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
assert [each.is_notice for each in all_comments] == [True]
|
|
@@ -549,3 +549,21 @@ def should_read_a_person_top_level_comment_record() -> None:
|
|
|
549
549
|
)
|
|
550
550
|
|
|
551
551
|
assert comment == top_level_comment(REVIEWER, 1, edited_minute=5, is_bot=False)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def should_read_a_bot_comment_carrying_a_repository_marker_as_a_notice() -> None:
|
|
555
|
+
body = "<!-- vendor:trial-expiring -->\n\nYour trial ends soon."
|
|
556
|
+
|
|
557
|
+
assert not model.parse_top_level_comment(notice_record(body, "Bot")).is_notice
|
|
558
|
+
assert model.parse_top_level_comment(
|
|
559
|
+
notice_record(body, "Bot"), ("<!-- vendor:trial-expiring -->",)
|
|
560
|
+
).is_notice
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def should_read_a_person_carrying_a_repository_marker_as_a_comment() -> None:
|
|
564
|
+
comment = model.parse_top_level_comment(
|
|
565
|
+
notice_record("<!-- vendor:trial-expiring -->", "User"),
|
|
566
|
+
("<!-- vendor:trial-expiring -->",),
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
assert not comment.is_notice
|