claude-dev-env 7.0.0 → 7.1.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 +189 -0
- package/.agents/skills/session-continuity/SKILL.md +114 -0
- package/.agents/skills/session-continuity/continuity.mjs +375 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +303 -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 +78 -0
- package/bin/install-session-continuity.test.mjs +42 -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
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
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 automatic_advisory.model import (
|
|
14
|
+
AdvisoryRegistration,
|
|
15
|
+
AdvisorySettings,
|
|
16
|
+
ChildOutcome,
|
|
17
|
+
)
|
|
18
|
+
from automatic_advisory.runner import (
|
|
19
|
+
AdvisoryGitHub,
|
|
20
|
+
AutomaticAdvisoryRunner,
|
|
21
|
+
Publication,
|
|
22
|
+
run_verification_child,
|
|
23
|
+
)
|
|
24
|
+
from pr_verification.model import PullRequestCandidate, RepositorySettings
|
|
25
|
+
|
|
26
|
+
VALID_ADVISORY_SETTINGS_TEMPLATE = '{"version":1,"app_id":1,"installation_id":1,"private_key_path":"$ROOT","poll_seconds":0.25,"child_timeout_seconds":45.5,"registrations":[{"repository":"owner/repository","pull_request":1,"checkout":"$ROOT","manifest":"manifest.json","report":"$ROOT","state":"$ROOT","base_ref":"main","remote":"origin"}]}'
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FakeGitHub(AdvisoryGitHub):
|
|
30
|
+
def __init__(self, candidate: PullRequestCandidate) -> None:
|
|
31
|
+
self.candidate = candidate
|
|
32
|
+
self.all_events: list[str] = []
|
|
33
|
+
self.all_merge_requirements: list[bool] = []
|
|
34
|
+
self.remove_label_failures = 0
|
|
35
|
+
|
|
36
|
+
def list_open_candidates(
|
|
37
|
+
self,
|
|
38
|
+
repository: RepositorySettings,
|
|
39
|
+
*,
|
|
40
|
+
should_require_merge_commit: bool = True,
|
|
41
|
+
) -> tuple[PullRequestCandidate, ...]:
|
|
42
|
+
self.all_merge_requirements.append(should_require_merge_commit)
|
|
43
|
+
return (self.candidate,)
|
|
44
|
+
|
|
45
|
+
def remove_label(
|
|
46
|
+
self, repository: RepositorySettings, pull_request_number: int, label: str
|
|
47
|
+
) -> None:
|
|
48
|
+
self.all_events.append("remove-label")
|
|
49
|
+
if self.remove_label_failures:
|
|
50
|
+
self.remove_label_failures -= 1
|
|
51
|
+
raise RuntimeError("label service unavailable")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FakePublication:
|
|
55
|
+
def __init__(self, status: str) -> None:
|
|
56
|
+
self.status = status
|
|
57
|
+
self.description = "local check finished"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_git(repository_path: Path, *arguments: str) -> str:
|
|
61
|
+
completed_process = subprocess.run(
|
|
62
|
+
["git", "-C", str(repository_path), *arguments],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
check=False,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
assert completed_process.returncode == 0, completed_process.stderr
|
|
68
|
+
return completed_process.stdout.strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_checkout(tmp_path: Path) -> tuple[Path, str]:
|
|
72
|
+
checkout_path = tmp_path / "checkout"
|
|
73
|
+
remote_path = tmp_path / "remote.git"
|
|
74
|
+
checkout_path.mkdir()
|
|
75
|
+
_initialize_checkout(checkout_path)
|
|
76
|
+
_write_checkout_files(checkout_path)
|
|
77
|
+
_run_git(checkout_path, "add", "tracked.txt")
|
|
78
|
+
_run_git(checkout_path, "add", "manifest.json")
|
|
79
|
+
_run_git(checkout_path, "add", ".github/ci/local_verify.py")
|
|
80
|
+
_run_git(checkout_path, "commit", "--quiet", "-m", "test")
|
|
81
|
+
_create_bare_remote(checkout_path, remote_path)
|
|
82
|
+
return checkout_path, _run_git(checkout_path, "rev-parse", "HEAD")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _initialize_checkout(checkout_path: Path) -> None:
|
|
86
|
+
_run_git(checkout_path, "init", "--quiet")
|
|
87
|
+
hooks_directory = checkout_path / ".test-hooks"
|
|
88
|
+
hooks_directory.mkdir()
|
|
89
|
+
_run_git(checkout_path, "config", "--local", "core.hooksPath", str(hooks_directory))
|
|
90
|
+
_run_git(checkout_path, "checkout", "-b", "main")
|
|
91
|
+
_run_git(checkout_path, "config", "user.email", "test@example.com")
|
|
92
|
+
_run_git(checkout_path, "config", "user.name", "Automatic Advisory Test")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write_checkout_files(checkout_path: Path) -> None:
|
|
96
|
+
(checkout_path / "tracked.txt").write_text("clean\n", encoding="utf-8")
|
|
97
|
+
(checkout_path / "manifest.json").write_text(
|
|
98
|
+
json.dumps(
|
|
99
|
+
{
|
|
100
|
+
"version": 1,
|
|
101
|
+
"checks": [
|
|
102
|
+
{
|
|
103
|
+
"id": "real-child",
|
|
104
|
+
"argv": [sys.executable, "-c", "raise SystemExit(0)"],
|
|
105
|
+
"cwd": ".",
|
|
106
|
+
"timeout_seconds": 10,
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
encoding="utf-8",
|
|
112
|
+
)
|
|
113
|
+
_write_local_verify_wrapper(checkout_path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _write_local_verify_wrapper(checkout_path: Path) -> None:
|
|
117
|
+
wrapper_path = checkout_path / ".github" / "ci" / "local_verify.py"
|
|
118
|
+
wrapper_path.parent.mkdir(parents=True)
|
|
119
|
+
wrapper_path.write_text(
|
|
120
|
+
"import argparse\n"
|
|
121
|
+
"import os\n"
|
|
122
|
+
"from pathlib import Path\n"
|
|
123
|
+
"import subprocess\n"
|
|
124
|
+
"import sys\n"
|
|
125
|
+
"if os.environ.get('PYTEST_XDIST_AUTO_NUM_WORKERS') != '2':\n"
|
|
126
|
+
" raise SystemExit(9)\n"
|
|
127
|
+
"if str(Path.cwd()) not in os.environ.get('PYTHONPATH', '').split(os.pathsep):\n"
|
|
128
|
+
" raise SystemExit(10)\n"
|
|
129
|
+
"parser = argparse.ArgumentParser()\n"
|
|
130
|
+
"parser.add_argument('--base', required=True)\n"
|
|
131
|
+
"parser.add_argument('--executor', required=True)\n"
|
|
132
|
+
"parser.add_argument('--output', required=True)\n"
|
|
133
|
+
"arguments = parser.parse_args()\n"
|
|
134
|
+
"selected_manifest_path = Path(arguments.output).with_name(Path(arguments.output).stem + '.selected-manifest.json')\n"
|
|
135
|
+
"selected_manifest_path.parent.mkdir(parents=True, exist_ok=True)\n"
|
|
136
|
+
"selected_manifest_path.write_text(Path('manifest.json').read_text(encoding='utf-8'), encoding='utf-8')\n"
|
|
137
|
+
"raise SystemExit(subprocess.call([\n"
|
|
138
|
+
" sys.executable, arguments.executor, '--manifest', 'manifest.json',\n"
|
|
139
|
+
" '--repo', '.', '--base', arguments.base, '--output', arguments.output\n"
|
|
140
|
+
"]))\n",
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _create_bare_remote(checkout_path: Path, remote_path: Path) -> None:
|
|
146
|
+
_run_git(
|
|
147
|
+
checkout_path,
|
|
148
|
+
"clone",
|
|
149
|
+
"--bare",
|
|
150
|
+
str(checkout_path),
|
|
151
|
+
str(remote_path),
|
|
152
|
+
)
|
|
153
|
+
_run_git(checkout_path, "remote", "add", "origin", str(remote_path))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _build_registration(tmp_path: Path, checkout_path: Path) -> AdvisoryRegistration:
|
|
157
|
+
manifest_path = checkout_path / "manifest.json"
|
|
158
|
+
manifest_path.write_text(
|
|
159
|
+
json.dumps(
|
|
160
|
+
{
|
|
161
|
+
"version": 1,
|
|
162
|
+
"checks": [
|
|
163
|
+
{
|
|
164
|
+
"id": "real-child",
|
|
165
|
+
"argv": [sys.executable, "-c", "raise SystemExit(0)"],
|
|
166
|
+
"cwd": ".",
|
|
167
|
+
"timeout_seconds": 10,
|
|
168
|
+
}
|
|
169
|
+
],
|
|
170
|
+
}
|
|
171
|
+
),
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
)
|
|
174
|
+
return AdvisoryRegistration(
|
|
175
|
+
RepositorySettings("JonEcho/python-automation", "unused"),
|
|
176
|
+
2985,
|
|
177
|
+
checkout_path,
|
|
178
|
+
PurePosixPath("manifest.json"),
|
|
179
|
+
tmp_path / "reports" / "pr-2985.json",
|
|
180
|
+
tmp_path / "state" / "pr-2985.json",
|
|
181
|
+
"main",
|
|
182
|
+
"origin",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _build_candidate(head_sha: str, merge_sha: str = "unused") -> PullRequestCandidate:
|
|
187
|
+
return PullRequestCandidate(
|
|
188
|
+
"JonEcho/python-automation",
|
|
189
|
+
2985,
|
|
190
|
+
"main",
|
|
191
|
+
head_sha,
|
|
192
|
+
head_sha,
|
|
193
|
+
merge_sha,
|
|
194
|
+
False,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _build_settings(registration: AdvisoryRegistration) -> AdvisorySettings:
|
|
199
|
+
return AdvisorySettings(
|
|
200
|
+
"https://api.github.com",
|
|
201
|
+
4841271,
|
|
202
|
+
159293880,
|
|
203
|
+
registration.state_path.parent / "private-key.pem",
|
|
204
|
+
60.0,
|
|
205
|
+
30.0,
|
|
206
|
+
(registration,),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _valid_advisory_settings_fields(tmp_path: Path) -> dict[str, object]:
|
|
211
|
+
return json.loads(
|
|
212
|
+
VALID_ADVISORY_SETTINGS_TEMPLATE.replace('"$ROOT"', json.dumps(str(tmp_path)))
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _publish_passed(
|
|
217
|
+
*all_arguments: object,
|
|
218
|
+
) -> Publication:
|
|
219
|
+
return FakePublication("passed")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _run_recorded_child(
|
|
223
|
+
all_events: list[str],
|
|
224
|
+
registration: AdvisoryRegistration,
|
|
225
|
+
base_ref: str,
|
|
226
|
+
timeout_seconds: float,
|
|
227
|
+
) -> ChildOutcome:
|
|
228
|
+
all_events.append("child")
|
|
229
|
+
child_outcome = run_verification_child(registration, base_ref, timeout_seconds)
|
|
230
|
+
assert child_outcome.exit_code == 0, child_outcome.stderr_text
|
|
231
|
+
return child_outcome
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _run_counted_child(
|
|
235
|
+
child_calls: list[str],
|
|
236
|
+
registration: AdvisoryRegistration,
|
|
237
|
+
base_ref: str,
|
|
238
|
+
timeout_seconds: float,
|
|
239
|
+
) -> ChildOutcome:
|
|
240
|
+
assert registration.checkout_path.is_dir()
|
|
241
|
+
assert base_ref
|
|
242
|
+
assert timeout_seconds > 0
|
|
243
|
+
child_calls.append("child")
|
|
244
|
+
_write_child_artifacts(registration, 0)
|
|
245
|
+
return ChildOutcome(0, "", "", False)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _write_child_artifacts(
|
|
249
|
+
registration: AdvisoryRegistration,
|
|
250
|
+
exit_code: int,
|
|
251
|
+
) -> None:
|
|
252
|
+
registration.report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
253
|
+
registration.report_path.write_text(
|
|
254
|
+
json.dumps({"exit_code": exit_code}),
|
|
255
|
+
encoding="utf-8",
|
|
256
|
+
)
|
|
257
|
+
registration.selected_manifest_path.write_text(
|
|
258
|
+
registration.manifest_absolute_path.read_text(encoding="utf-8"),
|
|
259
|
+
encoding="utf-8",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _build_counting_runner(
|
|
264
|
+
registration: AdvisoryRegistration,
|
|
265
|
+
github: FakeGitHub,
|
|
266
|
+
child_calls: list[str],
|
|
267
|
+
publisher: Callable[..., Publication],
|
|
268
|
+
) -> AutomaticAdvisoryRunner:
|
|
269
|
+
return AutomaticAdvisoryRunner(
|
|
270
|
+
_build_settings(registration),
|
|
271
|
+
github,
|
|
272
|
+
child_runner=lambda child_registration, base_ref, timeout_seconds: (
|
|
273
|
+
_run_counted_child(
|
|
274
|
+
child_calls, child_registration, base_ref, timeout_seconds
|
|
275
|
+
)
|
|
276
|
+
),
|
|
277
|
+
publisher=publisher,
|
|
278
|
+
clock=lambda: "2026-09-05T00:00:00+00:00",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _publish_recorded(
|
|
283
|
+
all_events: list[str],
|
|
284
|
+
github: FakeGitHub,
|
|
285
|
+
github_client: object,
|
|
286
|
+
repository: RepositorySettings,
|
|
287
|
+
pull_request_number: int,
|
|
288
|
+
checkout: Path,
|
|
289
|
+
manifest: Path,
|
|
290
|
+
report: Path,
|
|
291
|
+
) -> FakePublication:
|
|
292
|
+
all_events.append("publish")
|
|
293
|
+
assert github_client is github
|
|
294
|
+
assert repository.slug == "JonEcho/python-automation"
|
|
295
|
+
assert pull_request_number == 2985
|
|
296
|
+
assert checkout.is_dir()
|
|
297
|
+
assert manifest.is_file()
|
|
298
|
+
assert report.is_file()
|
|
299
|
+
return FakePublication("passed")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _build_real_runner(
|
|
303
|
+
registration: AdvisoryRegistration,
|
|
304
|
+
github: FakeGitHub,
|
|
305
|
+
) -> tuple[AutomaticAdvisoryRunner, list[str]]:
|
|
306
|
+
all_events = github.all_events
|
|
307
|
+
runner = AutomaticAdvisoryRunner(
|
|
308
|
+
_build_settings(registration),
|
|
309
|
+
github,
|
|
310
|
+
child_runner=lambda child_registration, base_ref, timeout_seconds: (
|
|
311
|
+
_run_recorded_child(
|
|
312
|
+
all_events, child_registration, base_ref, timeout_seconds
|
|
313
|
+
)
|
|
314
|
+
),
|
|
315
|
+
publisher=lambda github_client, repository, pull_request_number, checkout, manifest, report: (
|
|
316
|
+
_publish_recorded(
|
|
317
|
+
all_events,
|
|
318
|
+
github,
|
|
319
|
+
github_client,
|
|
320
|
+
repository,
|
|
321
|
+
pull_request_number,
|
|
322
|
+
checkout,
|
|
323
|
+
manifest,
|
|
324
|
+
report,
|
|
325
|
+
)
|
|
326
|
+
),
|
|
327
|
+
clock=lambda: "2026-09-05T00:00:00+00:00",
|
|
328
|
+
)
|
|
329
|
+
return runner, all_events
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_runner_executes_real_child_and_publishes_after_label_removal(
|
|
333
|
+
tmp_path: Path,
|
|
334
|
+
) -> None:
|
|
335
|
+
checkout_path, head_sha = _build_checkout(tmp_path)
|
|
336
|
+
registration = _build_registration(tmp_path, checkout_path)
|
|
337
|
+
github = FakeGitHub(_build_candidate(head_sha))
|
|
338
|
+
runner, all_events = _build_real_runner(registration, github)
|
|
339
|
+
|
|
340
|
+
state = runner.run_once()[0]
|
|
341
|
+
|
|
342
|
+
assert state.status == "passed"
|
|
343
|
+
assert all_events == ["remove-label", "child", "publish"]
|
|
344
|
+
assert (
|
|
345
|
+
json.loads(registration.state_path.read_text(encoding="utf-8"))["head_sha"]
|
|
346
|
+
== head_sha
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_runner_waits_for_matching_clean_checkout(tmp_path: Path) -> None:
|
|
351
|
+
checkout_path, head_sha = _build_checkout(tmp_path)
|
|
352
|
+
registration = _build_registration(tmp_path, checkout_path)
|
|
353
|
+
github = FakeGitHub(_build_candidate("remote-head"))
|
|
354
|
+
child_calls: list[str] = []
|
|
355
|
+
runner = _build_counting_runner(registration, github, child_calls, _publish_passed)
|
|
356
|
+
|
|
357
|
+
state = runner.run_once()[0]
|
|
358
|
+
|
|
359
|
+
assert state.status == "waiting"
|
|
360
|
+
assert state.head_sha == "remote-head"
|
|
361
|
+
assert child_calls == []
|
|
362
|
+
assert head_sha != state.head_sha
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def test_runner_skips_an_unchanged_published_identity(tmp_path: Path) -> None:
|
|
366
|
+
checkout_path, head_sha = _build_checkout(tmp_path)
|
|
367
|
+
registration = _build_registration(tmp_path, checkout_path)
|
|
368
|
+
github = FakeGitHub(_build_candidate(head_sha))
|
|
369
|
+
child_calls: list[str] = []
|
|
370
|
+
runner = _build_counting_runner(registration, github, child_calls, _publish_passed)
|
|
371
|
+
|
|
372
|
+
runner.run_once()
|
|
373
|
+
state = runner.run_once()[0]
|
|
374
|
+
|
|
375
|
+
assert state.status == "passed"
|
|
376
|
+
assert child_calls == ["child"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
TESTS_DIRECTORY = Path(__file__).resolve().parent
|
|
8
|
+
if str(TESTS_DIRECTORY) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(TESTS_DIRECTORY))
|
|
10
|
+
|
|
11
|
+
import test_automatic_advisory as support
|
|
12
|
+
import test_automatic_advisory_repairs as process_support
|
|
13
|
+
|
|
14
|
+
OWNER_EXIT_TIMEOUT_SECONDS = 10.0
|
|
15
|
+
CHILD_START_WAIT_SECONDS = 5.0
|
|
16
|
+
CHILD_START_POLL_SECONDS = 0.05
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_owner_exit_script(
|
|
20
|
+
checkout_path: Path,
|
|
21
|
+
child_identifier_path: Path,
|
|
22
|
+
) -> str:
|
|
23
|
+
child_script = (
|
|
24
|
+
"import os,time\n"
|
|
25
|
+
"from pathlib import Path\n"
|
|
26
|
+
f"Path({str(child_identifier_path)!r}).write_text(str(os.getpid()), encoding='utf-8')\n"
|
|
27
|
+
"time.sleep(30)\n"
|
|
28
|
+
)
|
|
29
|
+
return (
|
|
30
|
+
"import os,sys,time\n"
|
|
31
|
+
"from pathlib import Path\n"
|
|
32
|
+
"from types import SimpleNamespace\n"
|
|
33
|
+
"from automatic_advisory import execution\n"
|
|
34
|
+
f"registration = SimpleNamespace(checkout_path=Path({str(checkout_path)!r}))\n"
|
|
35
|
+
"scripts_directory = Path(execution.__file__).resolve().parents[1]\n"
|
|
36
|
+
"child_environment = execution._build_child_environment(registration, scripts_directory)\n"
|
|
37
|
+
"owned_child = execution._start_child_process(\n"
|
|
38
|
+
f" (sys.executable, '-c', {child_script!r}), registration, child_environment\n"
|
|
39
|
+
")\n"
|
|
40
|
+
"if owned_child is None:\n"
|
|
41
|
+
" raise SystemExit(2)\n"
|
|
42
|
+
f"deadline = time.monotonic() + {CHILD_START_WAIT_SECONDS!r}\n"
|
|
43
|
+
f"while not Path({str(child_identifier_path)!r}).is_file() and time.monotonic() < deadline:\n"
|
|
44
|
+
f" time.sleep({CHILD_START_POLL_SECONDS!r})\n"
|
|
45
|
+
f"if not Path({str(child_identifier_path)!r}).is_file():\n"
|
|
46
|
+
" raise SystemExit(3)\n"
|
|
47
|
+
"os._exit(0)\n"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_owner_exit_closes_job_and_ends_active_child(tmp_path: Path) -> None:
|
|
52
|
+
if sys.platform != "win32":
|
|
53
|
+
return
|
|
54
|
+
child_identifier_path = tmp_path / "owned-child.pid"
|
|
55
|
+
owner_script = _build_owner_exit_script(tmp_path, child_identifier_path)
|
|
56
|
+
completed_owner = subprocess.run(
|
|
57
|
+
(sys.executable, "-c", owner_script),
|
|
58
|
+
cwd=support.SCRIPTS_DIRECTORY,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
check=False,
|
|
61
|
+
text=True,
|
|
62
|
+
timeout=OWNER_EXIT_TIMEOUT_SECONDS,
|
|
63
|
+
)
|
|
64
|
+
assert child_identifier_path.is_file(), completed_owner.stderr
|
|
65
|
+
child_identifier = int(child_identifier_path.read_text(encoding="utf-8"))
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
assert completed_owner.returncode == 0, completed_owner.stderr
|
|
69
|
+
assert process_support._wait_for_process_exit(child_identifier)
|
|
70
|
+
finally:
|
|
71
|
+
process_support._end_process_for_test(child_identifier)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
TEST_DIRECTORY = Path(__file__).resolve().parent
|
|
12
|
+
if str(TEST_DIRECTORY) not in sys.path:
|
|
13
|
+
sys.path.insert(0, str(TEST_DIRECTORY))
|
|
14
|
+
|
|
15
|
+
from automatic_advisory import cli as advisory_cli
|
|
16
|
+
from automatic_advisory.model import AdvisoryState
|
|
17
|
+
from pr_verification.lock import SupervisorLockError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _CycleFailsOnceRunner:
|
|
21
|
+
def __init__(self, cycle_error: Exception) -> None:
|
|
22
|
+
self.cycle_error = cycle_error
|
|
23
|
+
self.all_cycles: list[str] = []
|
|
24
|
+
|
|
25
|
+
def run_once(self, should_rerun: bool = False) -> tuple[AdvisoryState, ...]:
|
|
26
|
+
self.all_cycles.append("cycle")
|
|
27
|
+
if len(self.all_cycles) == 1:
|
|
28
|
+
raise self.cycle_error
|
|
29
|
+
return (
|
|
30
|
+
AdvisoryState(
|
|
31
|
+
"JonEcho/python-automation",
|
|
32
|
+
3087,
|
|
33
|
+
"passed",
|
|
34
|
+
"unchanged remote head and base",
|
|
35
|
+
None,
|
|
36
|
+
None,
|
|
37
|
+
"2026-09-06T16:00:00+00:00",
|
|
38
|
+
None,
|
|
39
|
+
"report.json",
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _stop_polling_on_the_second_sleep(
|
|
45
|
+
all_sleeps: list[float],
|
|
46
|
+
) -> Callable[[float], None]:
|
|
47
|
+
def sleeper(poll_seconds: float) -> None:
|
|
48
|
+
all_sleeps.append(poll_seconds)
|
|
49
|
+
if len(all_sleeps) == 2:
|
|
50
|
+
raise KeyboardInterrupt
|
|
51
|
+
|
|
52
|
+
return sleeper
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(
|
|
56
|
+
"cycle_error",
|
|
57
|
+
[
|
|
58
|
+
SupervisorLockError("supervisor lock is held by another owner"),
|
|
59
|
+
OSError("state file is temporarily unavailable"),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
def test_polling_reports_a_failed_cycle_and_runs_the_next_cycle(
|
|
63
|
+
cycle_error: Exception,
|
|
64
|
+
tmp_path: Path,
|
|
65
|
+
) -> None:
|
|
66
|
+
advisory_runner = _CycleFailsOnceRunner(cycle_error)
|
|
67
|
+
all_sleeps: list[float] = []
|
|
68
|
+
stdout = io.StringIO()
|
|
69
|
+
|
|
70
|
+
exit_code = advisory_cli.run_polling(
|
|
71
|
+
advisory_runner,
|
|
72
|
+
1.5,
|
|
73
|
+
stdout,
|
|
74
|
+
tmp_path / "poll-errors.log",
|
|
75
|
+
_stop_polling_on_the_second_sleep(all_sleeps),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert exit_code == 0
|
|
79
|
+
assert advisory_runner.all_cycles == ["cycle", "cycle"]
|
|
80
|
+
assert all_sleeps == [1.5, 1.5]
|
|
81
|
+
all_lines = stdout.getvalue().splitlines()
|
|
82
|
+
assert json.loads(all_lines[0]) == {"poll_error": str(cycle_error)}
|
|
83
|
+
assert json.loads(all_lines[1])["status"] == "passed"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _EveryCycleFailsRunner:
|
|
87
|
+
def __init__(self, cycle_error: Exception) -> None:
|
|
88
|
+
self.cycle_error = cycle_error
|
|
89
|
+
self.all_cycles: list[str] = []
|
|
90
|
+
|
|
91
|
+
def run_once(self, should_rerun: bool = False) -> tuple[AdvisoryState, ...]:
|
|
92
|
+
self.all_cycles.append("cycle")
|
|
93
|
+
raise self.cycle_error
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _stop_polling_after(cycle_limit: int) -> Callable[[float], None]:
|
|
97
|
+
all_sleeps: list[float] = []
|
|
98
|
+
|
|
99
|
+
def sleeper(poll_seconds: float) -> None:
|
|
100
|
+
all_sleeps.append(poll_seconds)
|
|
101
|
+
if len(all_sleeps) == cycle_limit:
|
|
102
|
+
raise KeyboardInterrupt
|
|
103
|
+
|
|
104
|
+
return sleeper
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_polling_logs_the_failed_cycle_line_beside_the_state_files(
|
|
108
|
+
tmp_path: Path,
|
|
109
|
+
) -> None:
|
|
110
|
+
cycle_error = SupervisorLockError("supervisor lock is held by another owner")
|
|
111
|
+
poll_error_log_path = tmp_path / "state" / "poll-errors.log"
|
|
112
|
+
|
|
113
|
+
exit_code = advisory_cli.run_polling(
|
|
114
|
+
_EveryCycleFailsRunner(cycle_error),
|
|
115
|
+
1.5,
|
|
116
|
+
io.StringIO(),
|
|
117
|
+
poll_error_log_path,
|
|
118
|
+
_stop_polling_after(1),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
assert exit_code == 0
|
|
122
|
+
all_lines = poll_error_log_path.read_text(encoding="utf-8").splitlines()
|
|
123
|
+
assert [json.loads(each_line) for each_line in all_lines] == [
|
|
124
|
+
{"poll_error": str(cycle_error)}
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _NumberedFailuresRunner:
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
self.all_cycles: list[str] = []
|
|
131
|
+
|
|
132
|
+
def run_once(self, should_rerun: bool = False) -> tuple[AdvisoryState, ...]:
|
|
133
|
+
self.all_cycles.append("cycle")
|
|
134
|
+
raise OSError(f"state file is unavailable on cycle {len(self.all_cycles)}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_poll_error_log_keeps_the_newest_two_hundred_lines(tmp_path: Path) -> None:
|
|
138
|
+
poll_error_log_path = tmp_path / "poll-errors.log"
|
|
139
|
+
|
|
140
|
+
advisory_cli.run_polling(
|
|
141
|
+
_NumberedFailuresRunner(),
|
|
142
|
+
0.0,
|
|
143
|
+
io.StringIO(),
|
|
144
|
+
poll_error_log_path,
|
|
145
|
+
_stop_polling_after(250),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
all_lines = poll_error_log_path.read_text(encoding="utf-8").splitlines()
|
|
149
|
+
assert [json.loads(each_line)["poll_error"] for each_line in all_lines] == [
|
|
150
|
+
f"state file is unavailable on cycle {each_cycle}"
|
|
151
|
+
for each_cycle in range(51, 251)
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_poll_error_log_holding_non_utf8_bytes_leaves_the_poller_running(
|
|
156
|
+
tmp_path: Path,
|
|
157
|
+
) -> None:
|
|
158
|
+
poll_error_log_path = tmp_path / "poll-errors.log"
|
|
159
|
+
poll_error_log_path.write_bytes(b"\xff\xfe not a utf-8 line\n")
|
|
160
|
+
advisory_runner = _EveryCycleFailsRunner(OSError("state file is unavailable"))
|
|
161
|
+
|
|
162
|
+
exit_code = advisory_cli.run_polling(
|
|
163
|
+
advisory_runner,
|
|
164
|
+
0.0,
|
|
165
|
+
io.StringIO(),
|
|
166
|
+
poll_error_log_path,
|
|
167
|
+
_stop_polling_after(1),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
assert exit_code == 0
|
|
171
|
+
assert advisory_runner.all_cycles == ["cycle"]
|
|
172
|
+
all_lines = poll_error_log_path.read_text(
|
|
173
|
+
encoding="utf-8", errors="replace"
|
|
174
|
+
).splitlines()
|
|
175
|
+
assert json.loads(all_lines[-1]) == {"poll_error": "state file is unavailable"}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_poll_error_log_line_read_value_error_leaves_the_poller_running(
|
|
179
|
+
tmp_path: Path,
|
|
180
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
181
|
+
) -> None:
|
|
182
|
+
def raise_value_error(poll_error_log_path: Path) -> list[str]:
|
|
183
|
+
raise ValueError("the log holds bytes no decoder accepts")
|
|
184
|
+
|
|
185
|
+
monkeypatch.setattr(advisory_cli, "_poll_error_log_lines", raise_value_error)
|
|
186
|
+
advisory_runner = _EveryCycleFailsRunner(OSError("state file is unavailable"))
|
|
187
|
+
|
|
188
|
+
exit_code = advisory_cli.run_polling(
|
|
189
|
+
advisory_runner,
|
|
190
|
+
0.0,
|
|
191
|
+
io.StringIO(),
|
|
192
|
+
tmp_path / "poll-errors.log",
|
|
193
|
+
_stop_polling_after(2),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
assert exit_code == 0
|
|
197
|
+
assert advisory_runner.all_cycles == ["cycle", "cycle"]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_poll_error_log_that_cannot_be_written_leaves_the_poller_running(
|
|
201
|
+
tmp_path: Path,
|
|
202
|
+
) -> None:
|
|
203
|
+
unwritable_log_path = tmp_path / "state.json" / "poll-errors.log"
|
|
204
|
+
unwritable_log_path.parent.write_text("not a directory\n", encoding="utf-8")
|
|
205
|
+
advisory_runner = _EveryCycleFailsRunner(OSError("state file is unavailable"))
|
|
206
|
+
|
|
207
|
+
exit_code = advisory_cli.run_polling(
|
|
208
|
+
advisory_runner,
|
|
209
|
+
0.0,
|
|
210
|
+
io.StringIO(),
|
|
211
|
+
unwritable_log_path,
|
|
212
|
+
_stop_polling_after(2),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
assert exit_code == 0
|
|
216
|
+
assert advisory_runner.all_cycles == ["cycle", "cycle"]
|