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,392 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
|
|
10
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
12
|
+
|
|
13
|
+
from pr_verification.github import (
|
|
14
|
+
GitHubApi,
|
|
15
|
+
GitHubAppAuthenticator,
|
|
16
|
+
GitHubError,
|
|
17
|
+
HttpReply,
|
|
18
|
+
)
|
|
19
|
+
from pr_verification.model import RepositorySettings, StatusState
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RecordingRequester:
|
|
23
|
+
def __init__(self, all_replies: list[HttpReply]) -> None:
|
|
24
|
+
self.all_replies = all_replies
|
|
25
|
+
self.all_requests: list[tuple[str, str, dict[str, str], bytes | None]] = []
|
|
26
|
+
|
|
27
|
+
def __call__(
|
|
28
|
+
self,
|
|
29
|
+
method: str,
|
|
30
|
+
url: str,
|
|
31
|
+
headers: dict[str, str],
|
|
32
|
+
body: bytes | None,
|
|
33
|
+
) -> HttpReply:
|
|
34
|
+
self.all_requests.append((method, url, headers, body))
|
|
35
|
+
return self.all_replies.pop(0)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def repository_settings() -> RepositorySettings:
|
|
39
|
+
return RepositorySettings(
|
|
40
|
+
slug="owner/repository",
|
|
41
|
+
clone_url="https://github.test/owner/repository.git",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pull_payload(
|
|
46
|
+
head_sha: str = "head",
|
|
47
|
+
base_ref: str = "main",
|
|
48
|
+
) -> dict[str, object]:
|
|
49
|
+
return {
|
|
50
|
+
"number": 7,
|
|
51
|
+
"draft": True,
|
|
52
|
+
"merge_commit_sha": "merge",
|
|
53
|
+
"base": {"ref": base_ref, "sha": "base"},
|
|
54
|
+
"head": {"sha": head_sha},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def branch_payload(branch_sha: str = "current-base") -> dict[str, object]:
|
|
59
|
+
return {
|
|
60
|
+
"ref": "refs/heads/main",
|
|
61
|
+
"object": {"sha": branch_sha, "type": "commit"},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _issue_repository_api(
|
|
66
|
+
tmp_path: Path,
|
|
67
|
+
requester: RecordingRequester,
|
|
68
|
+
all_claims: list[dict[str, int | str]],
|
|
69
|
+
) -> GitHubApi:
|
|
70
|
+
private_key_path = tmp_path / "app.pem"
|
|
71
|
+
private_key_path.write_text("private-key", encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
def encode_jwt(
|
|
74
|
+
claims: dict[str, int | str], private_key: str, algorithm: str
|
|
75
|
+
) -> str:
|
|
76
|
+
all_claims.append(claims)
|
|
77
|
+
assert private_key == "private-key"
|
|
78
|
+
assert algorithm == "RS256"
|
|
79
|
+
return "app-jwt"
|
|
80
|
+
|
|
81
|
+
authenticator = GitHubAppAuthenticator(
|
|
82
|
+
"https://api.github.test",
|
|
83
|
+
42,
|
|
84
|
+
84,
|
|
85
|
+
private_key_path,
|
|
86
|
+
requester=requester,
|
|
87
|
+
jwt_encoder=encode_jwt,
|
|
88
|
+
clock=lambda: 1000.0,
|
|
89
|
+
)
|
|
90
|
+
return authenticator.issue_repository_api(repository_settings())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_lists_draft_pull_requests() -> None:
|
|
94
|
+
requester = RecordingRequester(
|
|
95
|
+
[HttpReply(200, {}, json.dumps([pull_payload()]).encode("utf-8"))]
|
|
96
|
+
)
|
|
97
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
98
|
+
|
|
99
|
+
all_candidates = github.list_open_candidates(repository_settings())
|
|
100
|
+
|
|
101
|
+
assert len(all_candidates) == 1
|
|
102
|
+
assert all_candidates[0].is_draft is True
|
|
103
|
+
assert "state=open" in requester.all_requests[0][1]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_reads_merge_commit_parents_in_order() -> None:
|
|
107
|
+
requester = RecordingRequester(
|
|
108
|
+
[
|
|
109
|
+
HttpReply(
|
|
110
|
+
200,
|
|
111
|
+
{},
|
|
112
|
+
json.dumps(
|
|
113
|
+
{"sha": "merge", "parents": [{"sha": "base"}, {"sha": "head"}]}
|
|
114
|
+
).encode("utf-8"),
|
|
115
|
+
)
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
119
|
+
|
|
120
|
+
all_parent_shas = github.get_commit_parents(repository_settings(), "merge")
|
|
121
|
+
|
|
122
|
+
assert all_parent_shas == ("base", "head")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_posts_status_to_recognized_merge_sha() -> None:
|
|
126
|
+
requester = RecordingRequester([HttpReply(201, {}, b"{}")])
|
|
127
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
128
|
+
|
|
129
|
+
github.post_status(
|
|
130
|
+
repository_settings(),
|
|
131
|
+
"merge",
|
|
132
|
+
StatusState.SUCCESS,
|
|
133
|
+
"local/verify",
|
|
134
|
+
"All required checks passed",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
method, url, headers, body = requester.all_requests[0]
|
|
138
|
+
assert method == "POST"
|
|
139
|
+
assert url.endswith("/repos/owner/repository/statuses/merge")
|
|
140
|
+
assert headers["Authorization"] == "Bearer installation-token"
|
|
141
|
+
assert json.loads(body or b"{}")["context"] == "local/verify"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_adds_and_removes_one_label_without_replacing_other_labels() -> None:
|
|
145
|
+
requester = RecordingRequester(
|
|
146
|
+
[
|
|
147
|
+
HttpReply(200, {}, b"[]"),
|
|
148
|
+
HttpReply(200, {}, json.dumps([{"name": "local-checks:passed"}]).encode()),
|
|
149
|
+
HttpReply(204, {}, b""),
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
153
|
+
|
|
154
|
+
github.add_label(repository_settings(), 7, "local-checks:passed")
|
|
155
|
+
github.remove_label(repository_settings(), 7, "local-checks:passed")
|
|
156
|
+
|
|
157
|
+
add_method, add_url, _, add_body = requester.all_requests[0]
|
|
158
|
+
read_method, read_url, _, read_body = requester.all_requests[1]
|
|
159
|
+
remove_method, remove_url, _, remove_body = requester.all_requests[2]
|
|
160
|
+
assert add_method == "POST"
|
|
161
|
+
assert add_url.endswith("/repos/owner/repository/issues/7/labels")
|
|
162
|
+
assert json.loads(add_body or b"{}") == {"labels": ["local-checks:passed"]}
|
|
163
|
+
assert read_method == "GET"
|
|
164
|
+
assert read_url.endswith(
|
|
165
|
+
"/repos/owner/repository/issues/7/labels?per_page=100&page=1"
|
|
166
|
+
)
|
|
167
|
+
assert read_body is None
|
|
168
|
+
assert remove_method == "DELETE"
|
|
169
|
+
assert remove_url.endswith(
|
|
170
|
+
"/repos/owner/repository/issues/7/labels/local-checks%3Apassed"
|
|
171
|
+
)
|
|
172
|
+
assert remove_body is None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_removes_label_when_github_returns_ok() -> None:
|
|
176
|
+
requester = RecordingRequester(
|
|
177
|
+
[
|
|
178
|
+
HttpReply(200, {}, json.dumps([{"name": "local-checks:passed"}]).encode()),
|
|
179
|
+
HttpReply(200, {}, b"{}"),
|
|
180
|
+
]
|
|
181
|
+
)
|
|
182
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
183
|
+
|
|
184
|
+
github.remove_label(repository_settings(), 7, "local-checks:passed")
|
|
185
|
+
|
|
186
|
+
assert requester.all_requests[1][0] == "DELETE"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_ignores_label_that_is_already_absent() -> None:
|
|
190
|
+
requester = RecordingRequester([HttpReply(200, {}, b"[]")])
|
|
191
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
192
|
+
|
|
193
|
+
github.remove_label(repository_settings(), 7, "local-checks:passed")
|
|
194
|
+
|
|
195
|
+
assert len(requester.all_requests) == 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_raises_when_label_delete_is_forbidden() -> None:
|
|
199
|
+
requester = RecordingRequester(
|
|
200
|
+
[
|
|
201
|
+
HttpReply(200, {}, json.dumps([{"name": "local-checks:passed"}]).encode()),
|
|
202
|
+
HttpReply(403, {}, b"{}"),
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
github.remove_label(repository_settings(), 7, "local-checks:passed")
|
|
209
|
+
except GitHubError as error:
|
|
210
|
+
assert "403" in str(error)
|
|
211
|
+
else:
|
|
212
|
+
raise AssertionError("Expected forbidden label deletion to fail")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_app_token_is_scoped_to_one_repository_and_required_permissions(
|
|
216
|
+
tmp_path: Path,
|
|
217
|
+
) -> None:
|
|
218
|
+
requester = RecordingRequester(
|
|
219
|
+
[
|
|
220
|
+
HttpReply(
|
|
221
|
+
201, {}, json.dumps({"token": "installation-token"}).encode("utf-8")
|
|
222
|
+
)
|
|
223
|
+
]
|
|
224
|
+
)
|
|
225
|
+
all_claims: list[dict[str, int | str]] = []
|
|
226
|
+
github = _issue_repository_api(tmp_path, requester, all_claims)
|
|
227
|
+
|
|
228
|
+
request_body = json.loads(requester.all_requests[0][3] or b"{}")
|
|
229
|
+
assert github.installation_token == "installation-token"
|
|
230
|
+
assert request_body == {
|
|
231
|
+
"repositories": ["repository"],
|
|
232
|
+
"permissions": {
|
|
233
|
+
"contents": "read",
|
|
234
|
+
"pull_requests": "read",
|
|
235
|
+
"statuses": "write",
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
assert all_claims == [{"iss": "42", "iat": 940, "exp": 1540}]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_app_token_opt_in_adds_issue_write_permission(tmp_path: Path) -> None:
|
|
242
|
+
private_key_path = tmp_path / "app.pem"
|
|
243
|
+
private_key_path.write_text("private-key", encoding="utf-8")
|
|
244
|
+
requester = RecordingRequester(
|
|
245
|
+
[HttpReply(201, {}, json.dumps({"token": "token"}).encode("utf-8"))]
|
|
246
|
+
)
|
|
247
|
+
authenticator = GitHubAppAuthenticator(
|
|
248
|
+
"https://api.github.test",
|
|
249
|
+
42,
|
|
250
|
+
84,
|
|
251
|
+
private_key_path,
|
|
252
|
+
requester=requester,
|
|
253
|
+
jwt_encoder=lambda claims, private_key, algorithm: "app-jwt",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
authenticator.issue_repository_api(
|
|
257
|
+
repository_settings(), should_write_issue_labels=True
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
request_body = json.loads(requester.all_requests[0][3] or b"{}")
|
|
261
|
+
assert request_body == {
|
|
262
|
+
"repositories": ["repository"],
|
|
263
|
+
"permissions": {
|
|
264
|
+
"contents": "read",
|
|
265
|
+
"pull_requests": "write",
|
|
266
|
+
"statuses": "write",
|
|
267
|
+
"issues": "write",
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_ignores_pull_until_github_recognizes_merge_sha() -> None:
|
|
273
|
+
unrecognized_pull = pull_payload()
|
|
274
|
+
unrecognized_pull["merge_commit_sha"] = None
|
|
275
|
+
requester = RecordingRequester(
|
|
276
|
+
[HttpReply(200, {}, json.dumps([unrecognized_pull]).encode("utf-8"))]
|
|
277
|
+
)
|
|
278
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
279
|
+
|
|
280
|
+
all_candidates = github.list_open_candidates(repository_settings())
|
|
281
|
+
|
|
282
|
+
assert all_candidates == ()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_lists_pull_without_merge_sha_for_advisory_reads() -> None:
|
|
286
|
+
unrecognized_pull = pull_payload()
|
|
287
|
+
unrecognized_pull["merge_commit_sha"] = None
|
|
288
|
+
requester = RecordingRequester(
|
|
289
|
+
[
|
|
290
|
+
HttpReply(200, {}, json.dumps([unrecognized_pull]).encode("utf-8")),
|
|
291
|
+
HttpReply(200, {}, json.dumps(branch_payload()).encode("utf-8")),
|
|
292
|
+
]
|
|
293
|
+
)
|
|
294
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
295
|
+
|
|
296
|
+
all_candidates = github.list_open_candidates(
|
|
297
|
+
repository_settings(), should_require_merge_commit=False
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
assert len(all_candidates) == 1
|
|
301
|
+
assert all_candidates[0].merge_sha == ""
|
|
302
|
+
assert all_candidates[0].base_sha == "current-base"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_get_candidate_uses_current_branch_sha_for_advisory_reads() -> None:
|
|
306
|
+
requester = RecordingRequester(
|
|
307
|
+
[
|
|
308
|
+
HttpReply(
|
|
309
|
+
200,
|
|
310
|
+
{},
|
|
311
|
+
json.dumps(pull_payload(base_ref="release/#7")).encode("utf-8"),
|
|
312
|
+
),
|
|
313
|
+
HttpReply(
|
|
314
|
+
200,
|
|
315
|
+
{},
|
|
316
|
+
json.dumps(branch_payload("new-base")).encode("utf-8"),
|
|
317
|
+
),
|
|
318
|
+
]
|
|
319
|
+
)
|
|
320
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
321
|
+
|
|
322
|
+
candidate = github.get_candidate(
|
|
323
|
+
repository_settings(),
|
|
324
|
+
7,
|
|
325
|
+
should_require_merge_commit=False,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
assert candidate.base_sha == "new-base"
|
|
329
|
+
assert requester.all_requests[1][1].endswith(
|
|
330
|
+
"/repos/owner/repository/git/ref/heads/release/%237"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_list_resolves_each_unique_base_ref_once() -> None:
|
|
335
|
+
second_pull = pull_payload("second-head")
|
|
336
|
+
second_pull["number"] = 8
|
|
337
|
+
requester = RecordingRequester(
|
|
338
|
+
[
|
|
339
|
+
HttpReply(
|
|
340
|
+
200,
|
|
341
|
+
{},
|
|
342
|
+
json.dumps([pull_payload(), second_pull]).encode("utf-8"),
|
|
343
|
+
),
|
|
344
|
+
HttpReply(200, {}, json.dumps(branch_payload()).encode("utf-8")),
|
|
345
|
+
]
|
|
346
|
+
)
|
|
347
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
348
|
+
|
|
349
|
+
all_candidates = github.list_open_candidates(
|
|
350
|
+
repository_settings(),
|
|
351
|
+
should_require_merge_commit=False,
|
|
352
|
+
)
|
|
353
|
+
all_branch_requests = [
|
|
354
|
+
each_request
|
|
355
|
+
for each_request in requester.all_requests
|
|
356
|
+
if "/git/ref/heads/" in each_request[1]
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
assert [each_candidate.base_sha for each_candidate in all_candidates] == [
|
|
360
|
+
"current-base",
|
|
361
|
+
"current-base",
|
|
362
|
+
]
|
|
363
|
+
assert len(all_branch_requests) == 1
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def test_advisory_candidate_propagates_branch_read_failure() -> None:
|
|
367
|
+
requester = RecordingRequester(
|
|
368
|
+
[
|
|
369
|
+
HttpReply(200, {}, json.dumps(pull_payload()).encode("utf-8")),
|
|
370
|
+
HttpReply(500, {}, b"{}"),
|
|
371
|
+
]
|
|
372
|
+
)
|
|
373
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
374
|
+
|
|
375
|
+
with pytest.raises(GitHubError, match="500"):
|
|
376
|
+
github.get_candidate(
|
|
377
|
+
repository_settings(),
|
|
378
|
+
7,
|
|
379
|
+
should_require_merge_commit=False,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_strict_candidate_keeps_pull_base_without_branch_read() -> None:
|
|
384
|
+
requester = RecordingRequester(
|
|
385
|
+
[HttpReply(200, {}, json.dumps(pull_payload()).encode("utf-8"))]
|
|
386
|
+
)
|
|
387
|
+
github = GitHubApi("https://api.github.test", "installation-token", requester)
|
|
388
|
+
|
|
389
|
+
candidate = github.get_candidate(repository_settings(), 7)
|
|
390
|
+
|
|
391
|
+
assert candidate.base_sha == "base"
|
|
392
|
+
assert len(requester.all_requests) == 1
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1]
|
|
12
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
14
|
+
|
|
15
|
+
from pr_verification import lock as lock_module
|
|
16
|
+
from pr_verification.lock import SupervisorLock, SupervisorLockError
|
|
17
|
+
|
|
18
|
+
LOCK_CONTENDER_SCRIPT = """
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
scripts_directory = Path(sys.argv[1])
|
|
24
|
+
cache_root = Path(sys.argv[2])
|
|
25
|
+
result_path = Path(sys.argv[3])
|
|
26
|
+
start_path = Path(sys.argv[4])
|
|
27
|
+
sys.path.insert(0, str(scripts_directory))
|
|
28
|
+
|
|
29
|
+
from pr_verification.lock import SupervisorLock, SupervisorLockError
|
|
30
|
+
|
|
31
|
+
while not start_path.exists():
|
|
32
|
+
time.sleep(0.01)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with SupervisorLock(cache_root):
|
|
36
|
+
result_path.write_text("acquired", encoding="utf-8")
|
|
37
|
+
while True:
|
|
38
|
+
time.sleep(1)
|
|
39
|
+
except SupervisorLockError:
|
|
40
|
+
result_path.write_text("contended", encoding="utf-8")
|
|
41
|
+
"""
|
|
42
|
+
RESULT_WAIT_SECONDS = 5
|
|
43
|
+
PROCESS_WAIT_SECONDS = 10
|
|
44
|
+
POLL_INTERVAL_SECONDS = 0.01
|
|
45
|
+
LEGACY_LOCK_CONTENT = "legacy owner metadata"
|
|
46
|
+
BASE_PYTHON_EXECUTABLE = cast(str, getattr(sys, "_base_executable", sys.executable))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _start_lock_contender(
|
|
50
|
+
cache_root: Path, result_path: Path, start_path: Path
|
|
51
|
+
) -> subprocess.Popen[str]:
|
|
52
|
+
return subprocess.Popen(
|
|
53
|
+
[
|
|
54
|
+
BASE_PYTHON_EXECUTABLE,
|
|
55
|
+
"-c",
|
|
56
|
+
LOCK_CONTENDER_SCRIPT,
|
|
57
|
+
str(SCRIPTS_DIRECTORY),
|
|
58
|
+
str(cache_root),
|
|
59
|
+
str(result_path),
|
|
60
|
+
str(start_path),
|
|
61
|
+
],
|
|
62
|
+
stdout=subprocess.PIPE,
|
|
63
|
+
stderr=subprocess.PIPE,
|
|
64
|
+
text=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _wait_for_lock_outcomes(all_result_paths: list[Path]) -> list[str]:
|
|
69
|
+
deadline = time.monotonic() + RESULT_WAIT_SECONDS
|
|
70
|
+
while time.monotonic() < deadline:
|
|
71
|
+
if all(each_path.is_file() for each_path in all_result_paths):
|
|
72
|
+
return [
|
|
73
|
+
each_path.read_text(encoding="utf-8") for each_path in all_result_paths
|
|
74
|
+
]
|
|
75
|
+
time.sleep(POLL_INTERVAL_SECONDS)
|
|
76
|
+
raise AssertionError("lock contenders did not report within five seconds")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _terminate_owned_child(child_process: subprocess.Popen[str]) -> None:
|
|
80
|
+
if child_process.poll() is None:
|
|
81
|
+
child_process.terminate()
|
|
82
|
+
child_process.wait(timeout=PROCESS_WAIT_SECONDS)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _start_simultaneous_lock_contenders(
|
|
86
|
+
cache_root: Path, all_result_paths: list[Path], start_path: Path
|
|
87
|
+
) -> list[subprocess.Popen[str]]:
|
|
88
|
+
all_child_processes = [
|
|
89
|
+
_start_lock_contender(cache_root, each_result_path, start_path)
|
|
90
|
+
for each_result_path in all_result_paths
|
|
91
|
+
]
|
|
92
|
+
start_path.touch()
|
|
93
|
+
return all_child_processes
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _stop_initial_lock_processes(
|
|
97
|
+
all_child_processes: list[subprocess.Popen[str]], all_outcomes: list[str]
|
|
98
|
+
) -> None:
|
|
99
|
+
contender_index = all_outcomes.index("contended")
|
|
100
|
+
all_child_processes[contender_index].wait(timeout=PROCESS_WAIT_SECONDS)
|
|
101
|
+
owner_index = all_outcomes.index("acquired")
|
|
102
|
+
_terminate_owned_child(all_child_processes[owner_index])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_second_supervisor_cannot_own_same_cache_root(tmp_path: Path) -> None:
|
|
106
|
+
first_lock = SupervisorLock(tmp_path)
|
|
107
|
+
second_lock = SupervisorLock(tmp_path)
|
|
108
|
+
|
|
109
|
+
with first_lock, pytest.raises(SupervisorLockError):
|
|
110
|
+
second_lock.acquire()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_process_lock_survives_contention_and_owner_termination(
|
|
114
|
+
tmp_path: Path,
|
|
115
|
+
) -> None:
|
|
116
|
+
lock_path = tmp_path / "supervisor.lock"
|
|
117
|
+
lock_path.write_text(LEGACY_LOCK_CONTENT, encoding="utf-8")
|
|
118
|
+
start_path = tmp_path / "start"
|
|
119
|
+
all_result_paths = [tmp_path / "first.result", tmp_path / "second.result"]
|
|
120
|
+
all_child_processes = _start_simultaneous_lock_contenders(
|
|
121
|
+
tmp_path, all_result_paths, start_path
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
all_outcomes = _wait_for_lock_outcomes(all_result_paths)
|
|
125
|
+
assert sorted(all_outcomes) == ["acquired", "contended"]
|
|
126
|
+
_stop_initial_lock_processes(all_child_processes, all_outcomes)
|
|
127
|
+
|
|
128
|
+
recovery_result_path = tmp_path / "recovery.result"
|
|
129
|
+
recovery_process = _start_lock_contender(
|
|
130
|
+
tmp_path, recovery_result_path, start_path
|
|
131
|
+
)
|
|
132
|
+
all_child_processes.append(recovery_process)
|
|
133
|
+
assert _wait_for_lock_outcomes([recovery_result_path]) == ["acquired"]
|
|
134
|
+
assert lock_path.is_file()
|
|
135
|
+
_terminate_owned_child(recovery_process)
|
|
136
|
+
assert lock_path.read_text(encoding="utf-8") == LEGACY_LOCK_CONTENT
|
|
137
|
+
finally:
|
|
138
|
+
for each_child_process in all_child_processes:
|
|
139
|
+
_terminate_owned_child(each_child_process)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_lock_io_error_is_presented_as_supervisor_lock_error(
|
|
143
|
+
tmp_path: Path,
|
|
144
|
+
) -> None:
|
|
145
|
+
(tmp_path / "supervisor.lock").mkdir()
|
|
146
|
+
|
|
147
|
+
with pytest.raises(
|
|
148
|
+
SupervisorLockError,
|
|
149
|
+
match="Verification supervisor lock file is unavailable",
|
|
150
|
+
) as raised_error:
|
|
151
|
+
SupervisorLock(tmp_path).acquire()
|
|
152
|
+
|
|
153
|
+
assert isinstance(raised_error.value.__cause__, OSError)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_contention_error_survives_a_failing_descriptor_close(
|
|
157
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
158
|
+
) -> None:
|
|
159
|
+
def close_fails(file_descriptor: int) -> None:
|
|
160
|
+
raise OSError("close failed")
|
|
161
|
+
|
|
162
|
+
first_lock = SupervisorLock(tmp_path)
|
|
163
|
+
with first_lock:
|
|
164
|
+
monkeypatch.setattr(lock_module.os, "close", close_fails)
|
|
165
|
+
with pytest.raises(SupervisorLockError) as raised:
|
|
166
|
+
SupervisorLock(tmp_path).acquire()
|
|
167
|
+
monkeypatch.undo()
|
|
168
|
+
assert str(raised.value) == lock_module.SUPERVISOR_LOCK_ERROR
|