claude-dev-env 8.18.0 → 8.19.1
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/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/step_notes_constants/config/test_constants.py +22 -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/hooks/hooks_constants/test_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/review_closure_constants.py +29 -4
- 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/tests/test_adapter_configuration.py +8 -0
- 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
|
|
|
@@ -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."""
|
|
@@ -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,
|
|
@@ -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
|
|
|
@@ -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"],
|
|
@@ -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)
|