patchshuttle 0.1.0a2__py3-none-any.whl
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.
- patchshuttle/__init__.py +98 -0
- patchshuttle/_diff.py +317 -0
- patchshuttle/_process.py +198 -0
- patchshuttle/_version.py +3 -0
- patchshuttle/actions/__init__.py +80 -0
- patchshuttle/actions/constructors.py +211 -0
- patchshuttle/actions/create.py +155 -0
- patchshuttle/actions/modify.py +174 -0
- patchshuttle/audit.py +588 -0
- patchshuttle/backup.py +712 -0
- patchshuttle/checks/__init__.py +37 -0
- patchshuttle/checks/constructors.py +67 -0
- patchshuttle/checks/runner.py +233 -0
- patchshuttle/cli.py +766 -0
- patchshuttle/config.py +247 -0
- patchshuttle/context.py +370 -0
- patchshuttle/errors.py +291 -0
- patchshuttle/execution.py +651 -0
- patchshuttle/formatters/__init__.py +25 -0
- patchshuttle/formatters/runner.py +240 -0
- patchshuttle/identifiers.py +20 -0
- patchshuttle/inventory.py +331 -0
- patchshuttle/logging.py +741 -0
- patchshuttle/models.py +496 -0
- patchshuttle/operations.py +292 -0
- patchshuttle/parser.py +243 -0
- patchshuttle/planner.py +1144 -0
- patchshuttle/policy.py +377 -0
- patchshuttle/py.typed +1 -0
- patchshuttle/registry.py +275 -0
- patchshuttle/resources/AI_GUIDE.md +163 -0
- patchshuttle/resources/AUDIT-EXAMPLE.psh.yaml +10 -0
- patchshuttle/resources/PATCH-EXAMPLE.psh.yaml +17 -0
- patchshuttle/resources/PATCHSHUTTLE_PROTOCOL.md +109 -0
- patchshuttle/resources/__init__.py +1 -0
- patchshuttle/rollback.py +306 -0
- patchshuttle/runner.py +880 -0
- patchshuttle/verification.py +107 -0
- patchshuttle/workspace.py +382 -0
- patchshuttle-0.1.0a2.dist-info/METADATA +535 -0
- patchshuttle-0.1.0a2.dist-info/RECORD +44 -0
- patchshuttle-0.1.0a2.dist-info/WHEEL +4 -0
- patchshuttle-0.1.0a2.dist-info/entry_points.txt +2 -0
- patchshuttle-0.1.0a2.dist-info/licenses/LICENSE +21 -0
patchshuttle/cli.py
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
"""Command-line entry point for PatchShuttle."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import NoReturn
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from patchshuttle._version import __version__
|
|
9
|
+
from patchshuttle.context import create_handoff, create_snapshot
|
|
10
|
+
from patchshuttle.errors import (
|
|
11
|
+
ExecutionError,
|
|
12
|
+
ExecutionErrorCode,
|
|
13
|
+
JobError,
|
|
14
|
+
PlanningError,
|
|
15
|
+
PlanningErrorCode,
|
|
16
|
+
PolicyError,
|
|
17
|
+
WorkspaceError,
|
|
18
|
+
)
|
|
19
|
+
from patchshuttle.execution import (
|
|
20
|
+
RegisteredRunResult,
|
|
21
|
+
RunResult,
|
|
22
|
+
execute_plan,
|
|
23
|
+
execution_exit_code,
|
|
24
|
+
record_declined_plan,
|
|
25
|
+
resolve_registered_job,
|
|
26
|
+
)
|
|
27
|
+
from patchshuttle.logging import latest_log_path
|
|
28
|
+
from patchshuttle.models import Job, JobKind
|
|
29
|
+
from patchshuttle.operations import ManualRollbackResult, rollback_job
|
|
30
|
+
from patchshuttle.parser import load_job
|
|
31
|
+
from patchshuttle.planner import Plan, plan_job
|
|
32
|
+
from patchshuttle.registry import RegistryJobRecord, get_job, load_registry
|
|
33
|
+
from patchshuttle.workspace import (
|
|
34
|
+
CONFIG_RELATIVE_PATH,
|
|
35
|
+
Workspace,
|
|
36
|
+
discover_workspace,
|
|
37
|
+
init_workspace,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group(
|
|
42
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
43
|
+
no_args_is_help=True,
|
|
44
|
+
)
|
|
45
|
+
@click.version_option(version=__version__, prog_name="patchshuttle")
|
|
46
|
+
def main() -> None:
|
|
47
|
+
"""Run local, auditable workflows for AI-assisted project changes."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
def version() -> None:
|
|
52
|
+
"""Print the installed PatchShuttle version."""
|
|
53
|
+
|
|
54
|
+
click.echo(f"PatchShuttle {__version__}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@main.command("init")
|
|
58
|
+
@click.option(
|
|
59
|
+
"--new-project",
|
|
60
|
+
is_flag=True,
|
|
61
|
+
help="Require an empty directory and record a new-project workspace.",
|
|
62
|
+
)
|
|
63
|
+
def init_command(new_project: bool) -> None:
|
|
64
|
+
"""Initialize the current project without overwriting existing entries."""
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
result = init_workspace(Path.cwd(), new_project=new_project)
|
|
68
|
+
except WorkspaceError as error:
|
|
69
|
+
click.echo(f"INIT_FAILED {error}", err=True)
|
|
70
|
+
raise click.exceptions.Exit(3) from error
|
|
71
|
+
|
|
72
|
+
click.echo(
|
|
73
|
+
"\n".join(
|
|
74
|
+
(
|
|
75
|
+
result.status.value,
|
|
76
|
+
f"project_id: {result.workspace.project_id}",
|
|
77
|
+
f"origin: {result.workspace.origin.value}",
|
|
78
|
+
f"config: {CONFIG_RELATIVE_PATH.as_posix()}",
|
|
79
|
+
f"created_entries: {len(result.created_paths)}",
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@main.command("validate")
|
|
86
|
+
@click.argument("job_file", type=click.Path(path_type=Path))
|
|
87
|
+
def validate_command(job_file: Path) -> None:
|
|
88
|
+
"""Validate JOB_FILE against the current workspace without changing files."""
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
workspace = discover_workspace(Path.cwd())
|
|
92
|
+
except WorkspaceError as error:
|
|
93
|
+
click.echo(f"INVALID {error}", err=True)
|
|
94
|
+
raise click.exceptions.Exit(3) from error
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
job = load_job(
|
|
98
|
+
job_file,
|
|
99
|
+
max_bytes=workspace.config.execution.max_job_bytes,
|
|
100
|
+
)
|
|
101
|
+
except JobError as error:
|
|
102
|
+
click.echo(f"INVALID {error}", err=True)
|
|
103
|
+
raise click.exceptions.Exit(2) from error
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
workspace.require_project_id(job.project_id)
|
|
107
|
+
except WorkspaceError as error:
|
|
108
|
+
click.echo(f"INVALID {error}", err=True)
|
|
109
|
+
raise click.exceptions.Exit(3) from error
|
|
110
|
+
|
|
111
|
+
click.echo(
|
|
112
|
+
"\n".join(
|
|
113
|
+
(
|
|
114
|
+
"VALID",
|
|
115
|
+
f"job_id: {job.id}",
|
|
116
|
+
f"kind: {job.kind.value}",
|
|
117
|
+
f"protocol: {job.protocol}",
|
|
118
|
+
f"project_id: {job.project_id}",
|
|
119
|
+
f"actions: {len(job.actions)}",
|
|
120
|
+
f"checks: {len(job.checks)}",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@main.command("plan")
|
|
127
|
+
@click.argument("job_file", type=click.Path(path_type=Path))
|
|
128
|
+
def plan_command(job_file: Path) -> None:
|
|
129
|
+
"""Plan JOB_FILE completely without changing the workspace."""
|
|
130
|
+
|
|
131
|
+
click.echo(_render_plan(_load_plan(job_file, failure_prefix="PLAN_FAILED")))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@main.command("logs")
|
|
135
|
+
@click.option(
|
|
136
|
+
"--last",
|
|
137
|
+
"show_last",
|
|
138
|
+
is_flag=True,
|
|
139
|
+
help="Print the path to the latest PatchShuttle run log.",
|
|
140
|
+
)
|
|
141
|
+
def logs_command(show_last: bool) -> None:
|
|
142
|
+
"""Locate generated PatchShuttle run logs."""
|
|
143
|
+
|
|
144
|
+
if not show_last:
|
|
145
|
+
raise click.UsageError("the --last option is required")
|
|
146
|
+
try:
|
|
147
|
+
workspace = discover_workspace(Path.cwd())
|
|
148
|
+
path = latest_log_path(workspace)
|
|
149
|
+
except WorkspaceError as error:
|
|
150
|
+
click.echo(f"LOGS_FAILED {error}", err=True)
|
|
151
|
+
raise click.exceptions.Exit(3) from error
|
|
152
|
+
except ExecutionError as error:
|
|
153
|
+
click.echo(f"LOGS_FAILED {error}", err=True)
|
|
154
|
+
raise click.exceptions.Exit(execution_exit_code(error.code)) from error
|
|
155
|
+
click.echo(path.as_posix())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@main.command("snapshot")
|
|
159
|
+
def snapshot_command() -> None:
|
|
160
|
+
"""Create a bounded read-only project metadata snapshot."""
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
workspace = discover_workspace(Path.cwd())
|
|
164
|
+
result = create_snapshot(workspace)
|
|
165
|
+
except WorkspaceError as error:
|
|
166
|
+
click.echo(f"SNAPSHOT_FAILED {error}", err=True)
|
|
167
|
+
raise click.exceptions.Exit(3) from error
|
|
168
|
+
except ExecutionError as error:
|
|
169
|
+
click.echo(
|
|
170
|
+
_render_execution_error(error, prefix="SNAPSHOT_FAILED"),
|
|
171
|
+
err=True,
|
|
172
|
+
)
|
|
173
|
+
raise click.exceptions.Exit(execution_exit_code(error.code)) from error
|
|
174
|
+
click.echo(
|
|
175
|
+
"\n".join(
|
|
176
|
+
(
|
|
177
|
+
"SNAPSHOT_CREATED",
|
|
178
|
+
f"inventory_entries: {result.inventory_entries}",
|
|
179
|
+
f"output_truncated: {str(result.output_truncated).lower()}",
|
|
180
|
+
f"log: {result.path.as_posix()}",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@main.command("handoff")
|
|
187
|
+
def handoff_command() -> None:
|
|
188
|
+
"""Create one upload-friendly context log for an AI service."""
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
workspace = discover_workspace(Path.cwd())
|
|
192
|
+
result = create_handoff(workspace)
|
|
193
|
+
except WorkspaceError as error:
|
|
194
|
+
click.echo(f"HANDOFF_FAILED {error}", err=True)
|
|
195
|
+
raise click.exceptions.Exit(3) from error
|
|
196
|
+
except ExecutionError as error:
|
|
197
|
+
click.echo(
|
|
198
|
+
_render_execution_error(error, prefix="HANDOFF_FAILED"),
|
|
199
|
+
err=True,
|
|
200
|
+
)
|
|
201
|
+
raise click.exceptions.Exit(execution_exit_code(error.code)) from error
|
|
202
|
+
click.echo(
|
|
203
|
+
"\n".join(
|
|
204
|
+
(
|
|
205
|
+
"HANDOFF_CREATED",
|
|
206
|
+
f"inventory_entries: {result.inventory_entries}",
|
|
207
|
+
f"recent_jobs: {result.recent_jobs}",
|
|
208
|
+
f"output_truncated: {str(result.output_truncated).lower()}",
|
|
209
|
+
f"log: {result.path.as_posix()}",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@main.command("status")
|
|
216
|
+
@click.argument("job_id", required=False)
|
|
217
|
+
def status_command(job_id: str | None) -> None:
|
|
218
|
+
"""Show project-local registry state and the latest log path."""
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
workspace = discover_workspace(Path.cwd())
|
|
222
|
+
registry = load_registry(workspace)
|
|
223
|
+
latest = _optional_latest_log(workspace)
|
|
224
|
+
selected = get_job(registry, job_id) if job_id is not None else None
|
|
225
|
+
except WorkspaceError as error:
|
|
226
|
+
click.echo(f"STATUS_FAILED {error}", err=True)
|
|
227
|
+
raise click.exceptions.Exit(3) from error
|
|
228
|
+
except ExecutionError as error:
|
|
229
|
+
click.echo(f"STATUS_FAILED {error}", err=True)
|
|
230
|
+
raise click.exceptions.Exit(execution_exit_code(error.code)) from error
|
|
231
|
+
|
|
232
|
+
lines = [
|
|
233
|
+
"STATUS",
|
|
234
|
+
f"project_id: {registry.project_id}",
|
|
235
|
+
f"latest_log: {latest.as_posix() if latest is not None else 'none'}",
|
|
236
|
+
]
|
|
237
|
+
if selected is not None:
|
|
238
|
+
lines.extend(_render_registry_record(selected))
|
|
239
|
+
else:
|
|
240
|
+
records = sorted(
|
|
241
|
+
registry.jobs.values(),
|
|
242
|
+
key=lambda item: (item.latest_run_at, item.job_id),
|
|
243
|
+
reverse=True,
|
|
244
|
+
)
|
|
245
|
+
lines.append(f"jobs: {len(records)}")
|
|
246
|
+
lines.extend(
|
|
247
|
+
f" - {item.job_id} {item.latest_result} {item.job_hash[:8]}"
|
|
248
|
+
for item in records
|
|
249
|
+
)
|
|
250
|
+
click.echo("\n".join(lines))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@main.command("rollback")
|
|
254
|
+
@click.argument("job_id")
|
|
255
|
+
@click.option(
|
|
256
|
+
"--yes",
|
|
257
|
+
is_flag=True,
|
|
258
|
+
help="Roll back the completed job without an interactive prompt.",
|
|
259
|
+
)
|
|
260
|
+
def rollback_command(job_id: str, yes: bool) -> None:
|
|
261
|
+
"""Restore one completed patch from its retained backup manifest."""
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
workspace = discover_workspace(Path.cwd())
|
|
265
|
+
except WorkspaceError as error:
|
|
266
|
+
click.echo(f"ROLLBACK_FAILED {error}", err=True)
|
|
267
|
+
raise click.exceptions.Exit(3) from error
|
|
268
|
+
approved = yes
|
|
269
|
+
if not approved:
|
|
270
|
+
try:
|
|
271
|
+
approved = click.confirm(f"Roll back {job_id}?", default=False)
|
|
272
|
+
except click.Abort:
|
|
273
|
+
approved = False
|
|
274
|
+
if not approved:
|
|
275
|
+
error = ExecutionError(
|
|
276
|
+
ExecutionErrorCode.USER_DECLINED,
|
|
277
|
+
"user declined manual rollback",
|
|
278
|
+
item_id=job_id,
|
|
279
|
+
)
|
|
280
|
+
click.echo(
|
|
281
|
+
_render_execution_error(error, prefix="ROLLBACK_FAILED"),
|
|
282
|
+
err=True,
|
|
283
|
+
)
|
|
284
|
+
raise click.exceptions.Exit(4)
|
|
285
|
+
try:
|
|
286
|
+
result = rollback_job(workspace, job_id, approved=True)
|
|
287
|
+
except ExecutionError as error:
|
|
288
|
+
click.echo(
|
|
289
|
+
_render_execution_error(error, prefix="ROLLBACK_FAILED"),
|
|
290
|
+
err=True,
|
|
291
|
+
)
|
|
292
|
+
raise click.exceptions.Exit(execution_exit_code(error.code)) from error
|
|
293
|
+
click.echo(_render_manual_rollback_result(result))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@main.command("run")
|
|
297
|
+
@click.argument("job_file", type=click.Path(path_type=Path))
|
|
298
|
+
@click.option(
|
|
299
|
+
"--yes",
|
|
300
|
+
is_flag=True,
|
|
301
|
+
help="Execute an approved plan without an interactive prompt.",
|
|
302
|
+
)
|
|
303
|
+
@click.option(
|
|
304
|
+
"--keep-changes",
|
|
305
|
+
is_flag=True,
|
|
306
|
+
help="Keep partial project changes if a patch job fails.",
|
|
307
|
+
)
|
|
308
|
+
def run_command(job_file: Path, yes: bool, keep_changes: bool) -> None:
|
|
309
|
+
"""Plan and execute one audit, patch, or verify JOB_FILE."""
|
|
310
|
+
|
|
311
|
+
_execute_job_command(
|
|
312
|
+
job_file,
|
|
313
|
+
yes=yes,
|
|
314
|
+
keep_changes=keep_changes,
|
|
315
|
+
failure_prefix="RUN_FAILED",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@main.command("audit")
|
|
320
|
+
@click.argument("job_file", type=click.Path(path_type=Path))
|
|
321
|
+
def audit_command(job_file: Path) -> None:
|
|
322
|
+
"""Execute one read-only audit JOB_FILE without confirmation."""
|
|
323
|
+
|
|
324
|
+
_execute_job_command(
|
|
325
|
+
job_file,
|
|
326
|
+
yes=True,
|
|
327
|
+
expected_kind=JobKind.AUDIT,
|
|
328
|
+
failure_prefix="AUDIT_FAILED",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@main.command("verify")
|
|
333
|
+
@click.argument("job_file", type=click.Path(path_type=Path))
|
|
334
|
+
@click.option(
|
|
335
|
+
"--yes",
|
|
336
|
+
is_flag=True,
|
|
337
|
+
help="Run approved project checks without an interactive prompt.",
|
|
338
|
+
)
|
|
339
|
+
def verify_command(job_file: Path, yes: bool) -> None:
|
|
340
|
+
"""Execute one approved verify JOB_FILE."""
|
|
341
|
+
|
|
342
|
+
_execute_job_command(
|
|
343
|
+
job_file,
|
|
344
|
+
yes=yes,
|
|
345
|
+
expected_kind=JobKind.VERIFY,
|
|
346
|
+
failure_prefix="VERIFY_FAILED",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _execute_job_command(
|
|
351
|
+
job_file: Path,
|
|
352
|
+
*,
|
|
353
|
+
yes: bool,
|
|
354
|
+
failure_prefix: str,
|
|
355
|
+
expected_kind: JobKind | None = None,
|
|
356
|
+
keep_changes: bool = False,
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Shared universal execution flow for run, audit, and verify."""
|
|
359
|
+
|
|
360
|
+
workspace, job = _load_workspace_job(job_file, failure_prefix=failure_prefix)
|
|
361
|
+
if expected_kind is not None and job.kind is not expected_kind:
|
|
362
|
+
error = ExecutionError(
|
|
363
|
+
ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
|
|
364
|
+
f"this command requires a {expected_kind.value} job",
|
|
365
|
+
)
|
|
366
|
+
click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
|
|
367
|
+
raise click.exceptions.Exit(execution_exit_code(error.code))
|
|
368
|
+
try:
|
|
369
|
+
registered = resolve_registered_job(
|
|
370
|
+
workspace,
|
|
371
|
+
job,
|
|
372
|
+
source_path=job_file,
|
|
373
|
+
)
|
|
374
|
+
except ExecutionError as error:
|
|
375
|
+
click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
|
|
376
|
+
raise click.exceptions.Exit(execution_exit_code(error.code)) from error
|
|
377
|
+
if registered is not None:
|
|
378
|
+
click.echo(_render_registered_result(registered))
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
plan = _plan_loaded_job(
|
|
382
|
+
workspace,
|
|
383
|
+
job,
|
|
384
|
+
failure_prefix=failure_prefix,
|
|
385
|
+
)
|
|
386
|
+
click.echo(_render_plan(plan))
|
|
387
|
+
|
|
388
|
+
if keep_changes and job.kind is not JobKind.PATCH:
|
|
389
|
+
error = ExecutionError(
|
|
390
|
+
ExecutionErrorCode.JOB_KIND_UNSUPPORTED,
|
|
391
|
+
"--keep-changes is supported only for patch jobs",
|
|
392
|
+
)
|
|
393
|
+
click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
|
|
394
|
+
raise click.exceptions.Exit(execution_exit_code(error.code))
|
|
395
|
+
if keep_changes and not workspace.config.execution.allow_keep_changes:
|
|
396
|
+
error = ExecutionError(
|
|
397
|
+
ExecutionErrorCode.KEEP_CHANGES_FORBIDDEN,
|
|
398
|
+
"local workspace policy does not allow keeping failed-job changes",
|
|
399
|
+
)
|
|
400
|
+
click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
|
|
401
|
+
raise click.exceptions.Exit(execution_exit_code(error.code))
|
|
402
|
+
|
|
403
|
+
if plan.requires_confirmation:
|
|
404
|
+
click.echo(
|
|
405
|
+
"WARNING: Project checks execute local project code. PatchShuttle is not "
|
|
406
|
+
"an OS sandbox. Review the job and changed files before continuing."
|
|
407
|
+
)
|
|
408
|
+
if not _approve_run(yes):
|
|
409
|
+
_decline_job(plan, job_file, failure_prefix=failure_prefix)
|
|
410
|
+
|
|
411
|
+
if keep_changes:
|
|
412
|
+
click.echo(
|
|
413
|
+
"WARNING: --keep-changes disables automatic rollback for this run. "
|
|
414
|
+
"A failed patch may leave partial project changes in place."
|
|
415
|
+
)
|
|
416
|
+
if not _approve_keep_changes(yes):
|
|
417
|
+
_decline_job(plan, job_file, failure_prefix=failure_prefix)
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
result = execute_plan(
|
|
421
|
+
plan,
|
|
422
|
+
approved=True,
|
|
423
|
+
keep_changes=keep_changes,
|
|
424
|
+
source_path=job_file,
|
|
425
|
+
)
|
|
426
|
+
except ExecutionError as error:
|
|
427
|
+
click.echo(_render_execution_error(error, prefix=failure_prefix), err=True)
|
|
428
|
+
raise click.exceptions.Exit(execution_exit_code(error.code)) from error
|
|
429
|
+
|
|
430
|
+
click.echo(_render_run_result(result))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _load_plan(job_file: Path, *, failure_prefix: str) -> Plan:
|
|
434
|
+
"""Load and plan a job with the stable CLI exit-code mapping."""
|
|
435
|
+
|
|
436
|
+
workspace, job = _load_workspace_job(job_file, failure_prefix=failure_prefix)
|
|
437
|
+
return _plan_loaded_job(workspace, job, failure_prefix=failure_prefix)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _load_workspace_job(
|
|
441
|
+
job_file: Path,
|
|
442
|
+
*,
|
|
443
|
+
failure_prefix: str,
|
|
444
|
+
) -> tuple[Workspace, Job]:
|
|
445
|
+
try:
|
|
446
|
+
workspace = discover_workspace(Path.cwd())
|
|
447
|
+
except WorkspaceError as error:
|
|
448
|
+
click.echo(f"{failure_prefix} {error}", err=True)
|
|
449
|
+
raise click.exceptions.Exit(3) from error
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
job = load_job(
|
|
453
|
+
job_file,
|
|
454
|
+
max_bytes=workspace.config.execution.max_job_bytes,
|
|
455
|
+
)
|
|
456
|
+
except JobError as error:
|
|
457
|
+
click.echo(f"{failure_prefix} {error}", err=True)
|
|
458
|
+
raise click.exceptions.Exit(2) from error
|
|
459
|
+
try:
|
|
460
|
+
workspace.require_project_id(job.project_id)
|
|
461
|
+
except WorkspaceError as error:
|
|
462
|
+
click.echo(f"{failure_prefix} {error}", err=True)
|
|
463
|
+
raise click.exceptions.Exit(3) from error
|
|
464
|
+
return workspace, job
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _plan_loaded_job(
|
|
468
|
+
workspace: Workspace,
|
|
469
|
+
job: Job,
|
|
470
|
+
*,
|
|
471
|
+
failure_prefix: str,
|
|
472
|
+
) -> Plan:
|
|
473
|
+
try:
|
|
474
|
+
plan = plan_job(job, workspace)
|
|
475
|
+
except WorkspaceError as error:
|
|
476
|
+
click.echo(f"{failure_prefix} {error}", err=True)
|
|
477
|
+
raise click.exceptions.Exit(3) from error
|
|
478
|
+
except PolicyError as error:
|
|
479
|
+
click.echo(f"{failure_prefix} {error}", err=True)
|
|
480
|
+
raise click.exceptions.Exit(4) from error
|
|
481
|
+
except PlanningError as error:
|
|
482
|
+
click.echo(f"{failure_prefix} {error}", err=True)
|
|
483
|
+
raise click.exceptions.Exit(_planning_exit_code(error.code)) from error
|
|
484
|
+
|
|
485
|
+
return plan
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _approve_run(yes: bool) -> bool:
|
|
489
|
+
if yes:
|
|
490
|
+
return True
|
|
491
|
+
try:
|
|
492
|
+
return click.confirm("Apply this job?", default=False)
|
|
493
|
+
except click.Abort:
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _approve_keep_changes(yes: bool) -> bool:
|
|
498
|
+
if yes:
|
|
499
|
+
return True
|
|
500
|
+
try:
|
|
501
|
+
return click.confirm(
|
|
502
|
+
"Keep partial changes if this job fails?",
|
|
503
|
+
default=False,
|
|
504
|
+
)
|
|
505
|
+
except click.Abort:
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _decline_job(
|
|
510
|
+
plan: Plan,
|
|
511
|
+
job_file: Path,
|
|
512
|
+
*,
|
|
513
|
+
failure_prefix: str,
|
|
514
|
+
) -> NoReturn:
|
|
515
|
+
try:
|
|
516
|
+
error = record_declined_plan(plan, source_path=job_file)
|
|
517
|
+
except ExecutionError as record_error:
|
|
518
|
+
click.echo(
|
|
519
|
+
_render_execution_error(record_error, prefix=failure_prefix),
|
|
520
|
+
err=True,
|
|
521
|
+
)
|
|
522
|
+
raise click.exceptions.Exit(
|
|
523
|
+
execution_exit_code(record_error.code)
|
|
524
|
+
) from record_error
|
|
525
|
+
click.echo(
|
|
526
|
+
_render_execution_error(error, prefix=failure_prefix),
|
|
527
|
+
err=True,
|
|
528
|
+
)
|
|
529
|
+
raise click.exceptions.Exit(execution_exit_code(error.code))
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _render_execution_error(
|
|
533
|
+
error: ExecutionError,
|
|
534
|
+
*,
|
|
535
|
+
prefix: str = "RUN_FAILED",
|
|
536
|
+
) -> str:
|
|
537
|
+
lines = [f"{prefix} {error}"]
|
|
538
|
+
if error.backup_path is not None:
|
|
539
|
+
lines.append(f"backup: {error.backup_path.as_posix()}")
|
|
540
|
+
if error.log_path is not None:
|
|
541
|
+
lines.append(f"log: {error.log_path.as_posix()}")
|
|
542
|
+
if error.archived_job_path is not None:
|
|
543
|
+
lines.append(f"archived_job: {error.archived_job_path.as_posix()}")
|
|
544
|
+
if error.rollback_skipped:
|
|
545
|
+
rollback = (
|
|
546
|
+
"SKIPPED_CHANGES_KEPT" if error.changes_kept else "SKIPPED_NO_CHANGES"
|
|
547
|
+
)
|
|
548
|
+
else:
|
|
549
|
+
rollback = {
|
|
550
|
+
None: "NOT_STARTED",
|
|
551
|
+
True: "SUCCESS",
|
|
552
|
+
False: "FAILED",
|
|
553
|
+
}[error.rollback_succeeded]
|
|
554
|
+
lines.append(f"rollback: {rollback}")
|
|
555
|
+
lines.extend(
|
|
556
|
+
f"check: {result.id} {result.name} {result.status.value}"
|
|
557
|
+
for result in error.check_results
|
|
558
|
+
)
|
|
559
|
+
lines.extend(
|
|
560
|
+
f"formatter: {result.id} {result.name} {result.status.value}"
|
|
561
|
+
for result in error.formatting_results
|
|
562
|
+
)
|
|
563
|
+
if error.workspace_comparison is not None:
|
|
564
|
+
unexpected = error.workspace_comparison.unexpected_changes
|
|
565
|
+
lines.append(f"unexpected_workspace_changes: {len(unexpected)}")
|
|
566
|
+
lines.extend(
|
|
567
|
+
f" - {change.kind.value} {change.path.as_posix()}" for change in unexpected
|
|
568
|
+
)
|
|
569
|
+
return "\n".join(lines)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _render_run_result(result: RunResult) -> str:
|
|
573
|
+
plan = result.plan
|
|
574
|
+
lines = [
|
|
575
|
+
result.status.value,
|
|
576
|
+
f"project_id: {plan.job.project_id}",
|
|
577
|
+
f"job_id: {plan.job.id}",
|
|
578
|
+
f"job_hash: {plan.job_hash}",
|
|
579
|
+
"backup: "
|
|
580
|
+
+ (result.backup_path.as_posix() if result.backup_path is not None else "none"),
|
|
581
|
+
]
|
|
582
|
+
_append_path_list(lines, "created_files", result.created_files)
|
|
583
|
+
_append_path_list(lines, "modified_files", result.modified_files)
|
|
584
|
+
_append_path_list(lines, "created_directories", result.created_directories)
|
|
585
|
+
lines.append(f"initial_checks: {len(result.initial_checks)}")
|
|
586
|
+
lines.extend(
|
|
587
|
+
f" - {item.id} {item.name} {item.status.value}"
|
|
588
|
+
for item in result.initial_checks
|
|
589
|
+
)
|
|
590
|
+
lines.append(f"formatters: {len(result.formatting_results)}")
|
|
591
|
+
lines.extend(
|
|
592
|
+
f" - {item.id} {item.name} {item.status.value}"
|
|
593
|
+
for item in result.formatting_results
|
|
594
|
+
)
|
|
595
|
+
lines.append(f"final_checks: {len(result.final_checks)}")
|
|
596
|
+
lines.extend(
|
|
597
|
+
f" - {item.id} {item.name} {item.status.value}" for item in result.final_checks
|
|
598
|
+
)
|
|
599
|
+
lines.append(f"audit_results: {len(result.audit_results)}")
|
|
600
|
+
for item in result.audit_results:
|
|
601
|
+
lines.extend(
|
|
602
|
+
(
|
|
603
|
+
f" - {item.id} {item.name} {item.status}",
|
|
604
|
+
" output:",
|
|
605
|
+
*(f" {line}" for line in item.output.splitlines()),
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
comparison = result.workspace_comparison
|
|
609
|
+
lines.append(
|
|
610
|
+
"workspace_comparison: "
|
|
611
|
+
+ (
|
|
612
|
+
"NOT_APPLICABLE"
|
|
613
|
+
if comparison is None
|
|
614
|
+
else ("PASS" if comparison.success else "UNEXPECTED_CHANGES")
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
lines.append(f"workspace_changes: {len(comparison.changes) if comparison else 0}")
|
|
618
|
+
lines.append(
|
|
619
|
+
"unexpected_workspace_changes: "
|
|
620
|
+
f"{len(comparison.unexpected_changes) if comparison else 0}"
|
|
621
|
+
)
|
|
622
|
+
if result.log_path is not None:
|
|
623
|
+
lines.append(f"log: {result.log_path.as_posix()}")
|
|
624
|
+
if result.archived_job_path is not None:
|
|
625
|
+
lines.append(f"archived_job: {result.archived_job_path.as_posix()}")
|
|
626
|
+
return "\n".join(lines)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _render_registered_result(result: RegisteredRunResult) -> str:
|
|
630
|
+
return "\n".join(
|
|
631
|
+
(
|
|
632
|
+
result.status.value,
|
|
633
|
+
f"project_id: {result.job.project_id}",
|
|
634
|
+
f"job_id: {result.job.id}",
|
|
635
|
+
f"job_hash: {result.job_hash}",
|
|
636
|
+
"backup: none",
|
|
637
|
+
f"log: {result.log_path.as_posix()}",
|
|
638
|
+
f"archived_job: {result.archived_job_path.as_posix()}",
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _render_manual_rollback_result(result: ManualRollbackResult) -> str:
|
|
644
|
+
lines = [
|
|
645
|
+
"ROLLED_BACK",
|
|
646
|
+
f"job_id: {result.job_id}",
|
|
647
|
+
f"job_hash: {result.job_hash}",
|
|
648
|
+
f"backup: {result.backup_path.as_posix()}",
|
|
649
|
+
]
|
|
650
|
+
_append_path_list(lines, "restored_files", result.restored_files)
|
|
651
|
+
_append_path_list(lines, "removed_files", result.removed_files)
|
|
652
|
+
_append_path_list(lines, "removed_directories", result.removed_directories)
|
|
653
|
+
lines.append(f"log: {result.log_path.as_posix()}")
|
|
654
|
+
return "\n".join(lines)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _optional_latest_log(workspace: Workspace) -> Path | None:
|
|
658
|
+
try:
|
|
659
|
+
return latest_log_path(workspace)
|
|
660
|
+
except ExecutionError as error:
|
|
661
|
+
if error.code is ExecutionErrorCode.LOG_NOT_FOUND:
|
|
662
|
+
return None
|
|
663
|
+
raise
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _render_registry_record(record: RegistryJobRecord) -> list[str]:
|
|
667
|
+
return [
|
|
668
|
+
f"job_id: {record.job_id}",
|
|
669
|
+
f"job_hash: {record.job_hash}",
|
|
670
|
+
f"kind: {record.kind}",
|
|
671
|
+
f"first_run_at: {record.first_run_at}",
|
|
672
|
+
f"latest_run_at: {record.latest_run_at}",
|
|
673
|
+
f"latest_result: {record.latest_result}",
|
|
674
|
+
f"backup: {record.backup_reference or 'none'}",
|
|
675
|
+
f"rollback: {record.rollback_state}",
|
|
676
|
+
f"archived_job: {record.archived_job_copy}",
|
|
677
|
+
f"completed: {str(record.completed).lower()}",
|
|
678
|
+
f"run_count: {record.run_count}",
|
|
679
|
+
]
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
_POLICY_PLANNING_CODES = frozenset(
|
|
683
|
+
{
|
|
684
|
+
PlanningErrorCode.ACTION_LIMIT_EXCEEDED,
|
|
685
|
+
PlanningErrorCode.PATCH_CHECK_REQUIRED,
|
|
686
|
+
PlanningErrorCode.PATH_IGNORED,
|
|
687
|
+
PlanningErrorCode.FILE_SIZE_LIMIT_EXCEEDED,
|
|
688
|
+
PlanningErrorCode.FILE_BINARY,
|
|
689
|
+
PlanningErrorCode.FILE_ENCODING_UNSUPPORTED,
|
|
690
|
+
PlanningErrorCode.FILE_NEWLINE_UNSUPPORTED,
|
|
691
|
+
PlanningErrorCode.CONTENT_BINARY_FORBIDDEN,
|
|
692
|
+
PlanningErrorCode.PYTEST_ARGUMENT_FORBIDDEN,
|
|
693
|
+
PlanningErrorCode.CHECK_ARGUMENT_INVALID,
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _planning_exit_code(code: PlanningErrorCode) -> int:
|
|
699
|
+
if code in {
|
|
700
|
+
PlanningErrorCode.CHECK_PROFILE_NOT_FOUND,
|
|
701
|
+
PlanningErrorCode.DEPENDENCY_NOT_AVAILABLE,
|
|
702
|
+
}:
|
|
703
|
+
return 9
|
|
704
|
+
if code in _POLICY_PLANNING_CODES:
|
|
705
|
+
return 4
|
|
706
|
+
return 5
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _render_plan(plan: Plan) -> str:
|
|
710
|
+
lines = [
|
|
711
|
+
"PLAN",
|
|
712
|
+
f"project_id: {plan.job.project_id}",
|
|
713
|
+
f"job_id: {plan.job.id}",
|
|
714
|
+
f"job_hash: {plan.job_hash}",
|
|
715
|
+
f"kind: {plan.job.kind.value}",
|
|
716
|
+
f"planned_actions: {len(plan.actions)}",
|
|
717
|
+
]
|
|
718
|
+
lines.extend(
|
|
719
|
+
f" - {action.id} {action.name} {action.disposition.value}: "
|
|
720
|
+
f"{_path_summary(action.paths)}"
|
|
721
|
+
for action in plan.actions
|
|
722
|
+
)
|
|
723
|
+
_append_path_list(lines, "files_to_create", plan.files_to_create)
|
|
724
|
+
_append_path_list(lines, "files_to_modify", plan.files_to_modify)
|
|
725
|
+
_append_path_list(lines, "directories_to_create", plan.directories_to_create)
|
|
726
|
+
lines.append(f"requested_checks: {len(plan.checks)}")
|
|
727
|
+
lines.extend(
|
|
728
|
+
f" - {check.id} {check.name}: {_path_summary(check.paths)}"
|
|
729
|
+
for check in plan.checks
|
|
730
|
+
)
|
|
731
|
+
_append_path_list(lines, "formatting_scope", plan.formatting_targets)
|
|
732
|
+
lines.append(
|
|
733
|
+
f"protected_paths: {'PASS' if plan.protected_paths_passed else 'BLOCKED'}"
|
|
734
|
+
)
|
|
735
|
+
lines.append(
|
|
736
|
+
"backup_destination: "
|
|
737
|
+
+ (
|
|
738
|
+
plan.backup_destination.as_posix()
|
|
739
|
+
if plan.backup_destination is not None
|
|
740
|
+
else "none"
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
if plan.job.kind is JobKind.PATCH:
|
|
744
|
+
rollback = "enabled" if plan.auto_rollback else "disabled"
|
|
745
|
+
else:
|
|
746
|
+
rollback = "not_applicable"
|
|
747
|
+
lines.extend(
|
|
748
|
+
(
|
|
749
|
+
f"automatic_rollback: {rollback}",
|
|
750
|
+
"confirmation_required: " + ("yes" if plan.requires_confirmation else "no"),
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
return "\n".join(lines)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _append_path_list(lines: list[str], label: str, paths: tuple) -> None:
|
|
757
|
+
lines.append(f"{label}: {len(paths)}")
|
|
758
|
+
lines.extend(f" - {path.as_posix()}" for path in paths)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _path_summary(paths: tuple) -> str:
|
|
762
|
+
return ", ".join(path.as_posix() for path in paths) if paths else "-"
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
if __name__ == "__main__": # pragma: no cover
|
|
766
|
+
main()
|