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.
Files changed (30) hide show
  1. package/.agents/skills/eli5/SKILL.md +15 -1
  2. package/.agents/skills/eli5/reference/task-seeds.md +3 -1
  3. package/.agents/skills/step-notes/SKILL.md +30 -0
  4. package/.agents/skills/step-notes/scripts/step_notes_constants/__init__.py +0 -0
  5. package/.agents/skills/step-notes/scripts/step_notes_constants/config/__init__.py +1 -0
  6. package/.agents/skills/step-notes/scripts/step_notes_constants/config/constants.py +12 -0
  7. package/.agents/skills/step-notes/scripts/test_toggle_step_notes.py +62 -0
  8. package/.agents/skills/step-notes/scripts/toggle_step_notes.py +68 -0
  9. package/bin/ever-shipped-skills.mjs +1 -0
  10. package/hooks/blocking/step_note_gate.py +160 -0
  11. package/hooks/blocking/test_step_note_gate.py +245 -0
  12. package/hooks/hooks.json +5 -0
  13. package/hooks/hooks_constants/step_note_gate_constants.py +19 -0
  14. package/package.json +1 -1
  15. package/rules/review-closure-is-a-check.md +5 -2
  16. package/scripts/dev_env_scripts_constants/digest_check_constants.py +76 -0
  17. package/scripts/dev_env_scripts_constants/review_closure_constants.py +29 -4
  18. package/scripts/digest_check.py +227 -0
  19. package/scripts/policy_lint/adapter_callers.py +46 -2
  20. package/scripts/policy_lint/adapter_configuration.py +4 -0
  21. package/scripts/policy_lint/config/constants.py +1 -0
  22. package/scripts/review_closure.py +5 -2
  23. package/scripts/review_closure_github.py +65 -11
  24. package/scripts/review_closure_model.py +132 -3
  25. package/scripts/test_digest_check.py +147 -0
  26. package/scripts/tests/test_adapter_configuration.py +8 -0
  27. package/scripts/tests/test_policy_lint_rules_callers.py +31 -2
  28. package/scripts/tests/test_review_closure.py +25 -0
  29. package/scripts/tests/test_review_closure_github.py +51 -0
  30. 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 and the approvals conclusion, and name what waits.
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 the approvals finding when
168
- that check reports a blocking row on this commit.
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 &amp; 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(relative_path: str, prior_text: str | None = None) -> Document:
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
- "print('hi')\n",
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)