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
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Rule
6
6
 
7
- A review finding on the head is answered before the pull request merges. The agent driving it replies, pushes the fix, or both. The check named `Review closure` reads that state on every push and on every review event, and reports red while a finding waits.
7
+ A review finding on the head is answered before the pull request merges. The agent driving it replies, pushes the fix, or both. The check named `Review closure` reads that state on every push, on every review event, and on every top-level comment, and reports red while a finding waits.
8
8
 
9
9
  One command prints the same verdict:
10
10
 
@@ -24,6 +24,7 @@ It prints `CLOSED` and exits 0 when every finding on the head is answered. It pr
24
24
  | A red-circle finding | A reply from the driving account, or a push that replaced the code |
25
25
  | A thread the driving account opened | Itself |
26
26
  | A blocking `Claude Approvals` row | A push, which moves the head the check reports on |
27
+ | A top-level comment on the pull request | A later top-level comment from the driving account |
27
28
 
28
29
  A red circle marks a finding a review states as blocking, so resolution in silence leaves it open. The reply says what changed or why the finding stands, and the reviewer reads it beside the diff.
29
30
 
@@ -31,7 +32,9 @@ The driving account is the one that opened the pull request. Where the agent com
31
32
 
32
33
  ## Where the check runs
33
34
 
34
- `.github/workflows/review-closure.yml` runs it here on a push to a pull request, on a submitted or dismissed review, and on a review comment. Each run reports on the pull request's head commit, so a finding posted after the last push still turns the check red.
35
+ `.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.
36
+
37
+ A top-level comment arrives as an `issue_comment` event, and a run on that event belongs to the default branch commit. The comment job reads the pull request's head, runs the same command, and posts the verdict on that head through the Checks API as a `Review closure` check run. The token belongs to the GitHub Actions app, so that check run carries the same name and app as the pull request job's own, and the newest one on the head is the one branch rules read.
35
38
 
36
39
  A private repository that installs this package runs the same command from its own workflow, against the revision of this package that its workflow pins.
37
40
 
@@ -0,0 +1,76 @@
1
+ """Constants for the digest page check.
2
+
3
+ A digest page summarizes a set of changes for a reader who reads little and
4
+ needs to know every change. These values set the word budgets, the attribute
5
+ that marks one card, the elements that count as a picture, and the finding
6
+ messages.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ UTF8_ENCODING: str = "utf-8"
14
+
15
+ CARD_WORD_BUDGET: int = 25
16
+
17
+ LEAD_WORD_BUDGET: int = 40
18
+
19
+ ALL_PICTURE_TAGS: frozenset[str] = frozenset({"img", "svg", "canvas", "picture"})
20
+
21
+ ALL_HIDDEN_TEXT_TAGS: frozenset[str] = frozenset(
22
+ {"head", "script", "style", "template", "noscript"}
23
+ )
24
+
25
+ ALL_RAW_TEXT_TAGS: frozenset[str] = frozenset({"script", "style"})
26
+
27
+ ALL_VOID_TAGS: frozenset[str] = frozenset(
28
+ {
29
+ "area",
30
+ "base",
31
+ "br",
32
+ "col",
33
+ "embed",
34
+ "hr",
35
+ "img",
36
+ "input",
37
+ "link",
38
+ "meta",
39
+ "source",
40
+ "track",
41
+ "wbr",
42
+ }
43
+ )
44
+
45
+ MARKUP_PATTERN: re.Pattern[str] = re.compile(
46
+ r"<!--.*?-->|<![^>]*>"
47
+ r"|<(?P<closing>/?)(?P<tag_name>[a-zA-Z][\w:-]*)"
48
+ r"(?P<attributes>(?:[^>\"']|\"[^\"]*\"|'[^']*')*?)(?P<self_closing>/?)>",
49
+ re.DOTALL,
50
+ )
51
+
52
+ CARD_ATTRIBUTE_PATTERN: re.Pattern[str] = re.compile(
53
+ r"\bdata-digest-card\s*=\s*(?:\"(?P<double>[^\"]*)\"|'(?P<single>[^']*)')"
54
+ )
55
+
56
+ WORD_PATTERN: re.Pattern[str] = re.compile(r"[^\W_][\w'’-]*")
57
+
58
+ MISSING_CARD_FINDING: str = "missing card {card_id}: the page leaves it out"
59
+
60
+ DUPLICATE_CARD_FINDING: str = "card {card_id} appears {count} times"
61
+
62
+ CARD_OVER_BUDGET_FINDING: str = (
63
+ "card {card_id} carries {word_count} words; the budget is {budget}"
64
+ )
65
+
66
+ CARD_WITHOUT_PICTURE_FINDING: str = "card {card_id} has no picture"
67
+
68
+ LEAD_OVER_BUDGET_FINDING: str = (
69
+ "text outside the cards carries {word_count} words; the budget is {budget}"
70
+ )
71
+
72
+ CLEAN_VERDICT: str = "CLEAN: {card_count} cards, every one present"
73
+
74
+ EXIT_CLEAN: int = 0
75
+
76
+ EXIT_FINDINGS: int = 1
@@ -234,8 +234,33 @@ REVIEW_COMMENTS_ENDPOINT_TEMPLATE: str = (
234
234
  )
235
235
  """REST route carrying one page of a pull request's review comments."""
236
236
 
237
- REVIEW_COMMENT_PAGE_SIZE: int = 100
238
- """How many review comments one page carries."""
237
+ COMMENT_PAGE_SIZE: int = 100
238
+ """How many review or top-level comments one page carries."""
239
239
 
240
- MAX_REVIEW_COMMENT_PAGES: int = 20
241
- """How many comment pages one run reads before it stops."""
240
+ MAX_COMMENT_PAGES: int = 20
241
+ """How many comment pages one listing reads before it stops."""
242
+
243
+ TOP_LEVEL_COMMENTS_ENDPOINT_TEMPLATE: str = (
244
+ "{api_root}/repos/{slug}/issues/{number}/comments?per_page={page_size}&page={page}"
245
+ )
246
+ """REST route carrying one page of a pull request's top-level comments."""
247
+
248
+ HTML_URL_KEY: str = "html_url"
249
+ """Field carrying the page address of a top-level comment."""
250
+
251
+ CREATED_AT_KEY: str = "created_at"
252
+ """Field carrying when a top-level comment was posted."""
253
+
254
+ UPDATED_AT_KEY: str = "updated_at"
255
+ """Field carrying when a top-level comment was last edited."""
256
+
257
+ TOP_LEVEL_OPEN_REASON_TEMPLATE: str = (
258
+ "a top-level comment from {author} with no later top-level comment from "
259
+ "the driving agent"
260
+ )
261
+ """Why a top-level comment stays open until the agent posts after it."""
262
+
263
+ UNREADABLE_TOP_LEVEL_COMMENT_TEMPLATE: str = (
264
+ "a top-level comment carries an unreadable timestamp: {record}"
265
+ )
266
+ """What to report when a top-level comment's timestamps do not parse."""
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env python3
2
+ """Check that a digest page lets its reader read less and still know all.
3
+
4
+ A digest page summarizes a set of changes, such as the pull requests merged in
5
+ a day. Each change gets one card, marked with ``data-digest-card="<id>"``,
6
+ that holds its picture and its one-line change.
7
+
8
+ ::
9
+
10
+ python digest_check.py summary.html --expect 4701 4702 4715
11
+ ok: CLEAN: 3 cards, every one present
12
+ flag: missing card 4715: the page leaves it out
13
+ flag: card 4701 carries 31 words; the budget is 25
14
+ flag: card 4702 has no picture
15
+
16
+ The check also reports lead text over its own budget and a card marked twice.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import html
23
+ import re
24
+ import sys
25
+ from collections import Counter
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ from dev_env_scripts_constants.digest_check_constants import (
30
+ ALL_HIDDEN_TEXT_TAGS,
31
+ ALL_PICTURE_TAGS,
32
+ ALL_RAW_TEXT_TAGS,
33
+ ALL_VOID_TAGS,
34
+ CARD_ATTRIBUTE_PATTERN,
35
+ CARD_OVER_BUDGET_FINDING,
36
+ CARD_WITHOUT_PICTURE_FINDING,
37
+ CARD_WORD_BUDGET,
38
+ CLEAN_VERDICT,
39
+ DUPLICATE_CARD_FINDING,
40
+ EXIT_CLEAN,
41
+ EXIT_FINDINGS,
42
+ LEAD_OVER_BUDGET_FINDING,
43
+ LEAD_WORD_BUDGET,
44
+ MARKUP_PATTERN,
45
+ MISSING_CARD_FINDING,
46
+ UTF8_ENCODING,
47
+ WORD_PATTERN,
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class DigestCard:
53
+ """One marked card on a digest page."""
54
+
55
+ card_id: str
56
+ word_count: int = 0
57
+ has_picture: bool = False
58
+
59
+
60
+ @dataclass
61
+ class DigestPage:
62
+ """Every marked card on a page, and the words outside them."""
63
+
64
+ all_cards: list[DigestCard] = field(default_factory=list)
65
+ lead_word_count: int = 0
66
+
67
+
68
+ @dataclass
69
+ class _OpenElement:
70
+ tag_name: str
71
+ card: DigestCard | None
72
+ is_hidden: bool
73
+
74
+
75
+ def _innermost_card(all_open: list[_OpenElement]) -> DigestCard | None:
76
+ for each_open in reversed(all_open):
77
+ if each_open.card is not None:
78
+ return each_open.card
79
+ return None
80
+
81
+
82
+ def _count_words(page: DigestPage, all_open: list[_OpenElement], text: str) -> None:
83
+ if any(each_open.is_hidden for each_open in all_open):
84
+ return
85
+ word_count = len(WORD_PATTERN.findall(html.unescape(text)))
86
+ enclosing_card = _innermost_card(all_open)
87
+ if enclosing_card is None:
88
+ page.lead_word_count += word_count
89
+ else:
90
+ enclosing_card.word_count += word_count
91
+
92
+
93
+ def _close_element(all_open: list[_OpenElement], tag_name: str) -> None:
94
+ for each_index in range(len(all_open) - 1, -1, -1):
95
+ if all_open[each_index].tag_name == tag_name:
96
+ del all_open[each_index:]
97
+ return
98
+
99
+
100
+ def _open_element(
101
+ page: DigestPage, all_open: list[_OpenElement], tag_match: re.Match[str]
102
+ ) -> None:
103
+ tag_name = tag_match.group("tag_name").lower()
104
+ if tag_name in ALL_PICTURE_TAGS:
105
+ enclosing_card = _innermost_card(all_open)
106
+ if enclosing_card is not None:
107
+ enclosing_card.has_picture = True
108
+ if tag_name in ALL_VOID_TAGS or tag_match.group("self_closing"):
109
+ return
110
+ new_card = None
111
+ card_match = CARD_ATTRIBUTE_PATTERN.search(tag_match.group("attributes"))
112
+ if card_match is not None:
113
+ card_id = card_match.group("double") or card_match.group("single") or ""
114
+ new_card = DigestCard(card_id=html.unescape(card_id).strip())
115
+ page.all_cards.append(new_card)
116
+ all_open.append(_OpenElement(tag_name, new_card, tag_name in ALL_HIDDEN_TEXT_TAGS))
117
+
118
+
119
+ def read_digest_page(page_html: str) -> DigestPage:
120
+ """Return the marked cards and lead word count of one page.
121
+
122
+ Args:
123
+ page_html: Full HTML source of the page.
124
+
125
+ Returns:
126
+ The parsed page, with words counted toward their innermost card.
127
+ """
128
+ page = DigestPage()
129
+ all_open: list[_OpenElement] = []
130
+ position = 0
131
+ while position < len(page_html):
132
+ tag_match = MARKUP_PATTERN.search(page_html, position)
133
+ text_end = len(page_html) if tag_match is None else tag_match.start()
134
+ _count_words(page, all_open, page_html[position:text_end])
135
+ if tag_match is None:
136
+ break
137
+ position = tag_match.end()
138
+ if tag_match.group("tag_name") is None:
139
+ continue
140
+ tag_name = tag_match.group("tag_name").lower()
141
+ if tag_match.group("closing"):
142
+ _close_element(all_open, tag_name)
143
+ continue
144
+ _open_element(page, all_open, tag_match)
145
+ if tag_name in ALL_RAW_TEXT_TAGS and not tag_match.group("self_closing"):
146
+ raw_end = page_html.lower().find(f"</{tag_name}", position)
147
+ position = len(page_html) if raw_end == -1 else raw_end
148
+ return page
149
+
150
+
151
+ def _coverage_findings(page: DigestPage, all_expected_ids: list[str]) -> list[str]:
152
+ card_counts = Counter(each_card.card_id for each_card in page.all_cards)
153
+ all_findings = [
154
+ MISSING_CARD_FINDING.format(card_id=each_expected_id)
155
+ for each_expected_id in all_expected_ids
156
+ if card_counts[each_expected_id] == 0
157
+ ]
158
+ all_findings.extend(
159
+ DUPLICATE_CARD_FINDING.format(card_id=each_card_id, count=each_count)
160
+ for each_card_id, each_count in card_counts.items()
161
+ if each_count > 1
162
+ )
163
+ return all_findings
164
+
165
+
166
+ def _budget_findings(page: DigestPage) -> list[str]:
167
+ all_findings: list[str] = []
168
+ for each_card in page.all_cards:
169
+ if each_card.word_count > CARD_WORD_BUDGET:
170
+ all_findings.append(
171
+ CARD_OVER_BUDGET_FINDING.format(
172
+ card_id=each_card.card_id,
173
+ word_count=each_card.word_count,
174
+ budget=CARD_WORD_BUDGET,
175
+ )
176
+ )
177
+ if not each_card.has_picture:
178
+ all_findings.append(
179
+ CARD_WITHOUT_PICTURE_FINDING.format(card_id=each_card.card_id)
180
+ )
181
+ if page.lead_word_count > LEAD_WORD_BUDGET:
182
+ all_findings.append(
183
+ LEAD_OVER_BUDGET_FINDING.format(
184
+ word_count=page.lead_word_count, budget=LEAD_WORD_BUDGET
185
+ )
186
+ )
187
+ return all_findings
188
+
189
+
190
+ def digest_findings(page: DigestPage, all_expected_ids: list[str]) -> list[str]:
191
+ """Return every way the page makes its reader read more or know less.
192
+
193
+ Args:
194
+ page: The parsed page.
195
+ all_expected_ids: Every source id the page must cover with a card.
196
+
197
+ Returns:
198
+ Coverage findings first, then budget findings; empty when the page passes.
199
+ """
200
+ return _coverage_findings(page, all_expected_ids) + _budget_findings(page)
201
+
202
+
203
+ def main(all_arguments: list[str]) -> int:
204
+ """Check one page against its source ids and print the verdict.
205
+
206
+ Args:
207
+ all_arguments: Command-line arguments after the program name.
208
+
209
+ Returns:
210
+ Zero when the page passes, one when any finding reports.
211
+ """
212
+ argument_parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
213
+ argument_parser.add_argument("page", type=Path)
214
+ argument_parser.add_argument("--expect", nargs="+", required=True)
215
+ parsed = argument_parser.parse_args(all_arguments)
216
+ page = read_digest_page(parsed.page.read_text(encoding=UTF8_ENCODING))
217
+ all_findings = digest_findings(page, parsed.expect)
218
+ for each_finding in all_findings:
219
+ print(each_finding)
220
+ if all_findings:
221
+ return EXIT_FINDINGS
222
+ print(CLEAN_VERDICT.format(card_count=len(parsed.expect)))
223
+ return EXIT_CLEAN
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main(sys.argv[1:]))
@@ -83,6 +83,49 @@ def _has_caller(document: Document, all_caller_texts: tuple[str, ...]) -> bool:
83
83
  return any(name_pattern.search(each_text) for each_text in all_caller_texts)
84
84
 
85
85
 
86
+ def _reached_paths(
87
+ all_new_documents: tuple[Document, ...],
88
+ all_caller_texts: tuple[str, ...],
89
+ all_called: set[PurePosixPath],
90
+ ) -> set[PurePosixPath]:
91
+ return {
92
+ each_document.path
93
+ for each_document in all_new_documents
94
+ if each_document.path not in all_called
95
+ and _has_caller(each_document, all_caller_texts)
96
+ }
97
+
98
+
99
+ def _called_paths(
100
+ all_new_documents: tuple[Document, ...], all_caller_texts: tuple[str, ...]
101
+ ) -> frozenset[PurePosixPath]:
102
+ """Return each new file a caller reaches, directly or through a called new file.
103
+
104
+ ::
105
+
106
+ workflow step -> new_check.py -> new_check_constants.py both called
107
+ nothing -> lonely.py -> helper.py both uncalled
108
+
109
+ Args:
110
+ all_new_documents: Every new code file in the change.
111
+ all_caller_texts: Text of every existing file that may name one.
112
+
113
+ Returns:
114
+ Paths of the new files some caller chain reaches.
115
+ """
116
+ all_called: set[PurePosixPath] = set()
117
+ all_reached = _reached_paths(all_new_documents, all_caller_texts, all_called)
118
+ while all_reached:
119
+ all_called |= all_reached
120
+ all_reached_texts = tuple(
121
+ each_document.text
122
+ for each_document in all_new_documents
123
+ if each_document.path in all_reached
124
+ )
125
+ all_reached = _reached_paths(all_new_documents, all_reached_texts, all_called)
126
+ return frozenset(all_called)
127
+
128
+
86
129
  def _uncalled_diagnostic(document: Document) -> Diagnostic:
87
130
  return Diagnostic(
88
131
  constants.UNCALLED_NEW_FILE_RULE_ID,
@@ -102,7 +145,7 @@ def uncalled_new_file_diagnostics(
102
145
 
103
146
  Returns:
104
147
  One diagnostic for each added script, hook or skill script whose name
105
- appears only in tests and inventory files.
148
+ appears only in tests, inventory files, and other uncalled new files.
106
149
  """
107
150
  if document_set.selection not in {SelectionKind.STAGED, SelectionKind.BASE}:
108
151
  return ()
@@ -117,8 +160,9 @@ def uncalled_new_file_diagnostics(
117
160
  document_set.repository_root,
118
161
  frozenset(each_document.path for each_document in all_new_documents),
119
162
  )
163
+ all_called = _called_paths(all_new_documents, all_caller_texts)
120
164
  return tuple(
121
165
  _uncalled_diagnostic(each_document)
122
166
  for each_document in all_new_documents
123
- if not _has_caller(each_document, all_caller_texts)
167
+ if each_document.path not in all_called
124
168
  )
@@ -127,12 +127,16 @@ def _names_exempt_registration_path(registered_string: str) -> bool:
127
127
  ::
128
128
 
129
129
  hooks/blocking/bash_pre_tool_use_dispatcher.py -> exempt
130
+ hooks/blocking/step_note_gate.py -> exempt
130
131
  hooks/blocking/some_new_blocker.py -> flagged
131
132
 
132
133
  The Bash PreToolUse dispatcher sits under ``blocking/`` for layout reasons
133
134
  while its roster hosts one allow-and-rewrite hook. Its path segment reads as
134
135
  a policy boundary that the chain never carries.
135
136
 
137
+ The step-note gate allows every call until the user runs ``/step-notes on``.
138
+ It asks for a readable status line and decides no code or safety policy.
139
+
136
140
  Args:
137
141
  registered_string: One command, path, script, or entrypoint string.
138
142
  """
@@ -63,6 +63,7 @@ ALL_ACTION_BOUNDARY_SEGMENTS = frozenset(
63
63
  ALL_ACTION_BOUNDARY_PREFIXES = ("deny_", "block_", "ask_")
64
64
  ALL_ACTION_BOUNDARY_EXEMPT_REGISTRATION_PATHS = (
65
65
  "blocking/bash_pre_tool_use_dispatcher.py",
66
+ "blocking/step_note_gate.py",
66
67
  )
67
68
  ALL_TEST_DIRECTORY_NAMES = frozenset({"tests"})
68
69
  ALL_TEST_FILE_PREFIXES = ("test_",)
@@ -7,8 +7,9 @@
7
7
  CLOSED jl-cmd/claude-dev-env#1442 ab845eb :: every review finding on this
8
8
  commit is answered
9
9
 
10
- A finding waits until the agent driving the pull request replies to it or
11
- pushes the fix. Exit status is 0 for CLOSED, 1 for OPEN, and 2 when the pull
10
+ A finding is a review thread, a top-level comment on the pull request, or a
11
+ blocking Claude Approvals row. It waits until the agent driving the pull
12
+ request replies to it or pushes the fix. Exit status is 0 for CLOSED, 1 for OPEN, and 2 when the pull
12
13
  request state could not be read.
13
14
  """
14
15
 
@@ -35,6 +36,7 @@ from review_closure_github import (
35
36
  read_approvals_conclusion,
36
37
  read_pull_request,
37
38
  read_review_threads,
39
+ read_top_level_comments,
38
40
  )
39
41
  from review_closure_model import (
40
42
  OpenFinding,
@@ -93,6 +95,7 @@ def closure_report(
93
95
  all_pull_request_fields = read_pull_request(slug, number, token)
94
96
  all_findings = all_open_findings(
95
97
  read_review_threads(slug, number, token),
98
+ read_top_level_comments(slug, number, token),
96
99
  driver_logins(all_pull_request_fields, all_extra_logins),
97
100
  read_approvals_conclusion(slug, head_sha(all_pull_request_fields), token),
98
101
  )
@@ -1,12 +1,13 @@
1
1
  """Read from GitHub what the review closure verdict rests on.
2
2
 
3
- Three reads answer one pull request: the pull request itself, its review
4
- threads, and the check runs on its head commit.
3
+ Four reads answer one pull request: the pull request itself, its review
4
+ threads, its top-level comments, and the check runs on its head commit.
5
5
 
6
6
  ::
7
7
 
8
8
  read_pull_request("jl-cmd/claude-dev-env", 1442, token)
9
9
  read_review_threads("jl-cmd/claude-dev-env", 1442, token)
10
+ read_top_level_comments("jl-cmd/claude-dev-env", 1442, token)
10
11
  read_approvals_conclusion("jl-cmd/claude-dev-env", "ab845eb", token)
11
12
 
12
13
  Two routes carry the review threads. A Claude Code session reaches GitHub
@@ -30,10 +31,11 @@ from dev_env_scripts_constants.review_closure_constants import (
30
31
  CHECK_RUN_PAGE_SIZE,
31
32
  CHECK_RUNS_ENDPOINT_TEMPLATE,
32
33
  CHECK_RUNS_KEY,
34
+ COMMENT_PAGE_SIZE,
33
35
  GET_METHOD,
34
36
  GITHUB_API_ROOT,
35
37
  GITHUB_GRAPHQL_ENDPOINT,
36
- MAX_REVIEW_COMMENT_PAGES,
38
+ MAX_COMMENT_PAGES,
37
39
  NAME_VARIABLE,
38
40
  NO_SIGN_IN_MESSAGE,
39
41
  NUMBER_VARIABLE,
@@ -44,12 +46,13 @@ from dev_env_scripts_constants.review_closure_constants import (
44
46
  QUERY_KEY,
45
47
  REQUEST_FAILED_TEMPLATE,
46
48
  REQUEST_TIMEOUT_SECONDS,
47
- REVIEW_COMMENT_PAGE_SIZE,
48
49
  REVIEW_COMMENTS_ENDPOINT_TEMPLATE,
49
50
  REVIEW_THREAD_PAGE_SIZE,
50
51
  REVIEW_THREAD_QUERY,
51
52
  REVIEW_THREADS_ENDPOINT_TEMPLATE,
52
53
  SLUG_SEPARATOR,
54
+ TOP_LEVEL_COMMENTS_ENDPOINT_TEMPLATE,
55
+ UNREADABLE_TOP_LEVEL_COMMENT_TEMPLATE,
53
56
  VARIABLES_KEY,
54
57
  )
55
58
  from pr_verification.config.constants import (
@@ -67,9 +70,11 @@ from pr_verification.config.constants import (
67
70
  from pr_verification.github_parsing import GitHubError
68
71
  from review_closure_model import (
69
72
  ReviewThread,
73
+ TopLevelComment,
70
74
  approvals_conclusion,
71
75
  comment_records_by_id,
72
76
  parse_thread,
77
+ parse_top_level_comment,
73
78
  )
74
79
 
75
80
 
@@ -191,23 +196,72 @@ def read_review_comments_by_id(
191
196
  Raises:
192
197
  GitHubError: A page answered with something other than a list.
193
198
  """
199
+ return comment_records_by_id(
200
+ _all_listed_records(REVIEW_COMMENTS_ENDPOINT_TEMPLATE, slug, number, token)
201
+ )
202
+
203
+
204
+ def read_top_level_comments(
205
+ slug: str, number: int, token: str
206
+ ) -> tuple[TopLevelComment, ...]:
207
+ """Read every top-level comment on a pull request.
208
+
209
+ Args:
210
+ slug: The repository as ``owner/name``.
211
+ number: The pull request number.
212
+ token: The GitHub token the request authenticates with.
213
+
214
+ Returns:
215
+ Each comment posted on the pull request itself, outside any review
216
+ thread.
217
+
218
+ Raises:
219
+ GitHubError: A page answered with something other than a list, or a
220
+ comment carried a timestamp that does not parse.
221
+ """
222
+ all_records = _all_listed_records(
223
+ TOP_LEVEL_COMMENTS_ENDPOINT_TEMPLATE, slug, number, token
224
+ )
225
+ return tuple(
226
+ _parsed_top_level_comment(each_record)
227
+ for each_record in all_records
228
+ if isinstance(each_record, Mapping)
229
+ )
230
+
231
+
232
+ def _parsed_top_level_comment(
233
+ all_comment_fields: Mapping[str, object],
234
+ ) -> TopLevelComment:
235
+ try:
236
+ return parse_top_level_comment(all_comment_fields)
237
+ except ValueError as failure:
238
+ raise GitHubError(
239
+ UNREADABLE_TOP_LEVEL_COMMENT_TEMPLATE.format(record=all_comment_fields)
240
+ ) from failure
241
+
242
+
243
+ def _all_listed_records(
244
+ endpoint_template: str, slug: str, number: int, token: str
245
+ ) -> list[object]:
194
246
  all_records: list[object] = []
195
- for each_page in range(1, MAX_REVIEW_COMMENT_PAGES + 1):
196
- page = _review_comment_page(slug, number, token, each_page)
247
+ for each_page in range(1, MAX_COMMENT_PAGES + 1):
248
+ page = _listing_page(endpoint_template, slug, number, token, each_page)
197
249
  all_records.extend(page)
198
- if len(page) < REVIEW_COMMENT_PAGE_SIZE:
250
+ if len(page) < COMMENT_PAGE_SIZE:
199
251
  break
200
- return comment_records_by_id(all_records)
252
+ return all_records
201
253
 
202
254
 
203
- def _review_comment_page(slug: str, number: int, token: str, page: int) -> list[object]:
255
+ def _listing_page(
256
+ endpoint_template: str, slug: str, number: int, token: str, page: int
257
+ ) -> list[object]:
204
258
  document = request_json(
205
259
  GET_METHOD,
206
- REVIEW_COMMENTS_ENDPOINT_TEMPLATE.format(
260
+ endpoint_template.format(
207
261
  api_root=GITHUB_API_ROOT,
208
262
  slug=slug,
209
263
  number=number,
210
- page_size=REVIEW_COMMENT_PAGE_SIZE,
264
+ page_size=COMMENT_PAGE_SIZE,
211
265
  page=page,
212
266
  ),
213
267
  token,