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.
Files changed (102) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
  3. package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
  4. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
  5. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  6. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  7. package/bin/install.test.mjs +25 -0
  8. package/docs/high-trust-agent-delivery.md +132 -0
  9. package/hooks/advisory/refactor_guard.py +2 -2
  10. package/hooks/advisory/test_refactor_guard.py +21 -0
  11. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  12. package/hooks/blocking/code_rules_shared.py +4 -7
  13. package/hooks/blocking/conftest.py +22 -0
  14. package/hooks/blocking/state_description_blocker.py +10 -8
  15. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  16. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  17. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  18. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  19. package/hooks/blocking/test_code_rules_shared.py +18 -0
  20. package/hooks/blocking/test_conftest.py +35 -0
  21. package/hooks/blocking/test_state_description_blocker.py +16 -0
  22. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  23. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  24. package/hooks/git-hooks/test_config.py +10 -3
  25. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  26. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  27. package/hooks/git-hooks/verification_notice_state.py +3 -1
  28. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  29. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  30. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  31. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  32. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  33. package/hooks/validators/exempt_paths.py +3 -18
  34. package/hooks/validators/test_exempt_paths.py +20 -0
  35. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  36. package/package.json +1 -1
  37. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  38. package/rules/archiving-agent-config.md +10 -0
  39. package/rules/code-standards.md +1 -0
  40. package/rules/correction-lens.md +134 -0
  41. package/rules/flag-non-breaking-findings.md +11 -0
  42. package/rules/no-contrast-framing.md +68 -0
  43. package/rules/review-closure-is-a-check.md +44 -0
  44. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  45. package/scripts/agent_merge_check.py +381 -0
  46. package/scripts/check.ps1 +12 -1
  47. package/scripts/ci/install-playtest.mjs +291 -0
  48. package/scripts/ci/install-playtest.test.mjs +84 -0
  49. package/scripts/ci/installer-lifecycle.mjs +11 -30
  50. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  51. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  52. package/scripts/ci/scratch-home-install.mjs +73 -0
  53. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  54. package/scripts/codex_compat_materializer.py +33 -16
  55. package/scripts/contrast_framing.py +101 -0
  56. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  57. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  58. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  59. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  60. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  61. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  62. package/scripts/durable_post_lint.py +30 -4
  63. package/scripts/followup_cli.py +6 -10
  64. package/scripts/merge_gate_checks.py +236 -0
  65. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  66. package/scripts/policy_lint/adapters.py +30 -0
  67. package/scripts/policy_lint/config/constants.py +2 -0
  68. package/scripts/policy_lint/registry.py +5 -0
  69. package/scripts/repository_checks/config/constants.py +12 -0
  70. package/scripts/repository_checks/config/policy_document.py +28 -2
  71. package/scripts/repository_checks/followups.py +54 -0
  72. package/scripts/repository_checks/match_exemptions.py +67 -0
  73. package/scripts/repository_checks/models.py +46 -2
  74. package/scripts/repository_checks/reporting.py +23 -7
  75. package/scripts/repository_checks/tracked_secrets.py +2 -2
  76. package/scripts/repository_policy.py +4 -2
  77. package/scripts/review_closure.py +134 -0
  78. package/scripts/review_closure_github.py +345 -0
  79. package/scripts/review_closure_model.py +360 -0
  80. package/scripts/test_agent_merge_check.py +317 -0
  81. package/scripts/test_durable_post_lint.py +46 -0
  82. package/scripts/test_followup_cli.py +40 -0
  83. package/scripts/test_merge_gate_checks.py +92 -0
  84. package/scripts/tests/test_code_review_constants.py +78 -0
  85. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  86. package/scripts/tests/test_contrast_framing.py +117 -0
  87. package/scripts/tests/test_followups.py +88 -0
  88. package/scripts/tests/test_match_exemptions.py +291 -0
  89. package/scripts/tests/test_models.py +67 -2
  90. package/scripts/tests/test_package_inventory.py +6 -4
  91. package/scripts/tests/test_pester_suites_run.py +53 -0
  92. package/scripts/tests/test_policy_document.py +39 -1
  93. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  94. package/scripts/tests/test_reporting.py +18 -0
  95. package/scripts/tests/test_repository_policy.py +43 -1
  96. package/scripts/tests/test_review_closure.py +126 -0
  97. package/scripts/tests/test_review_closure_github.py +240 -0
  98. package/scripts/tests/test_review_closure_model.py +299 -0
  99. package/scripts/tests/test_tracked_secrets.py +42 -0
  100. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  101. package/scripts/repository_checks/email_exemptions.py +0 -39
  102. 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)