claude-dev-env 4.1.0 → 4.3.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/_shared/pr-loop/scripts/durable_post_lint.py +242 -0
- package/.agents/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/durable_post_lint_constants.py +106 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint.py +332 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_durable_post_lint_constants.py +25 -0
- package/.agents/skills/everything-search/SKILL.md +18 -3
- package/.agents/skills/everything-search/scripts/everything_search.py +204 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/__init__.py +1 -0
- package/.agents/skills/everything-search/scripts/everything_search_command_constants/config/constants.py +11 -0
- package/.agents/skills/everything-search/scripts/test_everything_search.py +172 -0
- package/.agents/skills/issue-tracker/SKILL.md +6 -0
- package/.agents/skills/pull-request/SKILL.md +218 -0
- package/.agents/skills/pull-request/reference/publication-tasks.md +18 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/__init__.py +1 -0
- package/.agents/skills/pull-request/scripts/github_pr_command_constants/config/constants.py +61 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +276 -0
- package/.agents/skills/pull-request/scripts/recover_legacy_author.py +251 -0
- package/.agents/skills/pull-request/scripts/test_github_pr_command_constants.py +49 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +398 -0
- package/.agents/skills/pull-request/scripts/test_recover_legacy_author.py +221 -0
- package/.agents/skills/test-runner/SKILL.md +69 -0
- package/.agents/skills/test-runner/scripts/preflight_checks.py +365 -0
- package/.agents/skills/test-runner/scripts/run_tests.py +316 -0
- package/.agents/skills/test-runner/scripts/test_preflight_checks.py +185 -0
- package/.agents/skills/test-runner/scripts/test_run_tests.py +281 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/__init__.py +1 -0
- package/.agents/skills/test-runner/scripts/test_runner_constants/config/constants.py +96 -0
- package/.agents/skills-archived/pr-loop-cloud-transport/reference/identity-and-hooks.md +1 -1
- package/bin/ever-shipped-skills.mjs +2 -0
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/install.mjs +9 -1
- package/bin/install.prune.test.mjs +149 -0
- package/bin/install.test.mjs +24 -7
- package/hooks/blocking/pii_prevention_blocker.py +32 -51
- package/hooks/blocking/test_bash_post_call_dispatcher.py +5 -28
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -4
- package/hooks/blocking/test_code_rules_command_dispatch.py +2 -2
- package/hooks/blocking/test_pii_prevention_blocker.py +37 -89
- package/hooks/gh_post_body_texts.py +195 -0
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +5 -16
- package/hooks/hooks_constants/gh_post_body_texts_constants.py +12 -0
- package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +0 -7
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +7 -20
- package/hooks/hooks_constants/test_failure_recorder_constants.py +3 -3
- package/hooks/hooks_constants/test_gh_post_body_texts_constants.py +19 -0
- package/hooks/observability/test_failure_recorder.py +1 -1
- package/hooks/pyproject.toml +1 -1
- package/hooks/test_gh_post_body_texts.py +79 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +31 -20
- package/rules/filesystem-search.md +5 -5
- package/rules/gh-cli-conventions.md +23 -13
- package/rules/git-workflow.md +21 -8
- package/scripts/policy_lint/adapter_configuration.py +53 -12
- package/scripts/repository_checks/config/constants.py +0 -5
- package/scripts/tests/test_adapter_configuration.py +54 -0
- package/hooks/_gh_pr_author_swap_utils.py +0 -1225
- package/hooks/blocking/conventional_pr_title_gate.py +0 -443
- package/hooks/blocking/es_exe_path_rewriter.py +0 -159
- package/hooks/blocking/gh_body_arg_blocker.py +0 -176
- package/hooks/blocking/gh_pr_author_enforcer.py +0 -487
- package/hooks/blocking/gh_pr_author_restore.py +0 -105
- package/hooks/blocking/pr_description_writer_gate.py +0 -256
- package/hooks/blocking/test_conventional_pr_title_gate.py +0 -640
- package/hooks/blocking/test_es_exe_path_rewriter.py +0 -447
- package/hooks/blocking/test_gh_body_arg_blocker.py +0 -441
- package/hooks/blocking/test_gh_pr_author_enforcer.py +0 -1166
- package/hooks/blocking/test_gh_pr_author_restore.py +0 -513
- package/hooks/blocking/test_gh_pr_author_swap_utils.py +0 -910
- package/hooks/blocking/test_pr_description_writer_gate.py +0 -234
- package/hooks/blocking/test_preflight_check.py +0 -343
- package/hooks/blocking/test_test_preflight_check.py +0 -80
- package/hooks/blocking/test_volatile_path_in_post_blocker.py +0 -322
- package/hooks/blocking/volatile_path_in_post_blocker.py +0 -461
- package/hooks/hooks_constants/conventional_pr_title_gate_constants.py +0 -58
- package/hooks/hooks_constants/gh_pr_author_swap_constants.py +0 -76
- package/hooks/hooks_constants/path_rewriter_constants.py +0 -13
- package/hooks/hooks_constants/pr_description_writer_gate_constants.py +0 -73
- package/hooks/hooks_constants/test_path_rewriter_constants.py +0 -57
- package/hooks/hooks_constants/volatile_path_in_post_blocker_constants.py +0 -55
- package/hooks/observability/pr_description_writer_spawn_tracker.py +0 -113
- package/hooks/observability/test_pr_description_writer_spawn_tracker.py +0 -150
- package/hooks/session/gh_pr_author_session_cleanup.py +0 -175
- package/hooks/session/test_gh_pr_author_session_cleanup.py +0 -576
- package/hooks/test__gh_pr_author_swap_utils.py +0 -333
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Contract tests for process-local pull request publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import Mock
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
19
|
+
|
|
20
|
+
import pull_request
|
|
21
|
+
|
|
22
|
+
REPOSITORY = "jl-cmd/claude-dev-env"
|
|
23
|
+
PR_NUMBER = "2518"
|
|
24
|
+
PR_TITLE = "feat(skills): add guarded pull request publication"
|
|
25
|
+
ACCOUNT_A = "JonEcho"
|
|
26
|
+
ACCOUNT_B = "ReviewBot"
|
|
27
|
+
BODY_FILENAME = "pull-request-body.md"
|
|
28
|
+
LINTER_PATH = (
|
|
29
|
+
_SCRIPTS_DIRECTORY.parents[1] / "_shared/pr-loop/scripts/durable_post_lint.py"
|
|
30
|
+
)
|
|
31
|
+
COMMENT_CLI = ["comment", "--repo", REPOSITORY, "--number", PR_NUMBER, "--body-file"]
|
|
32
|
+
ACTION_CASES = [
|
|
33
|
+
(
|
|
34
|
+
[
|
|
35
|
+
"create",
|
|
36
|
+
"--repo",
|
|
37
|
+
REPOSITORY,
|
|
38
|
+
"--base",
|
|
39
|
+
"main",
|
|
40
|
+
"--head",
|
|
41
|
+
"feature/post",
|
|
42
|
+
"--title",
|
|
43
|
+
PR_TITLE,
|
|
44
|
+
"--body-file",
|
|
45
|
+
BODY_FILENAME,
|
|
46
|
+
],
|
|
47
|
+
"pr-create",
|
|
48
|
+
[
|
|
49
|
+
"gh",
|
|
50
|
+
"pr",
|
|
51
|
+
"create",
|
|
52
|
+
"--draft",
|
|
53
|
+
"--repo",
|
|
54
|
+
REPOSITORY,
|
|
55
|
+
"--base",
|
|
56
|
+
"main",
|
|
57
|
+
"--head",
|
|
58
|
+
"feature/post",
|
|
59
|
+
"--title",
|
|
60
|
+
PR_TITLE,
|
|
61
|
+
"--body-file",
|
|
62
|
+
BODY_FILENAME,
|
|
63
|
+
],
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
[
|
|
67
|
+
"edit",
|
|
68
|
+
"--repo",
|
|
69
|
+
REPOSITORY,
|
|
70
|
+
"--number",
|
|
71
|
+
PR_NUMBER,
|
|
72
|
+
"--title",
|
|
73
|
+
PR_TITLE,
|
|
74
|
+
"--body-file",
|
|
75
|
+
BODY_FILENAME,
|
|
76
|
+
],
|
|
77
|
+
"pr-edit",
|
|
78
|
+
[
|
|
79
|
+
"gh",
|
|
80
|
+
"pr",
|
|
81
|
+
"edit",
|
|
82
|
+
PR_NUMBER,
|
|
83
|
+
"--repo",
|
|
84
|
+
REPOSITORY,
|
|
85
|
+
"--title",
|
|
86
|
+
PR_TITLE,
|
|
87
|
+
"--body-file",
|
|
88
|
+
BODY_FILENAME,
|
|
89
|
+
],
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
[*COMMENT_CLI, BODY_FILENAME],
|
|
93
|
+
"pr-comment",
|
|
94
|
+
[
|
|
95
|
+
"gh",
|
|
96
|
+
"pr",
|
|
97
|
+
"comment",
|
|
98
|
+
PR_NUMBER,
|
|
99
|
+
"--repo",
|
|
100
|
+
REPOSITORY,
|
|
101
|
+
"--body-file",
|
|
102
|
+
BODY_FILENAME,
|
|
103
|
+
],
|
|
104
|
+
),
|
|
105
|
+
(
|
|
106
|
+
[
|
|
107
|
+
"review",
|
|
108
|
+
"--repo",
|
|
109
|
+
REPOSITORY,
|
|
110
|
+
"--number",
|
|
111
|
+
PR_NUMBER,
|
|
112
|
+
"--body-file",
|
|
113
|
+
BODY_FILENAME,
|
|
114
|
+
],
|
|
115
|
+
"pr-review",
|
|
116
|
+
[
|
|
117
|
+
"gh",
|
|
118
|
+
"pr",
|
|
119
|
+
"review",
|
|
120
|
+
PR_NUMBER,
|
|
121
|
+
"--repo",
|
|
122
|
+
REPOSITORY,
|
|
123
|
+
"--comment",
|
|
124
|
+
"--body-file",
|
|
125
|
+
BODY_FILENAME,
|
|
126
|
+
],
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _completion(
|
|
132
|
+
all_arguments: list[str], code: int = 0, stdout: str = "", stderr: str = ""
|
|
133
|
+
) -> subprocess.CompletedProcess[str]:
|
|
134
|
+
return subprocess.CompletedProcess(all_arguments, code, stdout, stderr)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.fixture
|
|
138
|
+
def body_file(tmp_path: Path) -> Path:
|
|
139
|
+
post_file = tmp_path / BODY_FILENAME
|
|
140
|
+
post_file.write_text("Durable body text.", encoding="utf-8")
|
|
141
|
+
return post_file
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _comment_arguments(body_file: Path) -> list[str]:
|
|
145
|
+
return [*COMMENT_CLI, str(body_file)]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _account_text(account_name: str) -> str:
|
|
149
|
+
return account_name[::-1]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _calls(command_runner: Mock) -> list[list[str]]:
|
|
153
|
+
return [each_call.args[0] for each_call in command_runner.call_args_list]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _run_comment(
|
|
157
|
+
body_file: Path, command_runner: Mock, environment: dict[str, str]
|
|
158
|
+
) -> int:
|
|
159
|
+
return pull_request.main(
|
|
160
|
+
_comment_arguments(body_file),
|
|
161
|
+
all_environment=environment,
|
|
162
|
+
command_runner=command_runner,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _account_runner(
|
|
167
|
+
*, auth_code: int = 0, auth_stdout: str, action_code: int = 0, auth_stderr: str = ""
|
|
168
|
+
) -> Mock:
|
|
169
|
+
return Mock(
|
|
170
|
+
side_effect=[
|
|
171
|
+
_completion([]),
|
|
172
|
+
_completion([], auth_code, auth_stdout, auth_stderr),
|
|
173
|
+
_completion([], action_code),
|
|
174
|
+
]
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.parametrize(
|
|
179
|
+
("all_cli_arguments", "linter_action", "expected_gh"), ACTION_CASES
|
|
180
|
+
)
|
|
181
|
+
def test_actions_run_exact_linter_then_gh_arguments(
|
|
182
|
+
all_cli_arguments: list[str], linter_action: str, expected_gh: list[str]
|
|
183
|
+
) -> None:
|
|
184
|
+
command_runner = Mock(side_effect=[_completion([]), _completion([])])
|
|
185
|
+
exit_code = pull_request.main(
|
|
186
|
+
all_cli_arguments, all_environment={}, command_runner=command_runner
|
|
187
|
+
)
|
|
188
|
+
title_arguments = ["--title", PR_TITLE] if "--title" in all_cli_arguments else []
|
|
189
|
+
expected_linter = [
|
|
190
|
+
sys.executable,
|
|
191
|
+
str(LINTER_PATH),
|
|
192
|
+
"--action",
|
|
193
|
+
linter_action,
|
|
194
|
+
*title_arguments,
|
|
195
|
+
"--body-file",
|
|
196
|
+
BODY_FILENAME,
|
|
197
|
+
]
|
|
198
|
+
assert exit_code == 0
|
|
199
|
+
assert _calls(command_runner) == [expected_linter, expected_gh]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_body_transport_uses_files_only(body_file: Path) -> None:
|
|
203
|
+
command_runner = Mock(side_effect=[_completion([]), _completion([])])
|
|
204
|
+
assert _run_comment(body_file, command_runner, {}) == 0
|
|
205
|
+
for each_command in _calls(command_runner):
|
|
206
|
+
assert "--body-file" in each_command
|
|
207
|
+
assert "--body" not in each_command
|
|
208
|
+
assert "Durable body text." not in each_command
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_linter_failure_stops_credential_lookup_and_action(body_file: Path) -> None:
|
|
212
|
+
command_runner = Mock(return_value=_completion([], 1))
|
|
213
|
+
exit_code = _run_comment(
|
|
214
|
+
body_file, command_runner, {"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A}
|
|
215
|
+
)
|
|
216
|
+
assert exit_code == 1
|
|
217
|
+
assert command_runner.call_count == 1
|
|
218
|
+
assert Path(_calls(command_runner)[0][1]).name == "durable_post_lint.py"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_account_lookup_scopes_action_environment(body_file: Path) -> None:
|
|
222
|
+
account_text = _account_text(ACCOUNT_A)
|
|
223
|
+
command_runner = _account_runner(auth_stdout=f"{account_text}\n")
|
|
224
|
+
parent_environment = {
|
|
225
|
+
"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A,
|
|
226
|
+
"GITHUB_TOKEN": "parent-setting",
|
|
227
|
+
"UNCHANGED": "same",
|
|
228
|
+
}
|
|
229
|
+
assert _run_comment(body_file, command_runner, parent_environment) == 0
|
|
230
|
+
assert _calls(command_runner)[1] == ["gh", "auth", "token", "--user", ACCOUNT_A]
|
|
231
|
+
action_environment = command_runner.call_args_list[2].kwargs["env"]
|
|
232
|
+
assert action_environment["GH_TOKEN"] == account_text
|
|
233
|
+
assert "GITHUB_TOKEN" not in action_environment
|
|
234
|
+
assert action_environment["UNCHANGED"] == "same"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_absent_account_uses_copied_parent_environment(body_file: Path) -> None:
|
|
238
|
+
command_runner = Mock(side_effect=[_completion([]), _completion([])])
|
|
239
|
+
parent_environment = {"GITHUB_TOKEN": "parent-setting", "UNCHANGED": "same"}
|
|
240
|
+
assert _run_comment(body_file, command_runner, parent_environment) == 0
|
|
241
|
+
action_environment = command_runner.call_args_list[1].kwargs["env"]
|
|
242
|
+
assert action_environment == parent_environment
|
|
243
|
+
assert action_environment is not parent_environment
|
|
244
|
+
assert command_runner.call_count == 2
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_token_lookup_failure_prints_generic_safe_stderr(
|
|
248
|
+
body_file: Path, capsys: pytest.CaptureFixture[str]
|
|
249
|
+
) -> None:
|
|
250
|
+
account_text = _account_text(ACCOUNT_A)
|
|
251
|
+
command_runner = _account_runner(
|
|
252
|
+
auth_code=1, auth_stdout=account_text, auth_stderr=f"unsafe {account_text}"
|
|
253
|
+
)
|
|
254
|
+
exit_code = _run_comment(
|
|
255
|
+
body_file, command_runner, {"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A}
|
|
256
|
+
)
|
|
257
|
+
captured = capsys.readouterr()
|
|
258
|
+
assert exit_code == 1
|
|
259
|
+
assert captured.err == "error: GitHub account lookup failed\n"
|
|
260
|
+
assert account_text not in captured.out + captured.err
|
|
261
|
+
assert command_runner.call_count == 2
|
|
262
|
+
command_runner = Mock(side_effect=[_completion([]), OSError("unavailable")])
|
|
263
|
+
assert (
|
|
264
|
+
_run_comment(body_file, command_runner, {"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A})
|
|
265
|
+
== 1
|
|
266
|
+
)
|
|
267
|
+
assert capsys.readouterr().err == "error: GitHub account lookup failed\n"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_empty_account_token_stops_before_action(
|
|
271
|
+
body_file: Path, capsys: pytest.CaptureFixture[str]
|
|
272
|
+
) -> None:
|
|
273
|
+
command_runner = _account_runner(auth_stdout=" \n")
|
|
274
|
+
exit_code = _run_comment(
|
|
275
|
+
body_file, command_runner, {"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A}
|
|
276
|
+
)
|
|
277
|
+
captured = capsys.readouterr()
|
|
278
|
+
assert exit_code == 1
|
|
279
|
+
assert captured.err == "error: GitHub account lookup returned no value\n"
|
|
280
|
+
assert command_runner.call_count == 2
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_action_child_results_pass_through_or_report_safely(
|
|
284
|
+
body_file: Path, capsys: pytest.CaptureFixture[str]
|
|
285
|
+
) -> None:
|
|
286
|
+
command_runner = Mock(side_effect=[_completion([]), _completion([], 7)])
|
|
287
|
+
assert _run_comment(body_file, command_runner, {}) == 7
|
|
288
|
+
command_runner = Mock(side_effect=[_completion([]), OSError("unavailable")])
|
|
289
|
+
assert _run_comment(body_file, command_runner, {}) == 1
|
|
290
|
+
assert capsys.readouterr().err == "error: GitHub action failed\n"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_supplied_parent_environment_remains_unchanged(body_file: Path) -> None:
|
|
294
|
+
command_runner = _account_runner(auth_stdout=_account_text(ACCOUNT_A))
|
|
295
|
+
parent_environment = {
|
|
296
|
+
"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A,
|
|
297
|
+
"GITHUB_TOKEN": "parent-setting",
|
|
298
|
+
"GH_TOKEN": "parent-setting",
|
|
299
|
+
}
|
|
300
|
+
original_environment = dict(parent_environment)
|
|
301
|
+
assert _run_comment(body_file, command_runner, parent_environment) == 0
|
|
302
|
+
assert parent_environment == original_environment
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_process_environment_remains_unchanged(
|
|
306
|
+
body_file: Path, monkeypatch: pytest.MonkeyPatch
|
|
307
|
+
) -> None:
|
|
308
|
+
monkeypatch.setenv("GITHUB_DEFAULT_ACCOUNT", ACCOUNT_A)
|
|
309
|
+
monkeypatch.setenv("GITHUB_TOKEN", "parent-setting")
|
|
310
|
+
monkeypatch.setenv("GH_TOKEN", "parent-setting")
|
|
311
|
+
original_environment = dict(os.environ)
|
|
312
|
+
command_runner = _account_runner(auth_stdout=_account_text(ACCOUNT_A))
|
|
313
|
+
exit_code = pull_request.main(
|
|
314
|
+
_comment_arguments(body_file),
|
|
315
|
+
all_environment=os.environ,
|
|
316
|
+
command_runner=command_runner,
|
|
317
|
+
)
|
|
318
|
+
assert exit_code == 0
|
|
319
|
+
assert dict(os.environ) == original_environment
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _concurrent_runner(
|
|
323
|
+
all_environments: list[dict[str, str]],
|
|
324
|
+
all_commands: list[list[str]],
|
|
325
|
+
record_lock: threading.Lock,
|
|
326
|
+
) -> Callable[..., subprocess.CompletedProcess[str]]:
|
|
327
|
+
def run_command(
|
|
328
|
+
all_arguments: list[str], **keyword_arguments: object
|
|
329
|
+
) -> subprocess.CompletedProcess[str]:
|
|
330
|
+
with record_lock:
|
|
331
|
+
all_commands.append(all_arguments)
|
|
332
|
+
if all_arguments[0] == sys.executable:
|
|
333
|
+
return _completion(all_arguments)
|
|
334
|
+
if all_arguments[:3] == ["gh", "auth", "token"]:
|
|
335
|
+
if all_arguments[-1] == ACCOUNT_A:
|
|
336
|
+
return _completion(all_arguments, stdout=_account_text(ACCOUNT_A))
|
|
337
|
+
return _completion(all_arguments, 1)
|
|
338
|
+
child_environment = keyword_arguments["env"]
|
|
339
|
+
assert isinstance(child_environment, dict)
|
|
340
|
+
with record_lock:
|
|
341
|
+
all_environments.append(dict(child_environment))
|
|
342
|
+
return _completion(all_arguments)
|
|
343
|
+
|
|
344
|
+
return run_command
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_concurrent_accounts_keep_tokens_isolated(body_file: Path) -> None:
|
|
348
|
+
all_environments: list[dict[str, str]] = []
|
|
349
|
+
all_commands: list[list[str]] = []
|
|
350
|
+
command_runner = _concurrent_runner(
|
|
351
|
+
all_environments, all_commands, threading.Lock()
|
|
352
|
+
)
|
|
353
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
354
|
+
first_run = executor.submit(
|
|
355
|
+
pull_request.main,
|
|
356
|
+
_comment_arguments(body_file),
|
|
357
|
+
all_environment={"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A},
|
|
358
|
+
command_runner=command_runner,
|
|
359
|
+
)
|
|
360
|
+
second_run = executor.submit(
|
|
361
|
+
pull_request.main,
|
|
362
|
+
_comment_arguments(body_file),
|
|
363
|
+
all_environment={"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_B},
|
|
364
|
+
command_runner=command_runner,
|
|
365
|
+
)
|
|
366
|
+
assert {first_run.result(), second_run.result()} == {0, 1}
|
|
367
|
+
assert len(all_environments) == 1
|
|
368
|
+
assert all_environments[0]["GH_TOKEN"] == _account_text(ACCOUNT_A)
|
|
369
|
+
assert all(
|
|
370
|
+
each_command[:3] != ["gh", "auth", "switch"] for each_command in all_commands
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def test_token_never_reaches_captured_output(
|
|
375
|
+
body_file: Path, capsys: pytest.CaptureFixture[str]
|
|
376
|
+
) -> None:
|
|
377
|
+
account_text = _account_text(ACCOUNT_A)
|
|
378
|
+
command_runner = _account_runner(auth_stdout=f"{account_text}\n")
|
|
379
|
+
assert (
|
|
380
|
+
_run_comment(body_file, command_runner, {"GITHUB_DEFAULT_ACCOUNT": ACCOUNT_A})
|
|
381
|
+
== 0
|
|
382
|
+
)
|
|
383
|
+
captured = capsys.readouterr()
|
|
384
|
+
assert account_text not in captured.out + captured.err
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_cli_rejects_inline_body(capsys: pytest.CaptureFixture[str]) -> None:
|
|
388
|
+
command_runner = Mock()
|
|
389
|
+
with pytest.raises(SystemExit) as raised_exit:
|
|
390
|
+
pull_request.main(
|
|
391
|
+
[*COMMENT_CLI[:-1], "--body", "Inline body text."],
|
|
392
|
+
all_environment={},
|
|
393
|
+
command_runner=command_runner,
|
|
394
|
+
)
|
|
395
|
+
captured = capsys.readouterr()
|
|
396
|
+
assert raised_exit.value.code == 2
|
|
397
|
+
assert "--body-file" in captured.err
|
|
398
|
+
command_runner.assert_not_called()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Contract tests for explicit legacy GitHub author recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import Mock
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
_SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
if str(_SCRIPTS_DIRECTORY) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
|
|
19
|
+
|
|
20
|
+
import recover_legacy_author
|
|
21
|
+
|
|
22
|
+
CURRENT_TIME_SECONDS = 2_000_000.0
|
|
23
|
+
CURRENT_USER_ID = 41
|
|
24
|
+
LEGACY_ACCOUNT = "previous-account"
|
|
25
|
+
OTHER_ACCOUNT = "other-account"
|
|
26
|
+
STATE_FILENAME = "gh_pr_author_swap_session-a.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _completion(
|
|
30
|
+
code: int = 0, stdout: str = "", stderr: str = ""
|
|
31
|
+
) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
return subprocess.CompletedProcess([], code, stdout, stderr)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _write_state(
|
|
36
|
+
directory: Path,
|
|
37
|
+
*,
|
|
38
|
+
filename: str = STATE_FILENAME,
|
|
39
|
+
payload: object | None = None,
|
|
40
|
+
age_seconds: float = 1801.0,
|
|
41
|
+
) -> Path:
|
|
42
|
+
state_file = directory / filename
|
|
43
|
+
state_payload = {"original_account": LEGACY_ACCOUNT} if payload is None else payload
|
|
44
|
+
state_file.write_text(json.dumps(state_payload), encoding="utf-8")
|
|
45
|
+
os.utime(
|
|
46
|
+
state_file,
|
|
47
|
+
(CURRENT_TIME_SECONDS - age_seconds, CURRENT_TIME_SECONDS - age_seconds),
|
|
48
|
+
)
|
|
49
|
+
if hasattr(os, "getuid"):
|
|
50
|
+
state_file.chmod(0o600)
|
|
51
|
+
return state_file
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run(
|
|
55
|
+
state_file: Path,
|
|
56
|
+
command_runner: Callable[..., subprocess.CompletedProcess[str]],
|
|
57
|
+
*,
|
|
58
|
+
confirm_inactive: bool = True,
|
|
59
|
+
) -> int:
|
|
60
|
+
all_arguments = [str(state_file)]
|
|
61
|
+
if confirm_inactive:
|
|
62
|
+
all_arguments.append("--confirm-inactive")
|
|
63
|
+
return recover_legacy_author.main(
|
|
64
|
+
all_arguments,
|
|
65
|
+
now_seconds=CURRENT_TIME_SECONDS,
|
|
66
|
+
current_user_id=os.getuid() if hasattr(os, "getuid") else None,
|
|
67
|
+
command_runner=command_runner,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_success_restores_selected_record_and_preserves_neighbor(
|
|
72
|
+
tmp_path: Path,
|
|
73
|
+
) -> None:
|
|
74
|
+
selected_file = _write_state(tmp_path)
|
|
75
|
+
neighbor_file = _write_state(
|
|
76
|
+
tmp_path,
|
|
77
|
+
filename="gh_pr_author_swap_session-b.json",
|
|
78
|
+
payload={"original_account": OTHER_ACCOUNT},
|
|
79
|
+
)
|
|
80
|
+
command_runner = Mock(return_value=_completion())
|
|
81
|
+
assert _run(selected_file, command_runner) == 0
|
|
82
|
+
command_runner.assert_called_once_with(
|
|
83
|
+
["gh", "auth", "switch", "--user", LEGACY_ACCOUNT],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
check=False,
|
|
86
|
+
shell=False,
|
|
87
|
+
text=True,
|
|
88
|
+
)
|
|
89
|
+
assert not selected_file.exists()
|
|
90
|
+
assert neighbor_file.exists()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_unconfirmed_record_is_unresolved_without_side_effects(
|
|
94
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
95
|
+
) -> None:
|
|
96
|
+
state_file = _write_state(tmp_path)
|
|
97
|
+
command_runner = Mock()
|
|
98
|
+
assert _run(state_file, command_runner, confirm_inactive=False) == 3
|
|
99
|
+
assert "confirm inactive" in capsys.readouterr().err
|
|
100
|
+
command_runner.assert_not_called()
|
|
101
|
+
assert state_file.exists()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_active_record_is_rejected_without_side_effects(
|
|
105
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
106
|
+
) -> None:
|
|
107
|
+
state_file = _write_state(tmp_path, age_seconds=10.0)
|
|
108
|
+
command_runner = Mock()
|
|
109
|
+
assert _run(state_file, command_runner) == 2
|
|
110
|
+
assert "record is active" in capsys.readouterr().err
|
|
111
|
+
command_runner.assert_not_called()
|
|
112
|
+
assert state_file.exists()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.parametrize(
|
|
116
|
+
"payload",
|
|
117
|
+
[
|
|
118
|
+
[],
|
|
119
|
+
{},
|
|
120
|
+
{"original_account": ""},
|
|
121
|
+
{"original_account": 7},
|
|
122
|
+
{"original_account": LEGACY_ACCOUNT, "extra": True},
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
def test_invalid_schema_is_rejected(
|
|
126
|
+
tmp_path: Path, payload: object, capsys: pytest.CaptureFixture[str]
|
|
127
|
+
) -> None:
|
|
128
|
+
state_file = _write_state(tmp_path, payload=payload)
|
|
129
|
+
command_runner = Mock()
|
|
130
|
+
assert _run(state_file, command_runner) == 2
|
|
131
|
+
assert "record is invalid" in capsys.readouterr().err
|
|
132
|
+
command_runner.assert_not_called()
|
|
133
|
+
assert state_file.exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_invalid_utf8_is_rejected(tmp_path: Path) -> None:
|
|
137
|
+
state_file = _write_state(tmp_path)
|
|
138
|
+
state_file.write_bytes(b"\xff")
|
|
139
|
+
os.utime(state_file, (CURRENT_TIME_SECONDS - 1801, CURRENT_TIME_SECONDS - 1801))
|
|
140
|
+
command_runner = Mock()
|
|
141
|
+
assert _run(state_file, command_runner) == 2
|
|
142
|
+
command_runner.assert_not_called()
|
|
143
|
+
assert state_file.exists()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_wrong_filename_is_rejected(tmp_path: Path) -> None:
|
|
147
|
+
state_file = _write_state(tmp_path, filename="other.json")
|
|
148
|
+
command_runner = Mock()
|
|
149
|
+
assert _run(state_file, command_runner) == 2
|
|
150
|
+
command_runner.assert_not_called()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_symlink_is_rejected(tmp_path: Path) -> None:
|
|
154
|
+
target_file = _write_state(tmp_path)
|
|
155
|
+
link_file = tmp_path / "gh_pr_author_swap_link.json"
|
|
156
|
+
try:
|
|
157
|
+
link_file.symlink_to(target_file)
|
|
158
|
+
except OSError as error:
|
|
159
|
+
pytest.skip(f"symlink unavailable: {error}")
|
|
160
|
+
command_runner = Mock()
|
|
161
|
+
assert _run(link_file, command_runner) == 2
|
|
162
|
+
command_runner.assert_not_called()
|
|
163
|
+
assert target_file.exists()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_failed_restore_preserves_record_and_hides_child_output(
|
|
167
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
168
|
+
) -> None:
|
|
169
|
+
state_file = _write_state(tmp_path)
|
|
170
|
+
command_runner = Mock(
|
|
171
|
+
return_value=_completion(1, LEGACY_ACCOUNT, f"unsafe {LEGACY_ACCOUNT}")
|
|
172
|
+
)
|
|
173
|
+
assert _run(state_file, command_runner) == 1
|
|
174
|
+
captured = capsys.readouterr()
|
|
175
|
+
assert captured.err == "error: GitHub account restore failed\n"
|
|
176
|
+
assert LEGACY_ACCOUNT not in captured.out + captured.err
|
|
177
|
+
assert state_file.exists()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_restore_launch_failure_preserves_record(
|
|
181
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
182
|
+
) -> None:
|
|
183
|
+
state_file = _write_state(tmp_path)
|
|
184
|
+
command_runner = Mock(side_effect=OSError("unavailable"))
|
|
185
|
+
assert _run(state_file, command_runner) == 1
|
|
186
|
+
assert capsys.readouterr().err == "error: GitHub account restore failed\n"
|
|
187
|
+
assert state_file.exists()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_changed_record_after_restore_is_preserved(
|
|
191
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
192
|
+
) -> None:
|
|
193
|
+
state_file = _write_state(tmp_path)
|
|
194
|
+
|
|
195
|
+
def change_record(
|
|
196
|
+
all_arguments: list[str], **keyword_arguments: object
|
|
197
|
+
) -> subprocess.CompletedProcess[str]:
|
|
198
|
+
state_file.write_text(
|
|
199
|
+
json.dumps({"original_account": OTHER_ACCOUNT}), encoding="utf-8"
|
|
200
|
+
)
|
|
201
|
+
return _completion()
|
|
202
|
+
|
|
203
|
+
assert _run(state_file, change_record) == 1
|
|
204
|
+
assert "record changed" in capsys.readouterr().err
|
|
205
|
+
assert state_file.exists()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_secure_posix_metadata_requires_regular_owner_mode() -> None:
|
|
209
|
+
regular_secure_mode = stat.S_IFREG | 0o600
|
|
210
|
+
assert recover_legacy_author._posix_metadata_is_secure(
|
|
211
|
+
regular_secure_mode, CURRENT_USER_ID, CURRENT_USER_ID
|
|
212
|
+
)
|
|
213
|
+
assert not recover_legacy_author._posix_metadata_is_secure(
|
|
214
|
+
regular_secure_mode, CURRENT_USER_ID + 1, CURRENT_USER_ID
|
|
215
|
+
)
|
|
216
|
+
assert not recover_legacy_author._posix_metadata_is_secure(
|
|
217
|
+
stat.S_IFREG | 0o644, CURRENT_USER_ID, CURRENT_USER_ID
|
|
218
|
+
)
|
|
219
|
+
assert not recover_legacy_author._posix_metadata_is_secure(
|
|
220
|
+
stat.S_IFLNK | 0o600, CURRENT_USER_ID, CURRENT_USER_ID
|
|
221
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-runner
|
|
3
|
+
description: >-
|
|
4
|
+
Run pytest or Playwright through the repository's test command.
|
|
5
|
+
Checks Django and Playwright readiness before launching the
|
|
6
|
+
selected child process. Use when running tests in a repository with local
|
|
7
|
+
Django or frontend services.
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# test-runner
|
|
11
|
+
|
|
12
|
+
Run Django and Playwright readiness checks, then launch the selected test
|
|
13
|
+
command. Pass one supported runner after `--`. The command forwards those
|
|
14
|
+
arguments without a shell.
|
|
15
|
+
|
|
16
|
+
## Command
|
|
17
|
+
|
|
18
|
+
Run from the repository that owns the tests:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
python "${CLAUDE_SKILL_DIR}/scripts/run_tests.py" -- python -m pytest <pytest-arguments>
|
|
22
|
+
python "${CLAUDE_SKILL_DIR}/scripts/run_tests.py" -- npx playwright test <playwright-arguments>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Use `--project <path>` when the test repository is not the current directory:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
python "${CLAUDE_SKILL_DIR}/scripts/run_tests.py" --project "<repository>" -- python -m pytest <pytest-arguments>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The supported runner forms are `pytest`, `python -m pytest`, `playwright test`,
|
|
32
|
+
and `npx playwright test`. The child receives the exact arguments after `--`,
|
|
33
|
+
inherits the current environment, writes to the current output streams, runs
|
|
34
|
+
with the selected project as its working directory, and returns its exit
|
|
35
|
+
status.
|
|
36
|
+
|
|
37
|
+
## Preflight
|
|
38
|
+
|
|
39
|
+
The command returns nonzero and launches no child when a required check fails.
|
|
40
|
+
|
|
41
|
+
- A pytest run in a project without `manage.py` runs without preflight.
|
|
42
|
+
- A pytest run in a Django project requires `db.sqlite3` and a healthy Django
|
|
43
|
+
server. The URL defaults to `http://localhost:8000`. A URL in the child
|
|
44
|
+
arguments overrides that default.
|
|
45
|
+
- A Playwright run checks its target server, validates a detected Django
|
|
46
|
+
`runserver` process uses `--test-db`, rejects conflicting Django servers from
|
|
47
|
+
different worktrees, and uses the URL default `http://localhost:3000`.
|
|
48
|
+
- When the project contains `frontend/`, the command runs `npm run build` in
|
|
49
|
+
that directory, then `python manage.py collectstatic --noinput` in the
|
|
50
|
+
project directory before Playwright starts.
|
|
51
|
+
|
|
52
|
+
The command does not parse shell syntax. Keep pipes, redirects, `cd`, and
|
|
53
|
+
background operators outside this command. Run this command directly so its
|
|
54
|
+
exit status reaches the caller.
|
|
55
|
+
|
|
56
|
+
## Unsupported commands
|
|
57
|
+
|
|
58
|
+
Use the normal runner for anything else. This command accepts only the four
|
|
59
|
+
runner forms listed above. It does not install dependencies, start a server,
|
|
60
|
+
run migrations, or repair a failed preflight.
|
|
61
|
+
|
|
62
|
+
## Files
|
|
63
|
+
|
|
64
|
+
- `SKILL.md` covers invocation, checks, and the command boundary.
|
|
65
|
+
- `scripts/run_tests.py` parses the command, runs preflight, and launches the child.
|
|
66
|
+
- `scripts/preflight_checks.py` runs Django and Playwright readiness checks.
|
|
67
|
+
- `scripts/test_run_tests.py` covers the command and preflight.
|
|
68
|
+
- `scripts/test_preflight_checks.py` covers the readiness checks.
|
|
69
|
+
- `scripts/test_runner_constants/` holds command names, messages, and limits.
|