claude-dev-env 7.0.0 → 7.2.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 (210) hide show
  1. package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
  2. package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
  3. package/.agents/skills/session-continuity/AGENTS.md +1 -0
  4. package/.agents/skills/session-continuity/CLAUDE.md +1 -0
  5. package/.agents/skills/session-continuity/README.md +213 -0
  6. package/.agents/skills/session-continuity/SKILL.md +125 -0
  7. package/.agents/skills/session-continuity/continuity.mjs +447 -0
  8. package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
  9. package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
  10. package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
  11. package/bin/cde.mjs +36 -8
  12. package/bin/cde.test.mjs +48 -0
  13. package/bin/ever-shipped-skills.mjs +2 -20
  14. package/bin/ever-shipped-skills.test.mjs +8 -0
  15. package/bin/expand_home_directory_tokens.mjs +4 -5
  16. package/bin/expand_home_directory_tokens.test.mjs +128 -0
  17. package/bin/git_hooks_installer.mjs +22 -3
  18. package/bin/git_hooks_installer.test.mjs +159 -5
  19. package/bin/install-session-continuity.mjs +88 -0
  20. package/bin/install-session-continuity.test.mjs +55 -0
  21. package/bin/install.codex-rules.test.mjs +5 -2
  22. package/bin/install.cursor-rules.test.mjs +5 -2
  23. package/bin/install.mjs +478 -63
  24. package/bin/install.policy-lint.test.mjs +56 -4
  25. package/bin/install.profiles.test.mjs +10 -0
  26. package/bin/install.prune.test.mjs +227 -17
  27. package/bin/install.test.mjs +500 -120
  28. package/bin/install.transaction.test.mjs +3 -3
  29. package/docs/CODE_RULES.md +1 -1
  30. package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
  31. package/hooks/blocking/precommit_code_rules_gate.py +5 -8
  32. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
  33. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
  34. package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
  35. package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
  36. package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
  37. package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
  38. package/hooks/git-hooks/post_commit.py +33 -0
  39. package/hooks/git-hooks/pre_commit.py +18 -90
  40. package/hooks/git-hooks/pre_push.py +45 -541
  41. package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
  42. package/hooks/git-hooks/test_native_hook_support.py +129 -0
  43. package/hooks/git-hooks/test_native_policy_commit.py +39 -6
  44. package/hooks/git-hooks/test_post_commit.py +43 -0
  45. package/hooks/git-hooks/test_pre_commit.py +36 -392
  46. package/hooks/git-hooks/test_pre_push.py +48 -1631
  47. package/hooks/git-hooks/test_verification_notice.py +60 -0
  48. package/hooks/git-hooks/test_verification_start.py +173 -0
  49. package/hooks/git-hooks/verification_notice.py +192 -0
  50. package/hooks/git-hooks/verification_notice_context.py +253 -0
  51. package/hooks/git-hooks/verification_notice_state.py +237 -0
  52. package/hooks/git-hooks/verification_start.py +100 -0
  53. package/hooks/hooks.json +0 -119
  54. package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
  55. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
  56. package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
  57. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
  58. package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
  59. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
  60. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
  61. package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
  62. package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
  63. package/hooks/validators/run_all_validators.py +5 -1
  64. package/hooks/validators/security_checks.py +57 -2
  65. package/hooks/validators/test_hook_timing_harness.py +2 -5
  66. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
  67. package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
  68. package/hooks/validators/test_security_checks.py +57 -0
  69. package/package.json +1 -1
  70. package/rules/confirm-implementation-forks.md +2 -0
  71. package/rules/git-workflow.md +12 -0
  72. package/rules/pstack-host-mapping.md +48 -0
  73. package/rules/vault-context.md +3 -5
  74. package/rules/verify-runtime-state.md +7 -0
  75. package/scripts/audit-hooks-cli.mjs +5 -0
  76. package/scripts/audit-hooks.mjs +72 -16
  77. package/scripts/automatic_advisory/__init__.py +1 -0
  78. package/scripts/automatic_advisory/checkout.py +51 -0
  79. package/scripts/automatic_advisory/cli.py +334 -0
  80. package/scripts/automatic_advisory/config/__init__.py +1 -0
  81. package/scripts/automatic_advisory/config/constants.py +93 -0
  82. package/scripts/automatic_advisory/config/timing.py +4 -0
  83. package/scripts/automatic_advisory/configuration.py +197 -0
  84. package/scripts/automatic_advisory/execution.py +350 -0
  85. package/scripts/automatic_advisory/git.py +69 -0
  86. package/scripts/automatic_advisory/model.py +107 -0
  87. package/scripts/automatic_advisory/process_host.py +32 -0
  88. package/scripts/automatic_advisory/publisher.py +84 -0
  89. package/scripts/automatic_advisory/runner.py +399 -0
  90. package/scripts/automatic_advisory/state.py +116 -0
  91. package/scripts/automatic_advisory/window_flags.py +41 -0
  92. package/scripts/automatic_advisory/windows_job.py +122 -0
  93. package/scripts/codex_compat_materializer.py +99 -13
  94. package/scripts/durable_post_lint.py +30 -2
  95. package/scripts/durable_post_lint_config/config/constants.py +10 -0
  96. package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
  97. package/scripts/local_report_cli.py +184 -0
  98. package/scripts/local_report_core.py +247 -0
  99. package/scripts/local_report_publisher.py +12 -0
  100. package/scripts/local_report_validation.py +357 -0
  101. package/scripts/local_verification/__init__.py +23 -0
  102. package/scripts/local_verification/check_runner.py +364 -0
  103. package/scripts/local_verification/check_support.py +99 -0
  104. package/scripts/local_verification/cli.py +158 -0
  105. package/scripts/local_verification/command_runner.py +84 -0
  106. package/scripts/local_verification/config/__init__.py +1 -0
  107. package/scripts/local_verification/config/constants.py +89 -0
  108. package/scripts/local_verification/config/timing.py +1 -0
  109. package/scripts/local_verification/git_state.py +146 -0
  110. package/scripts/local_verification/manifest.py +224 -0
  111. package/scripts/local_verification/model.py +174 -0
  112. package/scripts/local_verification/runner.py +234 -0
  113. package/scripts/policy_lint/adapter_pairing.py +58 -3
  114. package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
  115. package/scripts/pr_verification/__init__.py +1 -0
  116. package/scripts/pr_verification/branch_refs.py +114 -0
  117. package/scripts/pr_verification/config/__init__.py +1 -0
  118. package/scripts/pr_verification/config/constants.py +84 -0
  119. package/scripts/pr_verification/config/timing.py +5 -0
  120. package/scripts/pr_verification/github.py +137 -0
  121. package/scripts/pr_verification/github_api.py +376 -0
  122. package/scripts/pr_verification/github_labels.py +88 -0
  123. package/scripts/pr_verification/github_parsing.py +182 -0
  124. package/scripts/pr_verification/github_transport.py +91 -0
  125. package/scripts/pr_verification/lock.py +122 -0
  126. package/scripts/pr_verification/model.py +43 -0
  127. package/scripts/pr_verification/requirements.txt +1 -0
  128. package/scripts/test_durable_post_lint.py +97 -0
  129. package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
  130. package/scripts/tests/test-audit-hooks.mjs +94 -2
  131. package/scripts/tests/test_automatic_advisory.py +376 -0
  132. package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
  133. package/scripts/tests/test_automatic_advisory_polling.py +216 -0
  134. package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
  135. package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
  136. package/scripts/tests/test_automatic_advisory_settings.py +48 -0
  137. package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
  138. package/scripts/tests/test_closed_pr_label.py +157 -0
  139. package/scripts/tests/test_codex_compat_materializer.py +189 -6
  140. package/scripts/tests/test_github_labels.py +101 -0
  141. package/scripts/tests/test_github_transport.py +125 -0
  142. package/scripts/tests/test_local_report_publisher.py +396 -0
  143. package/scripts/tests/test_local_report_publisher_cli.py +85 -0
  144. package/scripts/tests/test_local_verification.py +348 -0
  145. package/scripts/tests/test_local_verification_cli.py +98 -0
  146. package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
  147. package/scripts/tests/test_pr_verification_github.py +392 -0
  148. package/scripts/tests/test_pr_verification_lock.py +168 -0
  149. package/scripts/tests/test_verification_notice.py +347 -0
  150. package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
  151. package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
  152. package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
  153. package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
  154. package/hooks/blocking/destructive_command_blocker.py +0 -2073
  155. package/hooks/blocking/fable_spawn_gate.py +0 -187
  156. package/hooks/blocking/luna_fast_mode_gate.py +0 -160
  157. package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
  158. package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
  159. package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
  160. package/hooks/blocking/pii_prevention_blocker.py +0 -365
  161. package/hooks/blocking/piped_pytest_blocker.py +0 -1223
  162. package/hooks/blocking/question_to_user_enforcer.py +0 -130
  163. package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
  164. package/hooks/blocking/sensitive_file_protector.py +0 -167
  165. package/hooks/blocking/session_edit_stage_gate.py +0 -684
  166. package/hooks/blocking/session_handoff_blocker.py +0 -182
  167. package/hooks/blocking/shell_substitution_blocker.py +0 -350
  168. package/hooks/blocking/test_ask_user_question_shape.py +0 -69
  169. package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
  170. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
  171. package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
  172. package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
  173. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
  174. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
  175. package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
  176. package/hooks/blocking/test_fable_spawn_gate.py +0 -381
  177. package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
  178. package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
  179. package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
  180. package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
  181. package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
  182. package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
  183. package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
  184. package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
  185. package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
  186. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
  187. package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
  188. package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
  189. package/hooks/blocking/test_sensitive_file_protector.py +0 -365
  190. package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
  191. package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
  192. package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
  193. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
  194. package/hooks/blocking/test_session_handoff_blocker.py +0 -318
  195. package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
  196. package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
  197. package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
  198. package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
  199. package/hooks/blocking/unscoped_search_blocker.py +0 -391
  200. package/hooks/blocking/write_existing_file_blocker.py +0 -124
  201. package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
  202. package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
  203. package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
  204. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
  205. package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
  206. package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
  207. package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
  208. package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
  209. package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
  210. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .branch_refs import (
6
+ GetJson,
7
+ _resolve_candidate_base_shas,
8
+ replace_candidate_base_sha,
9
+ )
10
+ from .config.constants import (
11
+ ACCEPT_HEADER,
12
+ API_VERSION_HEADER,
13
+ AUTHORIZATION_HEADER,
14
+ BEARER_PREFIX,
15
+ COMMIT_ENDPOINT_TEMPLATE,
16
+ COMMIT_PARENTS_KEY,
17
+ CONTENT_TYPE_HEADER,
18
+ GITHUB_ACCEPT_TYPE,
19
+ GITHUB_API_VERSION,
20
+ GITHUB_JSON_ERROR_TEMPLATE,
21
+ GITHUB_PAGE_SIZE,
22
+ GITHUB_REQUEST_ERROR_TEMPLATE,
23
+ GITHUB_SHAPE_ERROR_TEMPLATE,
24
+ HTTP_CREATED,
25
+ HTTP_NO_CONTENT,
26
+ HTTP_OK,
27
+ ISSUE_LABELS_ENDPOINT_TEMPLATE,
28
+ ISSUE_LABELS_KEY,
29
+ JSON_CONTENT_TYPE,
30
+ PULL_ENDPOINT_TEMPLATE,
31
+ PULL_SHA_KEY,
32
+ PULLS_ENDPOINT_TEMPLATE,
33
+ STATUS_CONTEXT_KEY,
34
+ STATUS_DESCRIPTION_KEY,
35
+ STATUS_ENDPOINT_TEMPLATE,
36
+ STATUS_STATE_KEY,
37
+ UTF8_ENCODING,
38
+ )
39
+ from .github_labels import DeleteLabel, ReadLabelsPage, remove_label_if_present
40
+ from .github_parsing import (
41
+ GitHubError,
42
+ _parse_pull_candidates,
43
+ _require_pull_list,
44
+ parse_candidate,
45
+ require_mapping,
46
+ require_text,
47
+ )
48
+ from .github_transport import HttpReply, HttpRequester, request_http
49
+ from .model import PullRequestCandidate, RepositorySettings, StatusState
50
+
51
+
52
+ def _build_pull_page_endpoint(repository: RepositorySettings, page: int) -> str:
53
+ return PULLS_ENDPOINT_TEMPLATE.format(
54
+ repository=repository.slug,
55
+ page_size=GITHUB_PAGE_SIZE,
56
+ page=page,
57
+ )
58
+
59
+
60
+ def _build_status_body(state: StatusState, context: str, description: str) -> bytes:
61
+ status_payload = {
62
+ STATUS_STATE_KEY: state.value,
63
+ STATUS_CONTEXT_KEY: context,
64
+ STATUS_DESCRIPTION_KEY: description,
65
+ }
66
+ return json.dumps(status_payload).encode(UTF8_ENCODING)
67
+
68
+
69
+ def _list_open_candidates(
70
+ get_json: GetJson,
71
+ repository: RepositorySettings,
72
+ should_require_merge_commit: bool,
73
+ ) -> tuple[PullRequestCandidate, ...]:
74
+ all_candidates: list[PullRequestCandidate] = []
75
+ page = 1
76
+ while True:
77
+ endpoint = _build_pull_page_endpoint(repository, page)
78
+ raw_page = _require_pull_list(get_json(endpoint))
79
+ all_candidates.extend(
80
+ _parse_pull_candidates(
81
+ repository.slug,
82
+ raw_page,
83
+ should_require_merge_commit,
84
+ )
85
+ )
86
+ if len(raw_page) < GITHUB_PAGE_SIZE:
87
+ return _resolve_candidate_base_shas(
88
+ all_candidates,
89
+ repository,
90
+ get_json,
91
+ should_require_merge_commit,
92
+ )
93
+ page += 1
94
+
95
+
96
+ class GitHubApi:
97
+ def __init__(
98
+ self,
99
+ api_url: str,
100
+ installation_token: str,
101
+ requester: HttpRequester = request_http,
102
+ ) -> None:
103
+ self.api_url = api_url
104
+ self.installation_token = installation_token
105
+ self.requester = requester
106
+
107
+ def list_open_candidates(
108
+ self,
109
+ repository: RepositorySettings,
110
+ *,
111
+ should_require_merge_commit: bool = True,
112
+ ) -> tuple[PullRequestCandidate, ...]:
113
+ """List recognized pull requests.
114
+
115
+ Args:
116
+ repository: Repository to query.
117
+
118
+ Returns:
119
+ Recognized pull requests.
120
+
121
+ Raises:
122
+ GitHubError: If GitHub data is invalid.
123
+ """
124
+ return _list_open_candidates(
125
+ self.get_json,
126
+ repository,
127
+ should_require_merge_commit,
128
+ )
129
+
130
+ def get_candidate(
131
+ self,
132
+ repository: RepositorySettings,
133
+ pull_request_number: int,
134
+ *,
135
+ should_require_merge_commit: bool = True,
136
+ ) -> PullRequestCandidate:
137
+ """Read one pull request.
138
+
139
+ Args:
140
+ repository: Repository to query.
141
+
142
+ Returns:
143
+ Pull request metadata.
144
+ """
145
+ endpoint = PULL_ENDPOINT_TEMPLATE.format(
146
+ repository=repository.slug,
147
+ pull_number=pull_request_number,
148
+ )
149
+ candidate = parse_candidate(
150
+ repository.slug,
151
+ self.get_json(endpoint),
152
+ should_require_merge_commit=should_require_merge_commit,
153
+ )
154
+ if should_require_merge_commit:
155
+ return candidate
156
+ return replace_candidate_base_sha(candidate, repository, self.get_json)
157
+
158
+ def get_commit_parents(
159
+ self, repository: RepositorySettings, commit_sha: str
160
+ ) -> tuple[str, ...]:
161
+ """Read commit parent SHAs.
162
+
163
+ Args:
164
+ repository: Repository to query.
165
+
166
+ Returns:
167
+ Parent commit SHAs.
168
+
169
+ Raises:
170
+ GitHubError: If GitHub data is invalid.
171
+ """
172
+ endpoint = COMMIT_ENDPOINT_TEMPLATE.format(
173
+ repository=repository.slug,
174
+ commit_sha=commit_sha,
175
+ )
176
+ commit_mapping = require_mapping(self.get_json(endpoint), "commit")
177
+ raw_parents = commit_mapping.get(COMMIT_PARENTS_KEY)
178
+ if not isinstance(raw_parents, list):
179
+ raise GitHubError(GITHUB_SHAPE_ERROR_TEMPLATE.format(resource="commit"))
180
+ return tuple(
181
+ require_text(require_mapping(each_parent, "commit"), PULL_SHA_KEY, "commit")
182
+ for each_parent in raw_parents
183
+ )
184
+
185
+ def post_status(
186
+ self,
187
+ repository: RepositorySettings,
188
+ commit_sha: str,
189
+ state: StatusState,
190
+ context: str,
191
+ description: str,
192
+ ) -> None:
193
+ """Post one advisory commit status.
194
+
195
+ Args:
196
+ commit_sha: Commit receiving the status.
197
+
198
+ Raises:
199
+ GitHubError: If GitHub rejects the request.
200
+ """
201
+ endpoint = STATUS_ENDPOINT_TEMPLATE.format(
202
+ repository=repository.slug,
203
+ commit_sha=commit_sha,
204
+ )
205
+ request_body = _build_status_body(state, context, description)
206
+ request_json(
207
+ self.requester,
208
+ "POST",
209
+ self.api_url + endpoint,
210
+ authorized_headers(self.installation_token),
211
+ request_body,
212
+ HTTP_CREATED,
213
+ )
214
+
215
+ def add_label(
216
+ self,
217
+ repository: RepositorySettings,
218
+ pull_request_number: int,
219
+ label: str,
220
+ ) -> None:
221
+ """Add one label to a pull request.
222
+
223
+ Args:
224
+ label: Label to add.
225
+
226
+ Raises:
227
+ GitHubError: If GitHub rejects the request.
228
+ """
229
+ endpoint = ISSUE_LABELS_ENDPOINT_TEMPLATE.format(
230
+ repository=repository.slug,
231
+ pull_number=pull_request_number,
232
+ )
233
+ request_body = json.dumps({ISSUE_LABELS_KEY: [label]}).encode(UTF8_ENCODING)
234
+ request_json(
235
+ self.requester,
236
+ "POST",
237
+ self.api_url + endpoint,
238
+ authorized_headers(self.installation_token),
239
+ request_body,
240
+ HTTP_OK,
241
+ )
242
+
243
+ def remove_label(
244
+ self,
245
+ repository: RepositorySettings,
246
+ pull_request_number: int,
247
+ label: str,
248
+ ) -> None:
249
+ """Remove one label from a pull request.
250
+
251
+ Args:
252
+ repository: Repository containing the pull request.
253
+ pull_request_number: Pull request number.
254
+ label: Label to remove.
255
+
256
+ Raises:
257
+ GitHubError: If GitHub rejects the request.
258
+ """
259
+ all_headers = authorized_headers(self.installation_token)
260
+ remove_label_if_present(
261
+ _labels_page_reader(self.requester, self.api_url, all_headers),
262
+ _label_deleter(self.requester, self.api_url, all_headers),
263
+ repository,
264
+ pull_request_number,
265
+ label,
266
+ )
267
+
268
+ def get_json(self, endpoint: str) -> object:
269
+ """Read one JSON resource.
270
+
271
+ Args:
272
+ endpoint: Repository API path.
273
+
274
+ Returns:
275
+ Decoded JSON payload.
276
+ """
277
+ return request_json(
278
+ self.requester,
279
+ "GET",
280
+ self.api_url + endpoint,
281
+ authorized_headers(self.installation_token),
282
+ None,
283
+ HTTP_OK,
284
+ )
285
+
286
+
287
+ def authorized_headers(token: str) -> dict[str, str]:
288
+ """Build headers for an installation token request.
289
+
290
+ Args:
291
+ token: Installation token.
292
+
293
+ Returns:
294
+ Request headers.
295
+ """
296
+ return {
297
+ AUTHORIZATION_HEADER: BEARER_PREFIX + token,
298
+ ACCEPT_HEADER: GITHUB_ACCEPT_TYPE,
299
+ CONTENT_TYPE_HEADER: JSON_CONTENT_TYPE,
300
+ API_VERSION_HEADER: GITHUB_API_VERSION,
301
+ }
302
+
303
+
304
+ def _labels_page_reader(
305
+ requester: HttpRequester, api_url: str, all_headers: dict[str, str]
306
+ ) -> ReadLabelsPage:
307
+ def read_labels_page(endpoint: str) -> object:
308
+ return request_json(
309
+ requester, "GET", api_url + endpoint, all_headers, None, HTTP_OK
310
+ )
311
+
312
+ return read_labels_page
313
+
314
+
315
+ def _label_deleter(
316
+ requester: HttpRequester, api_url: str, all_headers: dict[str, str]
317
+ ) -> DeleteLabel:
318
+ def delete_label(endpoint: str) -> None:
319
+ _delete_label(requester, api_url + endpoint, all_headers)
320
+
321
+ return delete_label
322
+
323
+
324
+ def _delete_label(
325
+ requester: HttpRequester, url: str, all_headers: dict[str, str]
326
+ ) -> None:
327
+ reply = requester("DELETE", url, all_headers, None)
328
+ if reply.status_code in (HTTP_OK, HTTP_NO_CONTENT):
329
+ return
330
+ raise GitHubError(
331
+ GITHUB_REQUEST_ERROR_TEMPLATE.format(
332
+ method="DELETE", url=url, status_code=reply.status_code
333
+ )
334
+ )
335
+
336
+
337
+ def _decode_json_reply(reply: HttpReply, url: str) -> object:
338
+ try:
339
+ return json.loads(reply.body.decode(UTF8_ENCODING))
340
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
341
+ raise GitHubError(GITHUB_JSON_ERROR_TEMPLATE.format(url=url)) from error
342
+
343
+
344
+ def _raise_unexpected_status(method: str, url: str, status_code: int) -> None:
345
+ raise GitHubError(
346
+ GITHUB_REQUEST_ERROR_TEMPLATE.format(
347
+ method=method, url=url, status_code=status_code
348
+ )
349
+ )
350
+
351
+
352
+ def request_json(
353
+ requester: HttpRequester,
354
+ method: str,
355
+ url: str,
356
+ all_headers: dict[str, str],
357
+ body: bytes | None,
358
+ expected_status: int,
359
+ ) -> object:
360
+ """Send a request and decode JSON.
361
+ Args:
362
+ requester: Request function.
363
+ method: HTTP method.
364
+ url: Request URL.
365
+ all_headers: Headers.
366
+ body: Optional body.
367
+ expected_status: Required status.
368
+ Returns:
369
+ Decoded JSON payload.
370
+ Raises:
371
+ GitHubError: If status or JSON is invalid.
372
+ """
373
+ reply = requester(method, url, all_headers, body)
374
+ if reply.status_code != expected_status:
375
+ _raise_unexpected_status(method, url, reply.status_code)
376
+ return _decode_json_reply(reply, url)
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import urllib.parse
4
+ from collections.abc import Callable
5
+
6
+ from .config.constants import (
7
+ GITHUB_PAGE_SIZE,
8
+ ISSUE_LABEL_ENDPOINT_TEMPLATE,
9
+ ISSUE_LABELS_PAGE_ENDPOINT_TEMPLATE,
10
+ )
11
+ from .model import RepositorySettings
12
+
13
+ ReadLabelsPage = Callable[[str], object]
14
+ DeleteLabel = Callable[[str], None]
15
+
16
+
17
+ def _label_is_present(all_labels: object, label: str) -> bool:
18
+ if not isinstance(all_labels, list):
19
+ return False
20
+ return any(
21
+ isinstance(each_label, dict) and each_label.get("name") == label
22
+ for each_label in all_labels
23
+ )
24
+
25
+
26
+ def _label_page_endpoint(
27
+ repository: RepositorySettings, pull_request_number: int, page: int
28
+ ) -> str:
29
+ return ISSUE_LABELS_PAGE_ENDPOINT_TEMPLATE.format(
30
+ repository=repository.slug,
31
+ pull_number=pull_request_number,
32
+ page_size=GITHUB_PAGE_SIZE,
33
+ page=page,
34
+ )
35
+
36
+
37
+ def _issue_label_endpoint(
38
+ repository: RepositorySettings, pull_request_number: int, label: str
39
+ ) -> str:
40
+ return ISSUE_LABEL_ENDPOINT_TEMPLATE.format(
41
+ repository=repository.slug,
42
+ pull_number=pull_request_number,
43
+ label=urllib.parse.quote(label, safe=""),
44
+ )
45
+
46
+
47
+ def _label_is_on_any_page(
48
+ read_labels_page: ReadLabelsPage,
49
+ repository: RepositorySettings,
50
+ pull_request_number: int,
51
+ label: str,
52
+ ) -> bool:
53
+ page = 1
54
+ while True:
55
+ raw_page = read_labels_page(
56
+ _label_page_endpoint(repository, pull_request_number, page)
57
+ )
58
+ if _label_is_present(raw_page, label):
59
+ return True
60
+ if not isinstance(raw_page, list) or len(raw_page) < GITHUB_PAGE_SIZE:
61
+ return False
62
+ page += 1
63
+
64
+
65
+ def remove_label_if_present(
66
+ read_labels_page: ReadLabelsPage,
67
+ delete_label: DeleteLabel,
68
+ repository: RepositorySettings,
69
+ pull_request_number: int,
70
+ label: str,
71
+ ) -> None:
72
+ """Delete one label from a pull request when a labels page still carries it.
73
+
74
+ Pages /issues/{n}/labels at per_page=100 until the label is found or a
75
+ short page ends the walk.
76
+
77
+ Args:
78
+ read_labels_page: Reads one labels endpoint and returns its parsed body.
79
+ delete_label: Deletes the label the endpoint names.
80
+ repository: Repository holding the pull request.
81
+ pull_request_number: Pull request number.
82
+ label: Label to remove.
83
+ """
84
+ if not _label_is_on_any_page(
85
+ read_labels_page, repository, pull_request_number, label
86
+ ):
87
+ return
88
+ delete_label(_issue_label_endpoint(repository, pull_request_number, label))
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ from .config.constants import (
6
+ GITHUB_SHAPE_ERROR_TEMPLATE,
7
+ PULL_BASE_KEY,
8
+ PULL_DRAFT_KEY,
9
+ PULL_HEAD_KEY,
10
+ PULL_LIST_RESOURCE_NAME,
11
+ PULL_MERGE_SHA_KEY,
12
+ PULL_NUMBER_KEY,
13
+ PULL_REF_KEY,
14
+ PULL_RESOURCE_NAME,
15
+ PULL_SHA_KEY,
16
+ )
17
+ from .model import PullRequestCandidate
18
+
19
+
20
+ class GitHubError(RuntimeError):
21
+ """Raised when GitHub metadata has an invalid shape."""
22
+
23
+
24
+ def _require_pull_list(all_pull_entries: object) -> list[object]:
25
+ if isinstance(all_pull_entries, list):
26
+ return all_pull_entries
27
+ raise GitHubError(
28
+ GITHUB_SHAPE_ERROR_TEMPLATE.format(resource=PULL_LIST_RESOURCE_NAME)
29
+ )
30
+
31
+
32
+ def _parse_pull_candidates(
33
+ repository_slug: str,
34
+ all_pull_entries: list[object],
35
+ should_require_merge_commit: bool,
36
+ ) -> tuple[PullRequestCandidate, ...]:
37
+ all_candidates: list[PullRequestCandidate] = []
38
+ for each_pull in all_pull_entries:
39
+ maybe_candidate = parse_recognized_candidate(
40
+ repository_slug,
41
+ each_pull,
42
+ should_require_merge_commit=should_require_merge_commit,
43
+ )
44
+ if maybe_candidate is not None:
45
+ all_candidates.append(maybe_candidate)
46
+ return tuple(all_candidates)
47
+
48
+
49
+ def _read_pull_branches(
50
+ all_pull_fields: Mapping[str, object],
51
+ ) -> tuple[Mapping[str, object], Mapping[str, object]]:
52
+ base = require_mapping(all_pull_fields.get(PULL_BASE_KEY), PULL_RESOURCE_NAME)
53
+ head = require_mapping(all_pull_fields.get(PULL_HEAD_KEY), PULL_RESOURCE_NAME)
54
+ return base, head
55
+
56
+
57
+ def _read_pull_draft(all_pull_fields: Mapping[str, object]) -> bool:
58
+ is_draft = all_pull_fields.get(PULL_DRAFT_KEY)
59
+ if isinstance(is_draft, bool):
60
+ return is_draft
61
+ raise GitHubError(GITHUB_SHAPE_ERROR_TEMPLATE.format(resource=PULL_RESOURCE_NAME))
62
+
63
+
64
+ def parse_candidate(
65
+ repository_slug: str,
66
+ raw_pull: object,
67
+ *,
68
+ should_require_merge_commit: bool,
69
+ ) -> PullRequestCandidate:
70
+ """Parse pull request metadata.
71
+
72
+ Args:
73
+ repository_slug: Repository slug.
74
+ raw_pull: GitHub pull JSON.
75
+
76
+ Returns:
77
+ Parsed pull request.
78
+
79
+ Raises:
80
+ GitHubError: If metadata is invalid.
81
+ """
82
+ pull = require_mapping(raw_pull, PULL_RESOURCE_NAME)
83
+ base, head = _read_pull_branches(pull)
84
+ pull_number = require_positive_integer(pull, PULL_NUMBER_KEY, PULL_RESOURCE_NAME)
85
+ return PullRequestCandidate(
86
+ repository_slug=repository_slug,
87
+ pull_request_number=pull_number,
88
+ base_ref=require_text(base, PULL_REF_KEY, PULL_RESOURCE_NAME),
89
+ base_sha=require_text(base, PULL_SHA_KEY, PULL_RESOURCE_NAME),
90
+ head_sha=require_text(head, PULL_SHA_KEY, PULL_RESOURCE_NAME),
91
+ merge_sha=_read_merge_sha(pull, should_require_merge_commit),
92
+ is_draft=_read_pull_draft(pull),
93
+ )
94
+
95
+
96
+ def _read_merge_sha(
97
+ all_pull_fields: Mapping[str, object],
98
+ should_require_merge_commit: bool,
99
+ ) -> str:
100
+ merge_sha = all_pull_fields.get(PULL_MERGE_SHA_KEY)
101
+ if merge_sha is None and not should_require_merge_commit:
102
+ return ""
103
+ if not isinstance(merge_sha, str) or not merge_sha:
104
+ raise GitHubError(
105
+ GITHUB_SHAPE_ERROR_TEMPLATE.format(resource=PULL_RESOURCE_NAME)
106
+ )
107
+ return merge_sha
108
+
109
+
110
+ def parse_recognized_candidate(
111
+ repository_slug: str,
112
+ raw_pull: object,
113
+ *,
114
+ should_require_merge_commit: bool = True,
115
+ ) -> PullRequestCandidate | None:
116
+ """Parse a pull request with a merge commit.
117
+
118
+ Args:
119
+ repository_slug: Repository slug.
120
+ raw_pull: GitHub pull JSON.
121
+
122
+ Returns:
123
+ Parsed pull request or None.
124
+ """
125
+ pull = require_mapping(raw_pull, PULL_RESOURCE_NAME)
126
+ if not should_require_merge_commit:
127
+ return parse_candidate(
128
+ repository_slug,
129
+ pull,
130
+ should_require_merge_commit=False,
131
+ )
132
+ if pull.get(PULL_MERGE_SHA_KEY) is None:
133
+ return None
134
+ return parse_candidate(repository_slug, pull, should_require_merge_commit=True)
135
+
136
+
137
+ def require_mapping(raw_payload: object, resource: str) -> Mapping[str, object]:
138
+ if not isinstance(raw_payload, Mapping):
139
+ raise GitHubError(GITHUB_SHAPE_ERROR_TEMPLATE.format(resource=resource))
140
+ return raw_payload
141
+
142
+
143
+ def require_text(
144
+ all_payload_fields: Mapping[str, object], field_name: str, resource: str
145
+ ) -> str:
146
+ """Read required text from a JSON object.
147
+
148
+ Args:
149
+ all_payload_fields: JSON object.
150
+
151
+ Returns:
152
+ Required text.
153
+
154
+ Raises:
155
+ GitHubError: If the field is absent.
156
+ """
157
+ field_text = all_payload_fields.get(field_name)
158
+ if not isinstance(field_text, str) or not field_text:
159
+ raise GitHubError(GITHUB_SHAPE_ERROR_TEMPLATE.format(resource=resource))
160
+ return field_text
161
+
162
+
163
+ def require_positive_integer(
164
+ all_payload_fields: Mapping[str, object], field_name: str, resource: str
165
+ ) -> int:
166
+ """Read a required positive integer.
167
+
168
+ Args:
169
+ all_payload_fields: JSON object.
170
+
171
+ Returns:
172
+ Required integer.
173
+
174
+ Raises:
175
+ GitHubError: If the field is invalid.
176
+ """
177
+ field_integer = all_payload_fields.get(field_name)
178
+ if isinstance(field_integer, bool) or not isinstance(field_integer, int):
179
+ raise GitHubError(GITHUB_SHAPE_ERROR_TEMPLATE.format(resource=resource))
180
+ if field_integer < 1:
181
+ raise GitHubError(GITHUB_SHAPE_ERROR_TEMPLATE.format(resource=resource))
182
+ return field_integer