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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
"""How many comment pages one
|
|
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
|
|
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
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
196
|
-
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) <
|
|
250
|
+
if len(page) < COMMENT_PAGE_SIZE:
|
|
199
251
|
break
|
|
200
|
-
return
|
|
252
|
+
return all_records
|
|
201
253
|
|
|
202
254
|
|
|
203
|
-
def
|
|
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
|
-
|
|
260
|
+
endpoint_template.format(
|
|
207
261
|
api_root=GITHUB_API_ROOT,
|
|
208
262
|
slug=slug,
|
|
209
263
|
number=number,
|
|
210
|
-
page_size=
|
|
264
|
+
page_size=COMMENT_PAGE_SIZE,
|
|
211
265
|
page=page,
|
|
212
266
|
),
|
|
213
267
|
token,
|