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.
- package/.agents/skills/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +213 -0
- package/.agents/skills/session-continuity/SKILL.md +125 -0
- package/.agents/skills/session-continuity/continuity.mjs +447 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +88 -0
- package/bin/install-session-continuity.test.mjs +55 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- 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
|