claude-dev-env 8.6.1 → 8.7.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/privacy-hygiene/reference/sweep-procedure.md +1 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
- package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
- package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
- package/bin/install.test.mjs +25 -0
- package/docs/high-trust-agent-delivery.md +132 -0
- package/hooks/advisory/refactor_guard.py +2 -2
- package/hooks/advisory/test_refactor_guard.py +21 -0
- package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
- package/hooks/blocking/code_rules_shared.py +4 -7
- package/hooks/blocking/conftest.py +22 -0
- package/hooks/blocking/state_description_blocker.py +10 -8
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
- package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
- package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
- package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
- package/hooks/blocking/test_code_rules_shared.py +18 -0
- package/hooks/blocking/test_conftest.py +35 -0
- package/hooks/blocking/test_state_description_blocker.py +16 -0
- package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
- package/hooks/git-hooks/pull_request_handoff.py +2 -2
- package/hooks/git-hooks/test_config.py +10 -3
- package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
- package/hooks/git-hooks/test_verification_notice_state.py +50 -0
- package/hooks/git-hooks/verification_notice_state.py +3 -1
- package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
- package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
- package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
- package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
- package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
- package/hooks/validators/exempt_paths.py +3 -18
- package/hooks/validators/test_exempt_paths.py +20 -0
- package/hooks/validators/test_python_antipattern_checks.py +14 -0
- package/package.json +1 -1
- package/rules/agent-merges-its-own-green-pull-request.md +54 -0
- package/rules/archiving-agent-config.md +10 -0
- package/rules/code-standards.md +1 -0
- package/rules/correction-lens.md +134 -0
- package/rules/flag-non-breaking-findings.md +11 -0
- package/rules/no-contrast-framing.md +68 -0
- package/rules/review-closure-is-a-check.md +44 -0
- package/scripts/Invoke-PesterSuites.ps1 +42 -0
- package/scripts/agent_merge_check.py +381 -0
- package/scripts/check.ps1 +12 -1
- package/scripts/ci/install-playtest.mjs +291 -0
- package/scripts/ci/install-playtest.test.mjs +84 -0
- package/scripts/ci/installer-lifecycle.mjs +11 -30
- package/scripts/ci/installer-lifecycle.test.mjs +14 -0
- package/scripts/ci/pstack-command-shim.test.mjs +33 -0
- package/scripts/ci/scratch-home-install.mjs +73 -0
- package/scripts/ci/scratch-home-install.test.mjs +49 -0
- package/scripts/codex_compat_materializer.py +33 -16
- package/scripts/contrast_framing.py +101 -0
- package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
- package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
- package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
- package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
- package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
- package/scripts/durable_post_lint.py +30 -4
- package/scripts/followup_cli.py +6 -10
- package/scripts/merge_gate_checks.py +236 -0
- package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
- package/scripts/policy_lint/adapters.py +30 -0
- package/scripts/policy_lint/config/constants.py +2 -0
- package/scripts/policy_lint/registry.py +5 -0
- package/scripts/repository_checks/config/constants.py +12 -0
- package/scripts/repository_checks/config/policy_document.py +28 -2
- package/scripts/repository_checks/followups.py +54 -0
- package/scripts/repository_checks/match_exemptions.py +67 -0
- package/scripts/repository_checks/models.py +46 -2
- package/scripts/repository_checks/reporting.py +23 -7
- package/scripts/repository_checks/tracked_secrets.py +2 -2
- package/scripts/repository_policy.py +4 -2
- package/scripts/review_closure.py +134 -0
- package/scripts/review_closure_github.py +345 -0
- package/scripts/review_closure_model.py +360 -0
- package/scripts/test_agent_merge_check.py +317 -0
- package/scripts/test_durable_post_lint.py +46 -0
- package/scripts/test_followup_cli.py +40 -0
- package/scripts/test_merge_gate_checks.py +92 -0
- package/scripts/tests/test_code_review_constants.py +78 -0
- package/scripts/tests/test_codex_compat_materializer.py +48 -4
- package/scripts/tests/test_contrast_framing.py +117 -0
- package/scripts/tests/test_followups.py +88 -0
- package/scripts/tests/test_match_exemptions.py +291 -0
- package/scripts/tests/test_models.py +67 -2
- package/scripts/tests/test_package_inventory.py +6 -4
- package/scripts/tests/test_pester_suites_run.py +53 -0
- package/scripts/tests/test_policy_document.py +39 -1
- package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
- package/scripts/tests/test_reporting.py +18 -0
- package/scripts/tests/test_repository_policy.py +43 -1
- package/scripts/tests/test_review_closure.py +126 -0
- package/scripts/tests/test_review_closure_github.py +240 -0
- package/scripts/tests/test_review_closure_model.py +299 -0
- package/scripts/tests/test_tracked_secrets.py +42 -0
- package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
- package/scripts/repository_checks/email_exemptions.py +0 -39
- package/scripts/tests/test_email_exemptions.py +0 -158
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Read from GitHub what the review closure verdict rests on.
|
|
2
|
+
|
|
3
|
+
Three reads answer one pull request: the pull request itself, its review
|
|
4
|
+
threads, and the check runs on its head commit.
|
|
5
|
+
|
|
6
|
+
::
|
|
7
|
+
|
|
8
|
+
read_pull_request("jl-cmd/claude-dev-env", 1442, token)
|
|
9
|
+
read_review_threads("jl-cmd/claude-dev-env", 1442, token)
|
|
10
|
+
read_approvals_conclusion("jl-cmd/claude-dev-env", "ab845eb", token)
|
|
11
|
+
|
|
12
|
+
Two routes carry the review threads. A Claude Code session reaches GitHub
|
|
13
|
+
through a proxy that refuses GraphQL and serves the threads over a REST route
|
|
14
|
+
of its own, so the REST route is read first and the GraphQL query answers for
|
|
15
|
+
a workflow runner.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import functools
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
|
|
27
|
+
from dev_env_scripts_constants.review_closure_constants import (
|
|
28
|
+
ALL_THREAD_NODE_KEYS,
|
|
29
|
+
ALL_TOKEN_ENVIRONMENT_VARIABLES,
|
|
30
|
+
CHECK_RUN_PAGE_SIZE,
|
|
31
|
+
CHECK_RUNS_ENDPOINT_TEMPLATE,
|
|
32
|
+
CHECK_RUNS_KEY,
|
|
33
|
+
GET_METHOD,
|
|
34
|
+
GITHUB_API_ROOT,
|
|
35
|
+
GITHUB_GRAPHQL_ENDPOINT,
|
|
36
|
+
MAX_REVIEW_COMMENT_PAGES,
|
|
37
|
+
NAME_VARIABLE,
|
|
38
|
+
NO_SIGN_IN_MESSAGE,
|
|
39
|
+
NUMBER_VARIABLE,
|
|
40
|
+
OWNER_VARIABLE,
|
|
41
|
+
PAGE_SIZE_VARIABLE,
|
|
42
|
+
POST_METHOD,
|
|
43
|
+
PULL_REQUEST_ENDPOINT_TEMPLATE,
|
|
44
|
+
QUERY_KEY,
|
|
45
|
+
REQUEST_FAILED_TEMPLATE,
|
|
46
|
+
REQUEST_TIMEOUT_SECONDS,
|
|
47
|
+
REVIEW_COMMENT_PAGE_SIZE,
|
|
48
|
+
REVIEW_COMMENTS_ENDPOINT_TEMPLATE,
|
|
49
|
+
REVIEW_THREAD_PAGE_SIZE,
|
|
50
|
+
REVIEW_THREAD_QUERY,
|
|
51
|
+
REVIEW_THREADS_ENDPOINT_TEMPLATE,
|
|
52
|
+
SLUG_SEPARATOR,
|
|
53
|
+
VARIABLES_KEY,
|
|
54
|
+
)
|
|
55
|
+
from pr_verification.config.constants import (
|
|
56
|
+
ACCEPT_HEADER,
|
|
57
|
+
API_VERSION_HEADER,
|
|
58
|
+
AUTHORIZATION_HEADER,
|
|
59
|
+
BEARER_PREFIX,
|
|
60
|
+
CONTENT_TYPE_HEADER,
|
|
61
|
+
GITHUB_ACCEPT_TYPE,
|
|
62
|
+
GITHUB_API_VERSION,
|
|
63
|
+
HTTP_OK,
|
|
64
|
+
JSON_CONTENT_TYPE,
|
|
65
|
+
UTF8_ENCODING,
|
|
66
|
+
)
|
|
67
|
+
from pr_verification.github_parsing import GitHubError
|
|
68
|
+
from review_closure_model import (
|
|
69
|
+
ReviewThread,
|
|
70
|
+
approvals_conclusion,
|
|
71
|
+
comment_records_by_id,
|
|
72
|
+
parse_thread,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def github_token() -> str:
|
|
77
|
+
"""Find the GitHub token this run authenticates with.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The first token the environment carries.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
GitHubError: No token is set.
|
|
84
|
+
"""
|
|
85
|
+
for each_variable in ALL_TOKEN_ENVIRONMENT_VARIABLES:
|
|
86
|
+
token = os.environ.get(each_variable)
|
|
87
|
+
if token:
|
|
88
|
+
return token
|
|
89
|
+
raise GitHubError(NO_SIGN_IN_MESSAGE)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def request_json(
|
|
93
|
+
method: str,
|
|
94
|
+
url: str,
|
|
95
|
+
token: str,
|
|
96
|
+
all_payload_fields: Mapping[str, object] | None,
|
|
97
|
+
) -> object:
|
|
98
|
+
"""Send one GitHub request and read its JSON answer.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
method: The HTTP method.
|
|
102
|
+
url: The absolute request URL.
|
|
103
|
+
token: The GitHub token the request authenticates with.
|
|
104
|
+
all_payload_fields: The JSON body to send, or None for a read.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The decoded answer.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
GitHubError: The request failed, or GitHub answered with a status
|
|
111
|
+
other than success.
|
|
112
|
+
"""
|
|
113
|
+
request = _built_request(method, url, token, all_payload_fields)
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as reply:
|
|
116
|
+
return _decoded_answer(url, reply.status, reply.read())
|
|
117
|
+
except (urllib.error.URLError, OSError, ValueError) as failure:
|
|
118
|
+
raise GitHubError(str(failure)) from failure
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_pull_request(slug: str, number: int, token: str) -> Mapping[str, object]:
|
|
122
|
+
"""Read one pull request from the GitHub REST API.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
slug: The repository as ``owner/name``.
|
|
126
|
+
number: The pull request number.
|
|
127
|
+
token: The GitHub token the request authenticates with.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The pull request fields, carrying its number, its head commit, and
|
|
131
|
+
the account that opened it.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
GitHubError: GitHub answered with something other than a pull
|
|
135
|
+
request object.
|
|
136
|
+
"""
|
|
137
|
+
document = request_json(
|
|
138
|
+
GET_METHOD,
|
|
139
|
+
PULL_REQUEST_ENDPOINT_TEMPLATE.format(
|
|
140
|
+
api_root=GITHUB_API_ROOT, slug=slug, number=number
|
|
141
|
+
),
|
|
142
|
+
token,
|
|
143
|
+
None,
|
|
144
|
+
)
|
|
145
|
+
if not isinstance(document, Mapping):
|
|
146
|
+
raise GitHubError(str(document))
|
|
147
|
+
return document
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_review_threads(slug: str, number: int, token: str) -> tuple[ReviewThread, ...]:
|
|
151
|
+
"""Read the review threads on a pull request.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
slug: The repository as ``owner/name``.
|
|
155
|
+
number: The pull request number.
|
|
156
|
+
token: The GitHub token the request authenticates with.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Every review thread on the pull request.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
GitHubError: Both routes failed, or the GraphQL answer carried no
|
|
163
|
+
review threads.
|
|
164
|
+
"""
|
|
165
|
+
all_records = _thread_records_over_rest(slug, number, token)
|
|
166
|
+
all_comment_records_by_id: Mapping[object, Mapping[str, object]] = {}
|
|
167
|
+
if all_records is None:
|
|
168
|
+
all_records = _thread_records_over_graphql(slug, number, token)
|
|
169
|
+
else:
|
|
170
|
+
all_comment_records_by_id = read_review_comments_by_id(slug, number, token)
|
|
171
|
+
return tuple(
|
|
172
|
+
parse_thread(each_record, all_comment_records_by_id)
|
|
173
|
+
for each_record in all_records
|
|
174
|
+
if isinstance(each_record, Mapping)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def read_review_comments_by_id(
|
|
179
|
+
slug: str, number: int, token: str
|
|
180
|
+
) -> dict[object, Mapping[str, object]]:
|
|
181
|
+
"""Read every review comment on a pull request, keyed by identifier.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
slug: The repository as ``owner/name``.
|
|
185
|
+
number: The pull request number.
|
|
186
|
+
token: The GitHub token the request authenticates with.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Each review comment under its identifier.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
GitHubError: A page answered with something other than a list.
|
|
193
|
+
"""
|
|
194
|
+
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)
|
|
197
|
+
all_records.extend(page)
|
|
198
|
+
if len(page) < REVIEW_COMMENT_PAGE_SIZE:
|
|
199
|
+
break
|
|
200
|
+
return comment_records_by_id(all_records)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _review_comment_page(slug: str, number: int, token: str, page: int) -> list[object]:
|
|
204
|
+
document = request_json(
|
|
205
|
+
GET_METHOD,
|
|
206
|
+
REVIEW_COMMENTS_ENDPOINT_TEMPLATE.format(
|
|
207
|
+
api_root=GITHUB_API_ROOT,
|
|
208
|
+
slug=slug,
|
|
209
|
+
number=number,
|
|
210
|
+
page_size=REVIEW_COMMENT_PAGE_SIZE,
|
|
211
|
+
page=page,
|
|
212
|
+
),
|
|
213
|
+
token,
|
|
214
|
+
None,
|
|
215
|
+
)
|
|
216
|
+
if not isinstance(document, list):
|
|
217
|
+
raise GitHubError(str(document))
|
|
218
|
+
return document
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def read_approvals_conclusion(slug: str, sha: str, token: str) -> str | None:
|
|
222
|
+
"""Read what the Claude Approvals check reports on one commit.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
slug: The repository as ``owner/name``.
|
|
226
|
+
sha: The head commit.
|
|
227
|
+
token: The GitHub token the request authenticates with.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
That check run's conclusion, or None where it does not run.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
GitHubError: GitHub answered with an unexpected shape.
|
|
234
|
+
"""
|
|
235
|
+
document = request_json(
|
|
236
|
+
GET_METHOD,
|
|
237
|
+
CHECK_RUNS_ENDPOINT_TEMPLATE.format(
|
|
238
|
+
api_root=GITHUB_API_ROOT,
|
|
239
|
+
slug=slug,
|
|
240
|
+
sha=sha,
|
|
241
|
+
page_size=CHECK_RUN_PAGE_SIZE,
|
|
242
|
+
),
|
|
243
|
+
token,
|
|
244
|
+
None,
|
|
245
|
+
)
|
|
246
|
+
return approvals_conclusion(_check_run_records(document))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _check_run_records(document: object) -> list[object]:
|
|
250
|
+
if not isinstance(document, Mapping):
|
|
251
|
+
raise GitHubError(str(document))
|
|
252
|
+
all_check_runs = document.get(CHECK_RUNS_KEY)
|
|
253
|
+
if not isinstance(all_check_runs, list):
|
|
254
|
+
raise GitHubError(str(document))
|
|
255
|
+
return all_check_runs
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _built_request(
|
|
259
|
+
method: str,
|
|
260
|
+
url: str,
|
|
261
|
+
token: str,
|
|
262
|
+
all_payload_fields: Mapping[str, object] | None,
|
|
263
|
+
) -> urllib.request.Request:
|
|
264
|
+
return urllib.request.Request(
|
|
265
|
+
url=url,
|
|
266
|
+
data=_encoded_body(all_payload_fields),
|
|
267
|
+
headers=_all_request_headers(token, all_payload_fields),
|
|
268
|
+
method=method,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _all_request_headers(
|
|
273
|
+
token: str, all_payload_fields: Mapping[str, object] | None
|
|
274
|
+
) -> dict[str, str]:
|
|
275
|
+
all_headers = {
|
|
276
|
+
ACCEPT_HEADER: GITHUB_ACCEPT_TYPE,
|
|
277
|
+
API_VERSION_HEADER: GITHUB_API_VERSION,
|
|
278
|
+
AUTHORIZATION_HEADER: BEARER_PREFIX + token,
|
|
279
|
+
}
|
|
280
|
+
if all_payload_fields is not None:
|
|
281
|
+
all_headers[CONTENT_TYPE_HEADER] = JSON_CONTENT_TYPE
|
|
282
|
+
return all_headers
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _encoded_body(all_payload_fields: Mapping[str, object] | None) -> bytes | None:
|
|
286
|
+
if all_payload_fields is None:
|
|
287
|
+
return None
|
|
288
|
+
return json.dumps(all_payload_fields).encode(UTF8_ENCODING)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _decoded_answer(url: str, status: int, body: bytes) -> object:
|
|
292
|
+
if status != HTTP_OK:
|
|
293
|
+
raise GitHubError(REQUEST_FAILED_TEMPLATE.format(url=url, status=status))
|
|
294
|
+
return json.loads(body.decode(UTF8_ENCODING))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _thread_records_over_rest(
|
|
298
|
+
slug: str, number: int, token: str
|
|
299
|
+
) -> list[object] | None:
|
|
300
|
+
try:
|
|
301
|
+
document = request_json(
|
|
302
|
+
GET_METHOD,
|
|
303
|
+
REVIEW_THREADS_ENDPOINT_TEMPLATE.format(
|
|
304
|
+
api_root=GITHUB_API_ROOT, slug=slug, number=number
|
|
305
|
+
),
|
|
306
|
+
token,
|
|
307
|
+
None,
|
|
308
|
+
)
|
|
309
|
+
except GitHubError:
|
|
310
|
+
return None
|
|
311
|
+
return document if isinstance(document, list) else None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _thread_records_over_graphql(slug: str, number: int, token: str) -> list[object]:
|
|
315
|
+
document = request_json(
|
|
316
|
+
POST_METHOD,
|
|
317
|
+
GITHUB_GRAPHQL_ENDPOINT,
|
|
318
|
+
token,
|
|
319
|
+
_thread_query_payload(slug, number),
|
|
320
|
+
)
|
|
321
|
+
all_records = functools.reduce(
|
|
322
|
+
functools.partial(_field_at, document), ALL_THREAD_NODE_KEYS, document
|
|
323
|
+
)
|
|
324
|
+
if not isinstance(all_records, list):
|
|
325
|
+
raise GitHubError(str(document))
|
|
326
|
+
return all_records
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _thread_query_payload(slug: str, number: int) -> dict[str, object]:
|
|
330
|
+
owner, _, name = slug.partition(SLUG_SEPARATOR)
|
|
331
|
+
return {
|
|
332
|
+
QUERY_KEY: REVIEW_THREAD_QUERY,
|
|
333
|
+
VARIABLES_KEY: {
|
|
334
|
+
OWNER_VARIABLE: owner,
|
|
335
|
+
NAME_VARIABLE: name,
|
|
336
|
+
NUMBER_VARIABLE: number,
|
|
337
|
+
PAGE_SIZE_VARIABLE: REVIEW_THREAD_PAGE_SIZE,
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _field_at(document: object, all_fields: object, key: str) -> object:
|
|
343
|
+
if not isinstance(all_fields, Mapping):
|
|
344
|
+
raise GitHubError(str(document))
|
|
345
|
+
return all_fields.get(key)
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Decide whether a review finding on a pull request head is answered.
|
|
2
|
+
|
|
3
|
+
A review bot posts a finding, the agent driving the pull request answers it or
|
|
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.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
thread: a bot comment, nothing after it -> open
|
|
10
|
+
thread: a bot comment, then an agent reply -> closed
|
|
11
|
+
thread: a bot comment, code since replaced -> closed
|
|
12
|
+
thread: a red circle, resolved in silence -> open
|
|
13
|
+
|
|
14
|
+
A red circle marks a finding a review states as blocking, so resolution alone
|
|
15
|
+
leaves it open. The reply says what changed, or why the finding stands.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from dev_env_scripts_constants.review_closure_constants import (
|
|
24
|
+
ALL_BLOCKING_CONCLUSIONS,
|
|
25
|
+
ALL_COMMENT_AUTHOR_KEYS,
|
|
26
|
+
ALL_COMMENT_LIST_KEYS,
|
|
27
|
+
ALL_OUTDATED_KEYS,
|
|
28
|
+
ALL_RESOLVED_KEYS,
|
|
29
|
+
APPROVALS_CHECK_NAME,
|
|
30
|
+
APPROVALS_OPEN_REASON,
|
|
31
|
+
AUTHOR_LOGIN_KEY,
|
|
32
|
+
CHECK_RUN_CONCLUSION_KEY,
|
|
33
|
+
CHECK_RUN_NAME_KEY,
|
|
34
|
+
CLOSED_DETAIL,
|
|
35
|
+
CLOSED_VERDICT_LABEL,
|
|
36
|
+
COMMENT_BODY_KEY,
|
|
37
|
+
COMMENT_IDENTIFIER_KEY,
|
|
38
|
+
COMMENT_IDS_KEY,
|
|
39
|
+
COMMENT_NODES_KEY,
|
|
40
|
+
HEAD_KEY,
|
|
41
|
+
NUMBER_KEY,
|
|
42
|
+
OPEN_DETAIL_TEMPLATE,
|
|
43
|
+
OPEN_VERDICT_LABEL,
|
|
44
|
+
RED_CIRCLE_MARKER,
|
|
45
|
+
RED_CIRCLE_OPEN_REASON,
|
|
46
|
+
SHA_KEY,
|
|
47
|
+
SHORT_SHA_LENGTH,
|
|
48
|
+
THREAD_PATH_KEY,
|
|
49
|
+
UNANSWERED_OPEN_REASON,
|
|
50
|
+
UNNAMED_THREAD_SUBJECT,
|
|
51
|
+
USER_KEY,
|
|
52
|
+
VERDICT_LINE_TEMPLATE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ReviewComment:
|
|
58
|
+
"""One comment inside a review thread."""
|
|
59
|
+
|
|
60
|
+
author_login: str
|
|
61
|
+
body: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class ReviewThread:
|
|
66
|
+
"""One review thread, as the closure decision reads it."""
|
|
67
|
+
|
|
68
|
+
subject: str
|
|
69
|
+
is_resolved: bool
|
|
70
|
+
is_outdated: bool
|
|
71
|
+
all_comments: tuple[ReviewComment, ...]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class OpenFinding:
|
|
76
|
+
"""One finding that still waits on the agent driving the pull request."""
|
|
77
|
+
|
|
78
|
+
subject: str
|
|
79
|
+
reason: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def carries_red_circle(thread: ReviewThread) -> bool:
|
|
83
|
+
"""Say whether a thread opens with a blocking finding.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
thread: The review thread to read.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True when its first comment carries the red circle a review marks a
|
|
90
|
+
blocking finding with.
|
|
91
|
+
"""
|
|
92
|
+
if not thread.all_comments:
|
|
93
|
+
return False
|
|
94
|
+
return RED_CIRCLE_MARKER in thread.all_comments[0].body
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def has_driver_reply(thread: ReviewThread, all_driver_logins: frozenset[str]) -> bool:
|
|
98
|
+
"""Say whether the driving agent answered a thread.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
thread: The review thread to read.
|
|
102
|
+
all_driver_logins: The logins whose comments count as the answer.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
True when any comment after the first carries one of those logins.
|
|
106
|
+
"""
|
|
107
|
+
return any(
|
|
108
|
+
each_comment.author_login in all_driver_logins
|
|
109
|
+
for each_comment in thread.all_comments[1:]
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def opened_by_driver(thread: ReviewThread, all_driver_logins: frozenset[str]) -> bool:
|
|
114
|
+
"""Say whether the driving agent started a thread itself.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
thread: The review thread to read.
|
|
118
|
+
all_driver_logins: The logins that count as the driving agent.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True when its first comment carries one of those logins.
|
|
122
|
+
"""
|
|
123
|
+
return bool(thread.all_comments) and (
|
|
124
|
+
thread.all_comments[0].author_login in all_driver_logins
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def thread_finding(
|
|
129
|
+
thread: ReviewThread, all_driver_logins: frozenset[str]
|
|
130
|
+
) -> OpenFinding | None:
|
|
131
|
+
"""Decide whether one review thread still waits on the driving agent.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
thread: The review thread to judge.
|
|
135
|
+
all_driver_logins: The logins whose comments count as the answer.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The finding this thread leaves open, or None when it is closed.
|
|
139
|
+
"""
|
|
140
|
+
if thread.is_outdated:
|
|
141
|
+
return None
|
|
142
|
+
if opened_by_driver(thread, all_driver_logins):
|
|
143
|
+
return None
|
|
144
|
+
if has_driver_reply(thread, all_driver_logins):
|
|
145
|
+
return None
|
|
146
|
+
if carries_red_circle(thread):
|
|
147
|
+
return OpenFinding(subject=thread.subject, reason=RED_CIRCLE_OPEN_REASON)
|
|
148
|
+
if thread.is_resolved:
|
|
149
|
+
return None
|
|
150
|
+
return OpenFinding(subject=thread.subject, reason=UNANSWERED_OPEN_REASON)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def all_open_findings(
|
|
154
|
+
all_threads: Iterable[ReviewThread],
|
|
155
|
+
all_driver_logins: frozenset[str],
|
|
156
|
+
approvals_conclusion_text: str | None,
|
|
157
|
+
) -> tuple[OpenFinding, ...]:
|
|
158
|
+
"""Collect every finding on a pull request head that waits on its agent.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
all_threads: The review threads on the pull request.
|
|
162
|
+
all_driver_logins: The logins whose comments count as the answer.
|
|
163
|
+
approvals_conclusion_text: What the Claude Approvals check run on the
|
|
164
|
+
head concluded, or None where that check does not run.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
One finding per open review thread, then the approvals finding when
|
|
168
|
+
that check reports a blocking row on this commit.
|
|
169
|
+
"""
|
|
170
|
+
all_findings = [
|
|
171
|
+
each_finding
|
|
172
|
+
for each_finding in (
|
|
173
|
+
thread_finding(each_thread, all_driver_logins)
|
|
174
|
+
for each_thread in all_threads
|
|
175
|
+
)
|
|
176
|
+
if each_finding is not None
|
|
177
|
+
]
|
|
178
|
+
if approvals_conclusion_text in ALL_BLOCKING_CONCLUSIONS:
|
|
179
|
+
all_findings.append(
|
|
180
|
+
OpenFinding(subject=APPROVALS_CHECK_NAME, reason=APPROVALS_OPEN_REASON)
|
|
181
|
+
)
|
|
182
|
+
return tuple(all_findings)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def head_sha(all_pull_request_fields: Mapping[str, object]) -> str:
|
|
186
|
+
"""Read the head commit a pull request reports.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
all_pull_request_fields: The pull request as GitHub reports it.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The head commit, or an empty string when the field is absent.
|
|
193
|
+
"""
|
|
194
|
+
all_head_fields = all_pull_request_fields.get(HEAD_KEY)
|
|
195
|
+
if isinstance(all_head_fields, Mapping):
|
|
196
|
+
return str(all_head_fields.get(SHA_KEY) or "")
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def verdict_line(
|
|
201
|
+
slug: str,
|
|
202
|
+
all_pull_request_fields: Mapping[str, object],
|
|
203
|
+
all_findings: Sequence[OpenFinding],
|
|
204
|
+
) -> str:
|
|
205
|
+
"""Build the one line the command prints for a pull request.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
slug: The repository as ``owner/name``.
|
|
209
|
+
all_pull_request_fields: The pull request the verdict describes.
|
|
210
|
+
all_findings: The findings that still wait on the driving agent.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
A line naming the verdict, the pull request, its head commit, and
|
|
214
|
+
either the closed detail or how many findings wait.
|
|
215
|
+
"""
|
|
216
|
+
return VERDICT_LINE_TEMPLATE.format(
|
|
217
|
+
label=OPEN_VERDICT_LABEL if all_findings else CLOSED_VERDICT_LABEL,
|
|
218
|
+
slug=slug,
|
|
219
|
+
number=all_pull_request_fields.get(NUMBER_KEY),
|
|
220
|
+
sha=head_sha(all_pull_request_fields)[:SHORT_SHA_LENGTH],
|
|
221
|
+
detail=_verdict_detail(all_findings),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _verdict_detail(all_findings: Sequence[OpenFinding]) -> str:
|
|
226
|
+
if not all_findings:
|
|
227
|
+
return CLOSED_DETAIL
|
|
228
|
+
return OPEN_DETAIL_TEMPLATE.format(count=len(all_findings))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def driver_logins(
|
|
232
|
+
all_pull_request_fields: Mapping[str, object],
|
|
233
|
+
all_extra_logins: Iterable[str],
|
|
234
|
+
) -> frozenset[str]:
|
|
235
|
+
"""Collect the logins whose comments answer a finding.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
all_pull_request_fields: The pull request, carrying the account that
|
|
239
|
+
opened it.
|
|
240
|
+
all_extra_logins: Logins named on the command line.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The account that opened the pull request, with every extra login.
|
|
244
|
+
"""
|
|
245
|
+
all_logins = {each_login for each_login in all_extra_logins if each_login}
|
|
246
|
+
all_author_fields = all_pull_request_fields.get(USER_KEY)
|
|
247
|
+
if isinstance(all_author_fields, Mapping):
|
|
248
|
+
author_login = all_author_fields.get(AUTHOR_LOGIN_KEY)
|
|
249
|
+
if author_login:
|
|
250
|
+
all_logins.add(str(author_login))
|
|
251
|
+
return frozenset(all_logins)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def parse_thread(
|
|
255
|
+
all_thread_fields: Mapping[str, object],
|
|
256
|
+
all_comment_records_by_id: Mapping[object, Mapping[str, object]] | None = None,
|
|
257
|
+
) -> ReviewThread:
|
|
258
|
+
"""Read one review thread from either route's answer.
|
|
259
|
+
|
|
260
|
+
The GraphQL answer nests each thread's comments. The session route names
|
|
261
|
+
them by identifier instead, so that route's caller passes the comments it
|
|
262
|
+
read for the pull request and this joins the two.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
all_thread_fields: The thread as the REST route or the GraphQL query
|
|
266
|
+
reports it.
|
|
267
|
+
all_comment_records_by_id: The pull request's review comments, keyed
|
|
268
|
+
by identifier, for a thread that names its comments by identifier.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The thread in the shape the closure decision reads.
|
|
272
|
+
"""
|
|
273
|
+
return ReviewThread(
|
|
274
|
+
subject=str(all_thread_fields.get(THREAD_PATH_KEY) or UNNAMED_THREAD_SUBJECT),
|
|
275
|
+
is_resolved=_any_flag(all_thread_fields, ALL_RESOLVED_KEYS),
|
|
276
|
+
is_outdated=_any_flag(all_thread_fields, ALL_OUTDATED_KEYS),
|
|
277
|
+
all_comments=tuple(
|
|
278
|
+
_parse_comment(each_record)
|
|
279
|
+
for each_record in _comment_records(
|
|
280
|
+
all_thread_fields, all_comment_records_by_id or {}
|
|
281
|
+
)
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def comment_records_by_id(
|
|
287
|
+
all_comment_records: Iterable[object],
|
|
288
|
+
) -> dict[object, Mapping[str, object]]:
|
|
289
|
+
"""Key a pull request's review comments by their identifiers.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
all_comment_records: The review comments GitHub reports.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Each comment under its identifier.
|
|
296
|
+
"""
|
|
297
|
+
return {
|
|
298
|
+
each_record[COMMENT_IDENTIFIER_KEY]: each_record
|
|
299
|
+
for each_record in all_comment_records
|
|
300
|
+
if isinstance(each_record, Mapping) and COMMENT_IDENTIFIER_KEY in each_record
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def approvals_conclusion(all_check_runs: Iterable[object]) -> str | None:
|
|
305
|
+
"""Find what the Claude Approvals check reports on a commit.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
all_check_runs: The check runs GitHub reports for the head commit.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
That check run's conclusion, or None where the repository does not
|
|
312
|
+
run it and where it has not concluded.
|
|
313
|
+
"""
|
|
314
|
+
for each_run in all_check_runs:
|
|
315
|
+
if (
|
|
316
|
+
isinstance(each_run, Mapping)
|
|
317
|
+
and each_run.get(CHECK_RUN_NAME_KEY) == APPROVALS_CHECK_NAME
|
|
318
|
+
):
|
|
319
|
+
conclusion = each_run.get(CHECK_RUN_CONCLUSION_KEY)
|
|
320
|
+
return None if conclusion is None else str(conclusion)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _comment_records(
|
|
325
|
+
all_thread_fields: Mapping[str, object],
|
|
326
|
+
all_comment_records_by_id: Mapping[object, Mapping[str, object]],
|
|
327
|
+
) -> list[Mapping[str, object]]:
|
|
328
|
+
all_identifiers = all_thread_fields.get(COMMENT_IDS_KEY)
|
|
329
|
+
if isinstance(all_identifiers, list):
|
|
330
|
+
return [
|
|
331
|
+
all_comment_records_by_id[each_identifier]
|
|
332
|
+
for each_identifier in all_identifiers
|
|
333
|
+
if each_identifier in all_comment_records_by_id
|
|
334
|
+
]
|
|
335
|
+
for each_key in ALL_COMMENT_LIST_KEYS:
|
|
336
|
+
found = all_thread_fields.get(each_key)
|
|
337
|
+
if isinstance(found, Mapping):
|
|
338
|
+
found = found.get(COMMENT_NODES_KEY)
|
|
339
|
+
if isinstance(found, list):
|
|
340
|
+
return [each for each in found if isinstance(each, Mapping)]
|
|
341
|
+
return []
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _parse_comment(all_comment_fields: Mapping[str, object]) -> ReviewComment:
|
|
345
|
+
return ReviewComment(
|
|
346
|
+
author_login=_comment_author_login(all_comment_fields),
|
|
347
|
+
body=str(all_comment_fields.get(COMMENT_BODY_KEY) or ""),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _comment_author_login(all_comment_fields: Mapping[str, object]) -> str:
|
|
352
|
+
for each_key in ALL_COMMENT_AUTHOR_KEYS:
|
|
353
|
+
all_author_fields = all_comment_fields.get(each_key)
|
|
354
|
+
if isinstance(all_author_fields, Mapping):
|
|
355
|
+
return str(all_author_fields.get(AUTHOR_LOGIN_KEY) or "")
|
|
356
|
+
return ""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _any_flag(all_fields: Mapping[str, object], all_keys: Sequence[str]) -> bool:
|
|
360
|
+
return any(all_fields.get(each_key) for each_key in all_keys)
|