claude-dev-env 8.17.1 → 8.19.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/.agents/skills/eli5/SKILL.md +15 -1
- package/.agents/skills/eli5/reference/task-seeds.md +3 -1
- package/.agents/skills/step-notes/SKILL.md +30 -0
- package/.agents/skills/step-notes/scripts/step_notes_constants/__init__.py +0 -0
- package/.agents/skills/step-notes/scripts/step_notes_constants/config/__init__.py +1 -0
- package/.agents/skills/step-notes/scripts/step_notes_constants/config/constants.py +12 -0
- package/.agents/skills/step-notes/scripts/test_toggle_step_notes.py +62 -0
- package/.agents/skills/step-notes/scripts/toggle_step_notes.py +68 -0
- package/bin/ever-shipped-skills.mjs +1 -0
- package/hooks/blocking/step_note_gate.py +160 -0
- package/hooks/blocking/test_step_note_gate.py +245 -0
- package/hooks/hooks.json +5 -0
- package/hooks/hooks_constants/step_note_gate_constants.py +19 -0
- package/package.json +1 -1
- package/rules/review-closure-is-a-check.md +5 -2
- package/scripts/dev_env_scripts_constants/digest_check_constants.py +76 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +29 -4
- package/scripts/digest_check.py +227 -0
- package/scripts/policy_lint/adapter_callers.py +46 -2
- package/scripts/policy_lint/adapter_configuration.py +4 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/review_closure.py +5 -2
- package/scripts/review_closure_github.py +65 -11
- package/scripts/review_closure_model.py +132 -3
- package/scripts/test_digest_check.py +147 -0
- package/scripts/tests/test_adapter_configuration.py +8 -0
- package/scripts/tests/test_policy_lint_rules_callers.py +31 -2
- package/scripts/tests/test_review_closure.py +25 -0
- package/scripts/tests/test_review_closure_github.py +51 -0
- package/scripts/tests/test_review_closure_model.py +141 -4
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
A review bot posts a finding, the agent driving the pull request answers it or
|
|
4
4
|
pushes the fix, and the thread closes. Until then the finding waits. These
|
|
5
|
-
functions read the threads
|
|
5
|
+
functions read the review threads, the top-level comments on the pull request,
|
|
6
|
+
and the approvals conclusion, and name what waits.
|
|
6
7
|
|
|
7
8
|
::
|
|
8
9
|
|
|
@@ -10,6 +11,9 @@ functions read the threads and the approvals conclusion, and name what waits.
|
|
|
10
11
|
thread: a bot comment, then an agent reply -> closed
|
|
11
12
|
thread: a bot comment, code since replaced -> closed
|
|
12
13
|
thread: a red circle, resolved in silence -> open
|
|
14
|
+
top-level: a bot comment, no agent comment -> open
|
|
15
|
+
top-level: a bot comment, then an agent one -> closed
|
|
16
|
+
top-level: the bot edits after that reply -> open
|
|
13
17
|
|
|
14
18
|
A red circle marks a finding a review states as blocking, so resolution alone
|
|
15
19
|
leaves it open. The reply says what changed, or why the finding stands.
|
|
@@ -19,6 +23,7 @@ from __future__ import annotations
|
|
|
19
23
|
|
|
20
24
|
from collections.abc import Iterable, Mapping, Sequence
|
|
21
25
|
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime
|
|
22
27
|
|
|
23
28
|
from dev_env_scripts_constants.review_closure_constants import (
|
|
24
29
|
ALL_BLOCKING_CONCLUSIONS,
|
|
@@ -37,7 +42,9 @@ from dev_env_scripts_constants.review_closure_constants import (
|
|
|
37
42
|
COMMENT_IDENTIFIER_KEY,
|
|
38
43
|
COMMENT_IDS_KEY,
|
|
39
44
|
COMMENT_NODES_KEY,
|
|
45
|
+
CREATED_AT_KEY,
|
|
40
46
|
HEAD_KEY,
|
|
47
|
+
HTML_URL_KEY,
|
|
41
48
|
NUMBER_KEY,
|
|
42
49
|
OPEN_DETAIL_TEMPLATE,
|
|
43
50
|
OPEN_VERDICT_LABEL,
|
|
@@ -46,8 +53,10 @@ from dev_env_scripts_constants.review_closure_constants import (
|
|
|
46
53
|
SHA_KEY,
|
|
47
54
|
SHORT_SHA_LENGTH,
|
|
48
55
|
THREAD_PATH_KEY,
|
|
56
|
+
TOP_LEVEL_OPEN_REASON_TEMPLATE,
|
|
49
57
|
UNANSWERED_OPEN_REASON,
|
|
50
58
|
UNNAMED_THREAD_SUBJECT,
|
|
59
|
+
UPDATED_AT_KEY,
|
|
51
60
|
USER_KEY,
|
|
52
61
|
VERDICT_LINE_TEMPLATE,
|
|
53
62
|
)
|
|
@@ -71,6 +80,17 @@ class ReviewThread:
|
|
|
71
80
|
all_comments: tuple[ReviewComment, ...]
|
|
72
81
|
|
|
73
82
|
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class TopLevelComment:
|
|
85
|
+
"""One comment on the pull request itself, outside any review thread."""
|
|
86
|
+
|
|
87
|
+
identifier: object
|
|
88
|
+
author_login: str
|
|
89
|
+
created_at: datetime
|
|
90
|
+
updated_at: datetime
|
|
91
|
+
url: str
|
|
92
|
+
|
|
93
|
+
|
|
74
94
|
@dataclass(frozen=True)
|
|
75
95
|
class OpenFinding:
|
|
76
96
|
"""One finding that still waits on the agent driving the pull request."""
|
|
@@ -150,8 +170,91 @@ def thread_finding(
|
|
|
150
170
|
return OpenFinding(subject=thread.subject, reason=UNANSWERED_OPEN_REASON)
|
|
151
171
|
|
|
152
172
|
|
|
173
|
+
def latest_driver_comment_time(
|
|
174
|
+
all_comments: Iterable[TopLevelComment],
|
|
175
|
+
all_driver_logins: frozenset[str],
|
|
176
|
+
) -> datetime | None:
|
|
177
|
+
"""Find when the driving agent last posted a top-level comment.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
all_comments: The top-level comments on the pull request.
|
|
181
|
+
all_driver_logins: The logins that count as the driving agent.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The creation time of the driving agent's latest top-level comment, or
|
|
185
|
+
None when it posted none.
|
|
186
|
+
"""
|
|
187
|
+
return max(
|
|
188
|
+
(
|
|
189
|
+
each_comment.created_at
|
|
190
|
+
for each_comment in all_comments
|
|
191
|
+
if each_comment.author_login in all_driver_logins
|
|
192
|
+
),
|
|
193
|
+
default=None,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def top_level_finding(
|
|
198
|
+
comment: TopLevelComment,
|
|
199
|
+
all_driver_logins: frozenset[str],
|
|
200
|
+
latest_driver_time: datetime | None,
|
|
201
|
+
) -> OpenFinding | None:
|
|
202
|
+
"""Decide whether one top-level comment still waits on the driving agent.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
comment: The top-level comment to judge.
|
|
206
|
+
all_driver_logins: The logins whose comments count as the answer.
|
|
207
|
+
latest_driver_time: When the driving agent last posted a top-level
|
|
208
|
+
comment, or None when it posted none.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The finding this comment leaves open, or None when it is answered.
|
|
212
|
+
"""
|
|
213
|
+
if comment.author_login in all_driver_logins:
|
|
214
|
+
return None
|
|
215
|
+
if latest_driver_time is not None and comment.updated_at <= latest_driver_time:
|
|
216
|
+
return None
|
|
217
|
+
return OpenFinding(
|
|
218
|
+
subject=comment.url,
|
|
219
|
+
reason=TOP_LEVEL_OPEN_REASON_TEMPLATE.format(author=comment.author_login),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def top_level_findings(
|
|
224
|
+
all_comments: Iterable[TopLevelComment],
|
|
225
|
+
all_driver_logins: frozenset[str],
|
|
226
|
+
) -> tuple[OpenFinding, ...]:
|
|
227
|
+
"""Decide which top-level comments still wait on the driving agent.
|
|
228
|
+
|
|
229
|
+
The driving agent answers a top-level comment by posting a top-level
|
|
230
|
+
comment of its own. Its latest one answers every comment last touched
|
|
231
|
+
before it, and a comment edited after it waits again.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
all_comments: The top-level comments on the pull request.
|
|
235
|
+
all_driver_logins: The logins whose comments count as the answer.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
One finding per comment from another account that was posted or
|
|
239
|
+
edited after the driving agent's latest top-level comment.
|
|
240
|
+
"""
|
|
241
|
+
all_listed_comments = tuple(all_comments)
|
|
242
|
+
latest_driver_time = latest_driver_comment_time(
|
|
243
|
+
all_listed_comments, all_driver_logins
|
|
244
|
+
)
|
|
245
|
+
return tuple(
|
|
246
|
+
each_finding
|
|
247
|
+
for each_finding in (
|
|
248
|
+
top_level_finding(each_comment, all_driver_logins, latest_driver_time)
|
|
249
|
+
for each_comment in all_listed_comments
|
|
250
|
+
)
|
|
251
|
+
if each_finding is not None
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
153
255
|
def all_open_findings(
|
|
154
256
|
all_threads: Iterable[ReviewThread],
|
|
257
|
+
all_top_level_comments: Iterable[TopLevelComment],
|
|
155
258
|
all_driver_logins: frozenset[str],
|
|
156
259
|
approvals_conclusion_text: str | None,
|
|
157
260
|
) -> tuple[OpenFinding, ...]:
|
|
@@ -159,13 +262,15 @@ def all_open_findings(
|
|
|
159
262
|
|
|
160
263
|
Args:
|
|
161
264
|
all_threads: The review threads on the pull request.
|
|
265
|
+
all_top_level_comments: The comments on the pull request itself.
|
|
162
266
|
all_driver_logins: The logins whose comments count as the answer.
|
|
163
267
|
approvals_conclusion_text: What the Claude Approvals check run on the
|
|
164
268
|
head concluded, or None where that check does not run.
|
|
165
269
|
|
|
166
270
|
Returns:
|
|
167
|
-
One finding per open review thread, then
|
|
168
|
-
|
|
271
|
+
One finding per open review thread, then one per waiting top-level
|
|
272
|
+
comment, then the approvals finding when that check reports a
|
|
273
|
+
blocking row on this commit.
|
|
169
274
|
"""
|
|
170
275
|
all_findings = [
|
|
171
276
|
each_finding
|
|
@@ -175,6 +280,7 @@ def all_open_findings(
|
|
|
175
280
|
)
|
|
176
281
|
if each_finding is not None
|
|
177
282
|
]
|
|
283
|
+
all_findings.extend(top_level_findings(all_top_level_comments, all_driver_logins))
|
|
178
284
|
if approvals_conclusion_text in ALL_BLOCKING_CONCLUSIONS:
|
|
179
285
|
all_findings.append(
|
|
180
286
|
OpenFinding(subject=APPROVALS_CHECK_NAME, reason=APPROVALS_OPEN_REASON)
|
|
@@ -301,6 +407,29 @@ def comment_records_by_id(
|
|
|
301
407
|
}
|
|
302
408
|
|
|
303
409
|
|
|
410
|
+
def parse_top_level_comment(
|
|
411
|
+
all_comment_fields: Mapping[str, object],
|
|
412
|
+
) -> TopLevelComment:
|
|
413
|
+
"""Read one top-level comment from the REST answer.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
all_comment_fields: The comment as the issue comments route reports it.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
The comment in the shape the closure decision reads.
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
ValueError: A timestamp is missing or does not parse.
|
|
423
|
+
"""
|
|
424
|
+
return TopLevelComment(
|
|
425
|
+
identifier=all_comment_fields.get(COMMENT_IDENTIFIER_KEY),
|
|
426
|
+
author_login=_comment_author_login(all_comment_fields),
|
|
427
|
+
created_at=datetime.fromisoformat(str(all_comment_fields.get(CREATED_AT_KEY))),
|
|
428
|
+
updated_at=datetime.fromisoformat(str(all_comment_fields.get(UPDATED_AT_KEY))),
|
|
429
|
+
url=str(all_comment_fields.get(HTML_URL_KEY) or ""),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
304
433
|
def approvals_conclusion(all_check_runs: Iterable[object]) -> str | None:
|
|
305
434
|
"""Find what the Claude Approvals check reports on a commit.
|
|
306
435
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Tests for the digest page check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
11
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
13
|
+
|
|
14
|
+
import digest_check
|
|
15
|
+
from dev_env_scripts_constants.digest_check_constants import (
|
|
16
|
+
CARD_WORD_BUDGET,
|
|
17
|
+
LEAD_WORD_BUDGET,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
PICTURE = '<svg viewBox="0 0 10 10"><rect width="10" height="10"/></svg>'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _card(card_id: str, words: str, picture: str = PICTURE) -> str:
|
|
24
|
+
return f'<section data-digest-card="{card_id}">{picture}<p>{words}</p></section>'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _page(*all_parts: str, lead: str = "Merged today") -> str:
|
|
28
|
+
body = "".join(all_parts)
|
|
29
|
+
return (
|
|
30
|
+
"<!doctype html><html><head><title>Digest</title>"
|
|
31
|
+
"<style>p{margin:0}</style></head>"
|
|
32
|
+
f"<body><h1>{lead}</h1>{body}"
|
|
33
|
+
"<script>const words = 'one two three four five';</script></body></html>"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _findings(page_html: str, all_expected_ids: list[str]) -> list[str]:
|
|
38
|
+
page = digest_check.read_digest_page(page_html)
|
|
39
|
+
return digest_check.digest_findings(page, all_expected_ids)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_page_covering_every_card_briefly_with_pictures_passes() -> None:
|
|
43
|
+
page_html = _page(
|
|
44
|
+
_card("4701", "Search now keeps your filters."),
|
|
45
|
+
_card("4702", "Exports finish twice as fast."),
|
|
46
|
+
)
|
|
47
|
+
assert _findings(page_html, ["4701", "4702"]) == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_page_leaving_out_an_expected_card_reports_it() -> None:
|
|
51
|
+
page_html = _page(_card("4701", "Search now keeps your filters."))
|
|
52
|
+
assert _findings(page_html, ["4701", "4715"]) == [
|
|
53
|
+
"missing card 4715: the page leaves it out"
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_card_over_the_word_budget_reports_its_count() -> None:
|
|
58
|
+
long_words = " ".join(["word"] * (CARD_WORD_BUDGET + 1))
|
|
59
|
+
page_html = _page(_card("4701", long_words))
|
|
60
|
+
assert _findings(page_html, ["4701"]) == [
|
|
61
|
+
f"card 4701 carries {CARD_WORD_BUDGET + 1} words; "
|
|
62
|
+
f"the budget is {CARD_WORD_BUDGET}"
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_card_at_the_word_budget_passes() -> None:
|
|
67
|
+
page_html = _page(_card("4701", " ".join(["word"] * CARD_WORD_BUDGET)))
|
|
68
|
+
assert _findings(page_html, ["4701"]) == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_card_without_a_picture_reports_it() -> None:
|
|
72
|
+
page_html = _page(_card("4701", "Search now keeps your filters.", picture=""))
|
|
73
|
+
assert _findings(page_html, ["4701"]) == ["card 4701 has no picture"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_self_closing_image_counts_as_a_picture() -> None:
|
|
77
|
+
page_html = _page(
|
|
78
|
+
_card("4701", "Search now keeps your filters.", picture='<img src="a.png"/>')
|
|
79
|
+
)
|
|
80
|
+
assert _findings(page_html, ["4701"]) == []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_lead_text_over_its_budget_reports_the_count() -> None:
|
|
84
|
+
long_lead = " ".join(["word"] * (LEAD_WORD_BUDGET + 1))
|
|
85
|
+
page_html = _page(_card("4701", "Search now keeps your filters."), lead=long_lead)
|
|
86
|
+
assert _findings(page_html, ["4701"]) == [
|
|
87
|
+
f"text outside the cards carries {LEAD_WORD_BUDGET + 1} words; "
|
|
88
|
+
f"the budget is {LEAD_WORD_BUDGET}"
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_head_script_and_style_text_stay_out_of_the_count() -> None:
|
|
93
|
+
page = digest_check.read_digest_page(_page(lead="Merged today"))
|
|
94
|
+
assert page.lead_word_count == 2
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_picture_label_text_counts_toward_its_card() -> None:
|
|
98
|
+
labelled_picture = "<svg><text>before after</text></svg>"
|
|
99
|
+
page = digest_check.read_digest_page(
|
|
100
|
+
_page(_card("4701", "one two", picture=labelled_picture))
|
|
101
|
+
)
|
|
102
|
+
assert [each_card.word_count for each_card in page.all_cards] == [4]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_card_marked_twice_reports_the_duplicate() -> None:
|
|
106
|
+
page_html = _page(
|
|
107
|
+
_card("4701", "Search now keeps your filters."),
|
|
108
|
+
_card("4701", "Search now keeps your filters."),
|
|
109
|
+
)
|
|
110
|
+
assert _findings(page_html, ["4701"]) == ["card 4701 appears 2 times"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_main_exits_one_and_prints_findings_for_a_missing_card(
|
|
114
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
115
|
+
) -> None:
|
|
116
|
+
page_path = tmp_path / "digest.html"
|
|
117
|
+
page_path.write_text(_page(_card("4701", "Search keeps filters.")), encoding="utf-8")
|
|
118
|
+
exit_code = digest_check.main([str(page_path), "--expect", "4701", "4702"])
|
|
119
|
+
assert exit_code == 1
|
|
120
|
+
assert capsys.readouterr().out == "missing card 4702: the page leaves it out\n"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_main_exits_zero_and_names_the_count_for_a_clean_page(
|
|
124
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
125
|
+
) -> None:
|
|
126
|
+
page_path = tmp_path / "digest.html"
|
|
127
|
+
page_path.write_text(_page(_card("4701", "Search keeps filters.")), encoding="utf-8")
|
|
128
|
+
exit_code = digest_check.main([str(page_path), "--expect", "4701"])
|
|
129
|
+
assert exit_code == 0
|
|
130
|
+
assert capsys.readouterr().out == "CLEAN: 1 cards, every one present\n"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_markup_inside_a_script_leaves_the_cards_intact() -> None:
|
|
134
|
+
page_html = _page(
|
|
135
|
+
"<script>if (a<b) { x = '<div data-digest-card=\"9\">'; }</script>",
|
|
136
|
+
_card("4701", "Search keeps filters."),
|
|
137
|
+
)
|
|
138
|
+
assert _findings(page_html, ["4701"]) == []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_single_quoted_card_marker_and_entities_parse() -> None:
|
|
142
|
+
page = digest_check.read_digest_page(
|
|
143
|
+
f"<div data-digest-card='4701'>{PICTURE}<p>Fish & chips</p></div>"
|
|
144
|
+
)
|
|
145
|
+
assert [(each_card.card_id, each_card.word_count) for each_card in page.all_cards] == [
|
|
146
|
+
("4701", 2)
|
|
147
|
+
]
|
|
@@ -50,6 +50,14 @@ def test_new_bash_pre_tool_use_dispatcher_registration_is_clean(tmp_path: Path)
|
|
|
50
50
|
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def test_new_step_note_gate_registration_is_clean(tmp_path: Path) -> None:
|
|
54
|
+
current_document = _hook_document(
|
|
55
|
+
["hooks/blocking/step_note_gate.py"],
|
|
56
|
+
'{"hooks": {}}',
|
|
57
|
+
)
|
|
58
|
+
assert adapters.hook_configuration_diagnostics(current_document, tmp_path) == ()
|
|
59
|
+
|
|
60
|
+
|
|
53
61
|
def test_new_blocker_in_staged_change_is_rejected(tmp_path: Path) -> None:
|
|
54
62
|
current_document = _hook_document(
|
|
55
63
|
["hooks/blocking/new.py"],
|
|
@@ -23,10 +23,12 @@ def _write(repository_root: Path, relative_path: str, text: str) -> None:
|
|
|
23
23
|
target.write_text(text, "utf-8")
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def _added(
|
|
26
|
+
def _added(
|
|
27
|
+
relative_path: str, prior_text: str | None = None, text: str = "print('hi')\n"
|
|
28
|
+
) -> Document:
|
|
27
29
|
return Document(
|
|
28
30
|
PurePosixPath(relative_path),
|
|
29
|
-
|
|
31
|
+
text,
|
|
30
32
|
prior_text,
|
|
31
33
|
None,
|
|
32
34
|
ContentOrigin.REVISION_DIFF,
|
|
@@ -138,3 +140,30 @@ def test_should_report_new_ci_and_tools_helpers_only_tests_name(
|
|
|
138
140
|
all_messages = _messages(tmp_path, (_added(ci_helper), _added(tools_helper)))
|
|
139
141
|
|
|
140
142
|
assert len(all_messages) == 2
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_should_pass_a_new_module_that_a_called_new_script_imports(
|
|
146
|
+
tmp_path: Path,
|
|
147
|
+
) -> None:
|
|
148
|
+
constants_path = f"{_PACKAGE}/scripts/constants/lonely_watcher_constants.py"
|
|
149
|
+
script_text = "from constants.lonely_watcher_constants import LIMIT\n"
|
|
150
|
+
_write(tmp_path, _NEW_SCRIPT, script_text)
|
|
151
|
+
_write(tmp_path, constants_path, "LIMIT = 3\n")
|
|
152
|
+
_write(tmp_path, ".github/workflows/ci.yml", f"run: python {_NEW_SCRIPT}\n")
|
|
153
|
+
|
|
154
|
+
all_documents = (_added(_NEW_SCRIPT, text=script_text), _added(constants_path))
|
|
155
|
+
|
|
156
|
+
assert _messages(tmp_path, all_documents) == ()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_should_report_a_new_module_only_an_uncalled_new_script_imports(
|
|
160
|
+
tmp_path: Path,
|
|
161
|
+
) -> None:
|
|
162
|
+
constants_path = f"{_PACKAGE}/scripts/constants/lonely_watcher_constants.py"
|
|
163
|
+
script_text = "from constants.lonely_watcher_constants import LIMIT\n"
|
|
164
|
+
_write(tmp_path, _NEW_SCRIPT, script_text)
|
|
165
|
+
_write(tmp_path, constants_path, "LIMIT = 3\n")
|
|
166
|
+
|
|
167
|
+
all_documents = (_added(_NEW_SCRIPT, text=script_text), _added(constants_path))
|
|
168
|
+
|
|
169
|
+
assert len(_messages(tmp_path, all_documents)) == 2
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
import pytest
|
|
@@ -51,10 +52,14 @@ def stub_reads(
|
|
|
51
52
|
monkeypatch: pytest.MonkeyPatch,
|
|
52
53
|
all_threads: tuple[model.ReviewThread, ...],
|
|
53
54
|
conclusion: str | None = "success",
|
|
55
|
+
all_top_level_comments: tuple[model.TopLevelComment, ...] = (),
|
|
54
56
|
) -> None:
|
|
55
57
|
monkeypatch.setattr(command, "github_token", lambda: "a-token")
|
|
56
58
|
monkeypatch.setattr(command, "read_pull_request", lambda *_: PULL_REQUEST)
|
|
57
59
|
monkeypatch.setattr(command, "read_review_threads", lambda *_: all_threads)
|
|
60
|
+
monkeypatch.setattr(
|
|
61
|
+
command, "read_top_level_comments", lambda *_: all_top_level_comments
|
|
62
|
+
)
|
|
58
63
|
monkeypatch.setattr(command, "read_approvals_conclusion", lambda *_: conclusion)
|
|
59
64
|
|
|
60
65
|
|
|
@@ -124,3 +129,23 @@ def should_exit_two_when_the_state_could_not_be_read(
|
|
|
124
129
|
|
|
125
130
|
assert status == 2
|
|
126
131
|
assert "refused" in capsys.readouterr().err
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def should_print_the_url_of_a_waiting_top_level_comment(
|
|
135
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
136
|
+
) -> None:
|
|
137
|
+
summary_url = "https://github.com/jl-cmd/claude-dev-env/pull/7#issuecomment-1"
|
|
138
|
+
posted = datetime.fromisoformat("2026-09-26T12:00:00+00:00")
|
|
139
|
+
bot_summary = model.TopLevelComment(
|
|
140
|
+
identifier=1,
|
|
141
|
+
author_login="qodo-merge-pro[bot]",
|
|
142
|
+
created_at=posted,
|
|
143
|
+
updated_at=posted,
|
|
144
|
+
url=summary_url,
|
|
145
|
+
)
|
|
146
|
+
stub_reads(monkeypatch, (answered_thread(),), all_top_level_comments=(bot_summary,))
|
|
147
|
+
|
|
148
|
+
status = command.main([SLUG, "7"])
|
|
149
|
+
|
|
150
|
+
assert status == 1
|
|
151
|
+
assert summary_url in capsys.readouterr().out
|
|
@@ -238,3 +238,54 @@ def should_report_a_check_run_answer_of_the_wrong_shape(
|
|
|
238
238
|
|
|
239
239
|
with pytest.raises(GitHubError):
|
|
240
240
|
reader.read_approvals_conclusion("jl-cmd/claude-dev-env", "ab845eb", TOKEN)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def top_level_record(identifier: int) -> dict[str, object]:
|
|
244
|
+
return {
|
|
245
|
+
"id": identifier,
|
|
246
|
+
"user": {"login": "qodo-merge-pro[bot]"},
|
|
247
|
+
"created_at": "2026-09-26T12:00:00Z",
|
|
248
|
+
"updated_at": "2026-09-26T12:00:00Z",
|
|
249
|
+
"html_url": (
|
|
250
|
+
f"https://github.com/jl-cmd/claude-dev-env/pull/7#issuecomment-{identifier}"
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def should_read_the_second_page_of_top_level_comments(
|
|
256
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
257
|
+
) -> None:
|
|
258
|
+
all_requested_urls = answer_with(
|
|
259
|
+
monkeypatch,
|
|
260
|
+
{
|
|
261
|
+
"issues/7/comments?per_page=100&page=1": (
|
|
262
|
+
200,
|
|
263
|
+
[top_level_record(each) for each in range(100)],
|
|
264
|
+
),
|
|
265
|
+
"issues/7/comments?per_page=100&page=2": (200, [top_level_record(100)]),
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
all_comments = reader.read_top_level_comments("jl-cmd/claude-dev-env", 7, TOKEN)
|
|
270
|
+
|
|
271
|
+
assert len(all_comments) == 101
|
|
272
|
+
assert all_comments[-1].identifier == 100
|
|
273
|
+
assert len(all_requested_urls) == 2
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def should_report_a_top_level_comment_page_of_the_wrong_shape(
|
|
277
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
278
|
+
) -> None:
|
|
279
|
+
answer_with(monkeypatch, {"issues/7/comments": (200, {"message": "not a list"})})
|
|
280
|
+
|
|
281
|
+
with pytest.raises(GitHubError):
|
|
282
|
+
reader.read_top_level_comments("jl-cmd/claude-dev-env", 7, TOKEN)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def should_report_a_top_level_comment_without_timestamps(
|
|
286
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
287
|
+
) -> None:
|
|
288
|
+
answer_with(monkeypatch, {"issues/7/comments": (200, [{"id": 1}])})
|
|
289
|
+
|
|
290
|
+
with pytest.raises(GitHubError):
|
|
291
|
+
reader.read_top_level_comments("jl-cmd/claude-dev-env", 7, TOKEN)
|