bugpilot 0.1.0__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.
- bugpilot/__init__.py +5 -0
- bugpilot/__main__.py +5 -0
- bugpilot/cli.py +2615 -0
- bugpilot/cli_json.py +173 -0
- bugpilot/core/__init__.py +1 -0
- bugpilot/core/agent_runner.py +138 -0
- bugpilot/core/artifact_io.py +40 -0
- bugpilot/core/artifacts.py +47 -0
- bugpilot/core/attachments.py +247 -0
- bugpilot/core/branch_policy.py +223 -0
- bugpilot/core/cleanup.py +58 -0
- bugpilot/core/code_files.py +99 -0
- bugpilot/core/config.py +242 -0
- bugpilot/core/context.py +436 -0
- bugpilot/core/copilot.py +47 -0
- bugpilot/core/delivery_instructions.py +106 -0
- bugpilot/core/doctor.py +66 -0
- bugpilot/core/email_notify.py +316 -0
- bugpilot/core/errors.py +105 -0
- bugpilot/core/executables.py +87 -0
- bugpilot/core/fix_mode_state.py +212 -0
- bugpilot/core/fix_mode_store.py +600 -0
- bugpilot/core/fix_modes.py +412 -0
- bugpilot/core/fix_report.py +124 -0
- bugpilot/core/git_history.py +1579 -0
- bugpilot/core/git_ops.py +254 -0
- bugpilot/core/handoff.py +127 -0
- bugpilot/core/identity.py +112 -0
- bugpilot/core/input_adapters.py +97 -0
- bugpilot/core/instructions.py +337 -0
- bugpilot/core/issue.py +487 -0
- bugpilot/core/jira.py +886 -0
- bugpilot/core/jira_adf.py +167 -0
- bugpilot/core/jira_parse.py +442 -0
- bugpilot/core/keywords.py +386 -0
- bugpilot/core/logging_utils.py +28 -0
- bugpilot/core/memory.py +151 -0
- bugpilot/core/models.py +322 -0
- bugpilot/core/project_settings.py +283 -0
- bugpilot/core/prompts.py +567 -0
- bugpilot/core/repository_profile.py +739 -0
- bugpilot/core/retrieval.py +485 -0
- bugpilot/core/review_changes.py +155 -0
- bugpilot/core/review_report.py +171 -0
- bugpilot/core/run.py +171 -0
- bugpilot/core/safe_paths.py +173 -0
- bugpilot/core/search.py +765 -0
- bugpilot/core/search_terms.py +310 -0
- bugpilot/core/setup.py +212 -0
- bugpilot/core/user_config.py +211 -0
- bugpilot/core/verification_report.py +313 -0
- bugpilot/core/workflow.py +2385 -0
- bugpilot/mcp_server.py +651 -0
- bugpilot-0.1.0.dist-info/METADATA +270 -0
- bugpilot-0.1.0.dist-info/RECORD +59 -0
- bugpilot-0.1.0.dist-info/WHEEL +5 -0
- bugpilot-0.1.0.dist-info/entry_points.txt +3 -0
- bugpilot-0.1.0.dist-info/licenses/LICENSE +122 -0
- bugpilot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"""Custom Fix Modes on disk: where they live, and what may be done to them.
|
|
2
|
+
|
|
3
|
+
Two scopes, and the difference between them is who the mode belongs to:
|
|
4
|
+
|
|
5
|
+
~/.bugpilot/fix_modes/<id>.json one developer's own workflows
|
|
6
|
+
<repo>/.bugpilot/fix_modes/<id>.json a team's, committed with the code
|
|
7
|
+
|
|
8
|
+
Built-ins stay packaged and read-only. A custom file may not claim one of their
|
|
9
|
+
ids, and may not claim to *be* one: `source` is never stored in the file, it is
|
|
10
|
+
derived from the directory the file was found in. A JSON file that could say
|
|
11
|
+
`"source": "builtin"` would be a file that could dress itself up as something
|
|
12
|
+
BugPilot ships.
|
|
13
|
+
|
|
14
|
+
Two views of the same modes, deliberately kept apart:
|
|
15
|
+
|
|
16
|
+
- the **effective registry** answers "which definition does this id run?",
|
|
17
|
+
one mode per id, project over user, built-ins reserved;
|
|
18
|
+
- the **catalog** answers "what exists on disk?", and keeps both
|
|
19
|
+
`user/my-safe` and `project/my-safe` visible so that management can address
|
|
20
|
+
either. Resolving through the effective registry to decide what to edit
|
|
21
|
+
would make the shadowed one unreachable.
|
|
22
|
+
|
|
23
|
+
Nothing here is cached. A store is built per command, so a file edited by hand
|
|
24
|
+
between two runs is seen by the second one — these files are meant to be edited
|
|
25
|
+
and committed like any other repository config.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Iterable, Literal
|
|
34
|
+
|
|
35
|
+
from .artifact_io import atomic_write_text
|
|
36
|
+
from .fix_modes import (
|
|
37
|
+
BUILTIN_FIX_MODES,
|
|
38
|
+
FixMode,
|
|
39
|
+
FixModeError,
|
|
40
|
+
FixModeNotFoundError,
|
|
41
|
+
FixModeRegistry,
|
|
42
|
+
builtin_fix_mode_registry,
|
|
43
|
+
is_valid_fix_mode_id,
|
|
44
|
+
)
|
|
45
|
+
from .safe_paths import is_link_or_junction
|
|
46
|
+
from .user_config import user_config_dir
|
|
47
|
+
|
|
48
|
+
CUSTOM_SCHEMA_VERSION = 1
|
|
49
|
+
FIX_MODE_DIR_NAME = ".bugpilot"
|
|
50
|
+
FIX_MODE_SUBDIR = "fix_modes"
|
|
51
|
+
|
|
52
|
+
CustomScope = Literal["user", "project"]
|
|
53
|
+
CUSTOM_SCOPES: tuple[str, ...] = ("user", "project")
|
|
54
|
+
|
|
55
|
+
BUILTIN_FIX_MODE_IDS: frozenset[str] = frozenset(mode.id for mode in BUILTIN_FIX_MODES)
|
|
56
|
+
|
|
57
|
+
# The content of a mode, as a file carries it. `source` is absent on purpose and
|
|
58
|
+
# `version` is managed by this module, never by whoever wrote the file.
|
|
59
|
+
_CONTENT_FIELDS: tuple[str, ...] = (
|
|
60
|
+
"name",
|
|
61
|
+
"description",
|
|
62
|
+
"objective",
|
|
63
|
+
"investigation",
|
|
64
|
+
"implementation",
|
|
65
|
+
"verification",
|
|
66
|
+
"constraints",
|
|
67
|
+
"completion",
|
|
68
|
+
"execution_kind",
|
|
69
|
+
)
|
|
70
|
+
_ORIGIN_FIELDS: tuple[str, ...] = ("based_on", "based_on_version")
|
|
71
|
+
_ALLOWED_KEYS: frozenset[str] = frozenset(
|
|
72
|
+
("schema_version", "id", "version", *_CONTENT_FIELDS, *_ORIGIN_FIELDS)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class FixModeIssue:
|
|
78
|
+
"""A custom file that could not be loaded, and why.
|
|
79
|
+
|
|
80
|
+
Carried rather than raised: one unreadable file must not take the other
|
|
81
|
+
modes down with it, and must not be silently skipped either. The developer
|
|
82
|
+
needs the path and the reason, in the listing where they would look.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
scope: str
|
|
86
|
+
path: str
|
|
87
|
+
message: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class FixModeCatalog:
|
|
92
|
+
"""Every mode that physically exists, by scope, plus what could not be read."""
|
|
93
|
+
|
|
94
|
+
builtin: tuple[FixMode, ...]
|
|
95
|
+
user: tuple[FixMode, ...]
|
|
96
|
+
project: tuple[FixMode, ...]
|
|
97
|
+
issues: tuple[FixModeIssue, ...] = ()
|
|
98
|
+
|
|
99
|
+
def scoped(self, scope: str) -> tuple[FixMode, ...]:
|
|
100
|
+
if scope == "builtin":
|
|
101
|
+
return self.builtin
|
|
102
|
+
if scope == "user":
|
|
103
|
+
return self.user
|
|
104
|
+
if scope == "project":
|
|
105
|
+
return self.project
|
|
106
|
+
raise FixModeError(f"Unknown Fix Mode scope: {scope!r}.")
|
|
107
|
+
|
|
108
|
+
def effective_modes(self) -> tuple[FixMode, ...]:
|
|
109
|
+
"""One mode per id: project over user, built-ins never overridden.
|
|
110
|
+
|
|
111
|
+
Order is deterministic and does not depend on directory enumeration:
|
|
112
|
+
built-ins in their packaged order, then custom ids alphabetically.
|
|
113
|
+
"""
|
|
114
|
+
effective: dict[str, FixMode] = {mode.id: mode for mode in self.builtin}
|
|
115
|
+
custom: dict[str, FixMode] = {}
|
|
116
|
+
for mode in self.user:
|
|
117
|
+
custom[mode.id] = mode
|
|
118
|
+
for mode in self.project:
|
|
119
|
+
# Project beats user for a custom id. Stated here rather than left to
|
|
120
|
+
# the order two directories happen to be walked in.
|
|
121
|
+
custom[mode.id] = mode
|
|
122
|
+
for mode_id in sorted(custom):
|
|
123
|
+
if mode_id in effective:
|
|
124
|
+
# Unreachable through `load_catalog`, which refuses a reserved id
|
|
125
|
+
# at load time. Kept so that a hand-built catalog cannot shadow a
|
|
126
|
+
# built-in either.
|
|
127
|
+
continue
|
|
128
|
+
effective[mode_id] = custom[mode_id]
|
|
129
|
+
return tuple(effective.values())
|
|
130
|
+
|
|
131
|
+
def effective_registry(self) -> FixModeRegistry:
|
|
132
|
+
return FixModeRegistry(self.effective_modes())
|
|
133
|
+
|
|
134
|
+
def is_effective(self, mode: FixMode) -> bool:
|
|
135
|
+
"""Whether this physical definition is the one its id resolves to."""
|
|
136
|
+
for candidate in self.effective_modes():
|
|
137
|
+
if candidate.id == mode.id:
|
|
138
|
+
return candidate is mode or candidate == mode
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def custom_mode_payload(mode: FixMode) -> dict[str, object]:
|
|
143
|
+
"""A mode as its file spells it: content, origin, version — never source."""
|
|
144
|
+
payload: dict[str, object] = {"schema_version": CUSTOM_SCHEMA_VERSION, "id": mode.id}
|
|
145
|
+
for field in _CONTENT_FIELDS:
|
|
146
|
+
payload[field] = getattr(mode, field)
|
|
147
|
+
payload["version"] = mode.version
|
|
148
|
+
payload["based_on"] = mode.based_on
|
|
149
|
+
payload["based_on_version"] = mode.based_on_version
|
|
150
|
+
return payload
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _require_scope(scope: str) -> CustomScope:
|
|
154
|
+
if scope not in CUSTOM_SCOPES:
|
|
155
|
+
raise FixModeError(
|
|
156
|
+
f"Unknown Fix Mode scope {scope!r}. Custom modes live in "
|
|
157
|
+
f"{' or '.join(CUSTOM_SCOPES)} scope; built-in modes cannot be modified."
|
|
158
|
+
)
|
|
159
|
+
return scope # type: ignore[return-value]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _require_not_reserved(mode_id: str) -> str:
|
|
163
|
+
if mode_id in BUILTIN_FIX_MODE_IDS:
|
|
164
|
+
raise FixModeError(
|
|
165
|
+
f"Built-in Fix Mode {mode_id!r} is reserved and cannot be overridden. "
|
|
166
|
+
"Duplicate it under a different custom id."
|
|
167
|
+
)
|
|
168
|
+
return mode_id
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _require_managed_version(value: object, field: str) -> int:
|
|
172
|
+
"""A version a caller sends back to us, checked before it decides anything.
|
|
173
|
+
|
|
174
|
+
`True` is not 1 here: a JSON `true` that slipped into an expected-version
|
|
175
|
+
field must not pass for the first version of a mode and silently authorize
|
|
176
|
+
an overwrite.
|
|
177
|
+
"""
|
|
178
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
179
|
+
raise FixModeError(
|
|
180
|
+
f"{field} must be an integer, got {type(value).__name__}."
|
|
181
|
+
)
|
|
182
|
+
if value < 1:
|
|
183
|
+
raise FixModeError(f"{field} must be >= 1.")
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def fix_mode_from_payload(
|
|
188
|
+
payload: object,
|
|
189
|
+
*,
|
|
190
|
+
scope: CustomScope,
|
|
191
|
+
mode_id: str | None = None,
|
|
192
|
+
require_version: bool = True,
|
|
193
|
+
where: str = "Fix Mode definition",
|
|
194
|
+
) -> FixMode:
|
|
195
|
+
"""Build a validated `FixMode` from a custom definition.
|
|
196
|
+
|
|
197
|
+
Strict on purpose. An unknown key is an error rather than something ignored,
|
|
198
|
+
because the realistic way to lose a section is to misspell it: a tolerant
|
|
199
|
+
reader would take `"verfication"` as "no verification given" and the mode
|
|
200
|
+
would quietly run without it. `source` is refused outright — the directory
|
|
201
|
+
decides that, not the file.
|
|
202
|
+
"""
|
|
203
|
+
if not isinstance(payload, dict):
|
|
204
|
+
raise FixModeError(f"{where} must be a JSON object.")
|
|
205
|
+
if "source" in payload:
|
|
206
|
+
raise FixModeError(
|
|
207
|
+
f"{where} must not set 'source'. A Fix Mode's scope comes from where it "
|
|
208
|
+
"is stored, so a file cannot claim to be built-in."
|
|
209
|
+
)
|
|
210
|
+
unknown = sorted(set(payload) - _ALLOWED_KEYS)
|
|
211
|
+
if unknown:
|
|
212
|
+
raise FixModeError(
|
|
213
|
+
f"{where} has unsupported field(s): {', '.join(unknown)}. Allowed: "
|
|
214
|
+
f"{', '.join(sorted(_ALLOWED_KEYS))}."
|
|
215
|
+
)
|
|
216
|
+
declared = payload.get("schema_version", CUSTOM_SCHEMA_VERSION)
|
|
217
|
+
if declared != CUSTOM_SCHEMA_VERSION:
|
|
218
|
+
raise FixModeError(
|
|
219
|
+
f"{where} has schema_version {declared!r}; this BugPilot understands "
|
|
220
|
+
f"{CUSTOM_SCHEMA_VERSION}."
|
|
221
|
+
)
|
|
222
|
+
missing = [field for field in ("id", *_CONTENT_FIELDS) if field not in payload]
|
|
223
|
+
if require_version and "version" not in payload:
|
|
224
|
+
missing.append("version")
|
|
225
|
+
if missing:
|
|
226
|
+
raise FixModeError(f"{where} is missing required field(s): {', '.join(missing)}.")
|
|
227
|
+
|
|
228
|
+
declared_id = payload["id"]
|
|
229
|
+
if mode_id is not None and declared_id != mode_id:
|
|
230
|
+
raise FixModeError(
|
|
231
|
+
f"{where} declares id {declared_id!r} but belongs to {mode_id!r}. "
|
|
232
|
+
"A custom Fix Mode's id and its file name must match."
|
|
233
|
+
)
|
|
234
|
+
version = (
|
|
235
|
+
_require_managed_version(payload["version"], "Fix Mode version")
|
|
236
|
+
if "version" in payload
|
|
237
|
+
else 1
|
|
238
|
+
)
|
|
239
|
+
mode = FixMode(
|
|
240
|
+
id=declared_id if isinstance(declared_id, str) else "",
|
|
241
|
+
name=payload["name"], # type: ignore[arg-type]
|
|
242
|
+
description=payload["description"], # type: ignore[arg-type]
|
|
243
|
+
objective=payload["objective"], # type: ignore[arg-type]
|
|
244
|
+
investigation=payload["investigation"], # type: ignore[arg-type]
|
|
245
|
+
implementation=payload["implementation"], # type: ignore[arg-type]
|
|
246
|
+
verification=payload["verification"], # type: ignore[arg-type]
|
|
247
|
+
constraints=payload["constraints"], # type: ignore[arg-type]
|
|
248
|
+
completion=payload["completion"], # type: ignore[arg-type]
|
|
249
|
+
execution_kind=payload["execution_kind"], # type: ignore[arg-type]
|
|
250
|
+
source=scope,
|
|
251
|
+
based_on=payload.get("based_on"), # type: ignore[arg-type]
|
|
252
|
+
based_on_version=payload.get("based_on_version"), # type: ignore[arg-type]
|
|
253
|
+
version=version,
|
|
254
|
+
)
|
|
255
|
+
mode.validate()
|
|
256
|
+
_require_not_reserved(mode.id)
|
|
257
|
+
return mode
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class FixModeStore:
|
|
261
|
+
"""The two custom-mode directories, and everything done to them.
|
|
262
|
+
|
|
263
|
+
Built per command rather than kept: these files are ordinary repository and
|
|
264
|
+
home-directory config, and a long-lived copy in memory would answer with
|
|
265
|
+
yesterday's content after a developer edited one in an editor.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, repo_root: Path | None = None, home_dir: Path | None = None) -> None:
|
|
269
|
+
self._repo_root = Path(repo_root) if repo_root is not None else None
|
|
270
|
+
# The same directory the rest of BugPilot calls home, so a test that
|
|
271
|
+
# redirects BUGPILOT_CONFIG_DIR redirects custom modes with it.
|
|
272
|
+
self._home_dir = Path(home_dir) if home_dir is not None else user_config_dir()
|
|
273
|
+
|
|
274
|
+
# --- locations ---------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def scope_dir(self, scope: str) -> Path:
|
|
277
|
+
"""Where a scope's modes live. Not created by reading."""
|
|
278
|
+
_require_scope(scope)
|
|
279
|
+
if scope == "user":
|
|
280
|
+
return self._home_dir / FIX_MODE_SUBDIR
|
|
281
|
+
if self._repo_root is None:
|
|
282
|
+
raise FixModeError(
|
|
283
|
+
"Project Fix Modes need a repository. Run this from inside the "
|
|
284
|
+
"repository you are working on, or use --scope user."
|
|
285
|
+
)
|
|
286
|
+
return self._repo_root / FIX_MODE_DIR_NAME / FIX_MODE_SUBDIR
|
|
287
|
+
|
|
288
|
+
def mode_path(self, scope: str, mode_id: str) -> Path:
|
|
289
|
+
"""The one file a mode may occupy, checked to be inside its directory.
|
|
290
|
+
|
|
291
|
+
The id is already restricted to lowercase letters, digits and hyphens,
|
|
292
|
+
which leaves no room for a separator or a `..`. This checks the resolved
|
|
293
|
+
path anyway: the id validator is a different function with a different
|
|
294
|
+
job, and a containment rule that depends on another function's invariant
|
|
295
|
+
is a containment rule that breaks when that function changes.
|
|
296
|
+
"""
|
|
297
|
+
if not is_valid_fix_mode_id(mode_id):
|
|
298
|
+
raise FixModeError(
|
|
299
|
+
f"Invalid Fix Mode id {mode_id!r}. Use lowercase letters, digits and "
|
|
300
|
+
"hyphens, starting with a letter."
|
|
301
|
+
)
|
|
302
|
+
directory = self.scope_dir(scope)
|
|
303
|
+
# Before anything is built from it: one guard here covers read, create,
|
|
304
|
+
# duplicate, update and delete, which all address a file through this.
|
|
305
|
+
unsafe = _unsafe_directory_reason(directory, scope)
|
|
306
|
+
if unsafe:
|
|
307
|
+
raise FixModeError(unsafe)
|
|
308
|
+
candidate = directory / f"{mode_id}.json"
|
|
309
|
+
base = _resolved(directory)
|
|
310
|
+
resolved = _resolved(candidate)
|
|
311
|
+
if resolved.parent != base:
|
|
312
|
+
raise FixModeError(
|
|
313
|
+
f"Refusing to work on {candidate}: it resolves outside the "
|
|
314
|
+
f"{scope} Fix Mode directory."
|
|
315
|
+
)
|
|
316
|
+
return candidate
|
|
317
|
+
|
|
318
|
+
# --- reading -----------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def load_catalog(self) -> FixModeCatalog:
|
|
321
|
+
"""Everything on disk, by scope, with unreadable files reported."""
|
|
322
|
+
issues: list[FixModeIssue] = []
|
|
323
|
+
user = self._load_scope("user", issues)
|
|
324
|
+
project = self._load_scope("project", issues) if self._repo_root is not None else ()
|
|
325
|
+
return FixModeCatalog(
|
|
326
|
+
# Through the factory rather than the raw tuple: it is the packaged
|
|
327
|
+
# set already validated, and one source for "what BugPilot ships"
|
|
328
|
+
# keeps the reserved ids and the catalog from ever disagreeing.
|
|
329
|
+
builtin=builtin_fix_mode_registry().list_modes(),
|
|
330
|
+
user=user,
|
|
331
|
+
project=project,
|
|
332
|
+
issues=tuple(issues),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def effective_registry(self) -> FixModeRegistry:
|
|
336
|
+
return self.load_catalog().effective_registry()
|
|
337
|
+
|
|
338
|
+
def _load_scope(self, scope: str, issues: list[FixModeIssue]) -> tuple[FixMode, ...]:
|
|
339
|
+
try:
|
|
340
|
+
directory = self.scope_dir(scope)
|
|
341
|
+
except FixModeError:
|
|
342
|
+
return ()
|
|
343
|
+
unsafe = _unsafe_directory_reason(directory, scope)
|
|
344
|
+
if unsafe:
|
|
345
|
+
# Reported rather than raised: one unusable scope must not cost the
|
|
346
|
+
# developer the built-ins and the other scope's modes.
|
|
347
|
+
issues.append(FixModeIssue(scope=scope, path=str(directory), message=unsafe))
|
|
348
|
+
return ()
|
|
349
|
+
if not directory.is_dir():
|
|
350
|
+
return ()
|
|
351
|
+
modes: list[FixMode] = []
|
|
352
|
+
# Sorted, so two machines listing the same directory agree.
|
|
353
|
+
for path in sorted(directory.glob("*.json")):
|
|
354
|
+
try:
|
|
355
|
+
modes.append(self._load_file(path, scope))
|
|
356
|
+
except FixModeError as exc:
|
|
357
|
+
issues.append(FixModeIssue(scope=scope, path=str(path), message=str(exc)))
|
|
358
|
+
return tuple(modes)
|
|
359
|
+
|
|
360
|
+
def _load_file(self, path: Path, scope: str) -> FixMode:
|
|
361
|
+
if not path.is_file():
|
|
362
|
+
raise FixModeError(f"{path.name} is not a file.")
|
|
363
|
+
if path.is_symlink():
|
|
364
|
+
# A link is a definition that lives somewhere else, under rules this
|
|
365
|
+
# store does not set. Reading one would make the scope a lie.
|
|
366
|
+
raise FixModeError(
|
|
367
|
+
f"{path.name} is a symbolic link. Custom Fix Modes must be real "
|
|
368
|
+
"files inside their scope directory."
|
|
369
|
+
)
|
|
370
|
+
try:
|
|
371
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
372
|
+
except json.JSONDecodeError as exc:
|
|
373
|
+
raise FixModeError(f"{path.name} is not valid JSON: {exc}.") from exc
|
|
374
|
+
except OSError as exc:
|
|
375
|
+
raise FixModeError(f"{path.name} could not be read: {exc}.") from exc
|
|
376
|
+
return fix_mode_from_payload(
|
|
377
|
+
raw,
|
|
378
|
+
scope=scope, # type: ignore[arg-type]
|
|
379
|
+
mode_id=path.stem,
|
|
380
|
+
require_version=True,
|
|
381
|
+
where=path.name,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def read(self, scope: str, mode_id: str) -> FixMode:
|
|
385
|
+
"""One scope's definition of an id, for management rather than execution."""
|
|
386
|
+
path = self.mode_path(scope, mode_id)
|
|
387
|
+
if not path.exists():
|
|
388
|
+
raise FixModeNotFoundError(
|
|
389
|
+
f"No {scope} Fix Mode {mode_id!r}. Run: bugpilot fix-mode list --all-scopes"
|
|
390
|
+
)
|
|
391
|
+
return self._load_file(path, scope)
|
|
392
|
+
|
|
393
|
+
# --- writing -----------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def create(self, scope: str, mode_id: str, payload: object) -> FixMode:
|
|
396
|
+
"""A new custom mode, complete, at version 1.
|
|
397
|
+
|
|
398
|
+
Deliberately does not fill missing sections from Standard Fix: a mode
|
|
399
|
+
assembled half from a payload and half from a built-in is a mode nobody
|
|
400
|
+
wrote. Starting from a built-in is what `duplicate` is for.
|
|
401
|
+
"""
|
|
402
|
+
_require_scope(scope)
|
|
403
|
+
_require_not_reserved(mode_id)
|
|
404
|
+
path = self.mode_path(scope, mode_id)
|
|
405
|
+
if path.exists():
|
|
406
|
+
raise FixModeError(
|
|
407
|
+
f"A {scope} Fix Mode {mode_id!r} already exists. Choose another id, "
|
|
408
|
+
"or update the existing one."
|
|
409
|
+
)
|
|
410
|
+
mode = fix_mode_from_payload(
|
|
411
|
+
payload,
|
|
412
|
+
scope=scope, # type: ignore[arg-type]
|
|
413
|
+
mode_id=mode_id,
|
|
414
|
+
require_version=False,
|
|
415
|
+
where=f"{mode_id}.json",
|
|
416
|
+
)
|
|
417
|
+
# Version is managed here, not by whoever wrote the payload.
|
|
418
|
+
mode = _with(mode, version=1)
|
|
419
|
+
self._write(path, mode)
|
|
420
|
+
return mode
|
|
421
|
+
|
|
422
|
+
def duplicate(
|
|
423
|
+
self,
|
|
424
|
+
source_id: str,
|
|
425
|
+
new_id: str,
|
|
426
|
+
scope: str,
|
|
427
|
+
*,
|
|
428
|
+
name: str | None = None,
|
|
429
|
+
registry: FixModeRegistry | None = None,
|
|
430
|
+
) -> FixMode:
|
|
431
|
+
"""A complete, independent copy that remembers where it came from.
|
|
432
|
+
|
|
433
|
+
`based_on` and `based_on_version` are audit metadata and nothing else:
|
|
434
|
+
the copy carries every section itself, so the original can change or be
|
|
435
|
+
deleted afterwards without touching it. There is no inheritance to break.
|
|
436
|
+
"""
|
|
437
|
+
_require_scope(scope)
|
|
438
|
+
_require_not_reserved(new_id)
|
|
439
|
+
source = (registry or self.effective_registry()).resolve(source_id)
|
|
440
|
+
copy = FixMode(
|
|
441
|
+
id=new_id,
|
|
442
|
+
name=name if name and name.strip() else f"{source.name} (copy)",
|
|
443
|
+
description=source.description,
|
|
444
|
+
objective=source.objective,
|
|
445
|
+
investigation=source.investigation,
|
|
446
|
+
implementation=source.implementation,
|
|
447
|
+
verification=source.verification,
|
|
448
|
+
constraints=source.constraints,
|
|
449
|
+
completion=source.completion,
|
|
450
|
+
execution_kind=source.execution_kind,
|
|
451
|
+
source=scope, # type: ignore[arg-type]
|
|
452
|
+
based_on=source.id,
|
|
453
|
+
based_on_version=source.version,
|
|
454
|
+
version=1,
|
|
455
|
+
)
|
|
456
|
+
copy.validate()
|
|
457
|
+
path = self.mode_path(scope, new_id)
|
|
458
|
+
if path.exists():
|
|
459
|
+
raise FixModeError(
|
|
460
|
+
f"A {scope} Fix Mode {new_id!r} already exists. Choose another id."
|
|
461
|
+
)
|
|
462
|
+
self._write(path, copy)
|
|
463
|
+
return copy
|
|
464
|
+
|
|
465
|
+
def update(self, scope: str, mode_id: str, payload: object, expected_version: object) -> FixMode:
|
|
466
|
+
"""Replace a custom mode's content, one version at a time.
|
|
467
|
+
|
|
468
|
+
The version is BugPilot's to increment and the caller's to state: a
|
|
469
|
+
client that could choose the next number could also skip past someone
|
|
470
|
+
else's save. Everything — the payload, the expected version, the new
|
|
471
|
+
definition — is checked before the file on disk is touched, so a refused
|
|
472
|
+
update leaves exactly what was there.
|
|
473
|
+
"""
|
|
474
|
+
_require_scope(scope)
|
|
475
|
+
# Before the lookup: a built-in id is reserved, not merely absent from a
|
|
476
|
+
# custom directory, and "No user Fix Mode 'standard'" would send the
|
|
477
|
+
# developer looking for a file rather than telling them why.
|
|
478
|
+
_require_not_reserved(mode_id)
|
|
479
|
+
current = self.read(scope, mode_id)
|
|
480
|
+
expected = _require_managed_version(expected_version, "expected_version")
|
|
481
|
+
if expected != current.version:
|
|
482
|
+
raise FixModeError(
|
|
483
|
+
f"Fix Mode {mode_id!r} changed since this editor was opened "
|
|
484
|
+
f"(expected version {expected}, found {current.version}). "
|
|
485
|
+
"Reload it before saving."
|
|
486
|
+
)
|
|
487
|
+
updated = fix_mode_from_payload(
|
|
488
|
+
payload,
|
|
489
|
+
scope=scope, # type: ignore[arg-type]
|
|
490
|
+
mode_id=mode_id,
|
|
491
|
+
require_version=False,
|
|
492
|
+
where=f"{mode_id}.json",
|
|
493
|
+
)
|
|
494
|
+
# Identity, origin and version are not the payload's to change: an id
|
|
495
|
+
# rename would orphan every work item that recorded it, and `based_on`
|
|
496
|
+
# records where this mode came from rather than what it says today.
|
|
497
|
+
updated = _with(
|
|
498
|
+
updated,
|
|
499
|
+
version=current.version + 1,
|
|
500
|
+
based_on=current.based_on,
|
|
501
|
+
based_on_version=current.based_on_version,
|
|
502
|
+
)
|
|
503
|
+
self._write(self.mode_path(scope, mode_id), updated)
|
|
504
|
+
return updated
|
|
505
|
+
|
|
506
|
+
def delete(self, scope: str, mode_id: str, expected_version: object) -> FixMode:
|
|
507
|
+
"""Remove one custom mode, if it is still the version the caller saw."""
|
|
508
|
+
_require_scope(scope)
|
|
509
|
+
_require_not_reserved(mode_id)
|
|
510
|
+
current = self.read(scope, mode_id)
|
|
511
|
+
expected = _require_managed_version(expected_version, "expected_version")
|
|
512
|
+
if expected != current.version:
|
|
513
|
+
raise FixModeError(
|
|
514
|
+
f"Fix Mode {mode_id!r} changed since it was listed "
|
|
515
|
+
f"(expected version {expected}, found {current.version}). "
|
|
516
|
+
"Reload it before deleting."
|
|
517
|
+
)
|
|
518
|
+
path = self.mode_path(scope, mode_id)
|
|
519
|
+
if path.is_symlink():
|
|
520
|
+
raise FixModeError(
|
|
521
|
+
f"{path.name} is a symbolic link and will not be deleted through BugPilot."
|
|
522
|
+
)
|
|
523
|
+
path.unlink()
|
|
524
|
+
return current
|
|
525
|
+
|
|
526
|
+
def _write(self, path: Path, mode: FixMode) -> None:
|
|
527
|
+
if path.is_symlink():
|
|
528
|
+
raise FixModeError(
|
|
529
|
+
f"{path.name} is a symbolic link. BugPilot will not write through it."
|
|
530
|
+
)
|
|
531
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
532
|
+
text = json.dumps(custom_mode_payload(mode), indent=2, sort_keys=True) + "\n"
|
|
533
|
+
atomic_write_text(path, text)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _with(mode: FixMode, **changes: object) -> FixMode:
|
|
537
|
+
from dataclasses import replace
|
|
538
|
+
|
|
539
|
+
updated = replace(mode, **changes) # type: ignore[arg-type]
|
|
540
|
+
updated.validate()
|
|
541
|
+
return updated
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _redirects_elsewhere(path: Path) -> bool:
|
|
545
|
+
"""Whether this path is a link that sends writes somewhere else.
|
|
546
|
+
|
|
547
|
+
`resolve()` cannot answer this, and that is exactly how a symlinked store
|
|
548
|
+
escaped containment: resolving *both* the base directory and the candidate
|
|
549
|
+
file follows the same link, so the two agree and the check passes while the
|
|
550
|
+
file lands outside the repository.
|
|
551
|
+
|
|
552
|
+
The one link test BugPilot has (`safe_paths.is_link_or_junction`), shared
|
|
553
|
+
with `clean` and `--fresh`: symbolic links everywhere, and on Windows every
|
|
554
|
+
name-surrogate reparse point, junctions included.
|
|
555
|
+
"""
|
|
556
|
+
return is_link_or_junction(path)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _unsafe_directory_reason(directory: Path, scope: str) -> str | None:
|
|
560
|
+
"""Why this scope's directory may not be used, if it may not be.
|
|
561
|
+
|
|
562
|
+
The store is `<config dir>/fix_modes` and `<repo>/.bugpilot/fix_modes`, so
|
|
563
|
+
both the directory and the config directory holding it have to be real: a
|
|
564
|
+
link at either level redirects the whole store.
|
|
565
|
+
"""
|
|
566
|
+
for candidate in (directory, directory.parent):
|
|
567
|
+
if _redirects_elsewhere(candidate):
|
|
568
|
+
return (
|
|
569
|
+
f"{candidate} is a symbolic link or junction. {scope.capitalize()} Fix "
|
|
570
|
+
"Mode directories must be real directories, so that modes cannot be "
|
|
571
|
+
"read from or written to somewhere else."
|
|
572
|
+
)
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _resolved(path: Path) -> Path:
|
|
577
|
+
"""`resolve()` that also works for a path that does not exist yet."""
|
|
578
|
+
try:
|
|
579
|
+
return path.resolve()
|
|
580
|
+
except OSError: # pragma: no cover - only on an unreadable parent
|
|
581
|
+
return path.absolute()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def catalog_for(repo_root: Path | None, home_dir: Path | None = None) -> FixModeCatalog:
|
|
585
|
+
"""Read every mode available to one repository. The common entry point."""
|
|
586
|
+
return FixModeStore(repo_root, home_dir).load_catalog()
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def effective_registry_for(
|
|
590
|
+
repo_root: Path | None, home_dir: Path | None = None
|
|
591
|
+
) -> FixModeRegistry:
|
|
592
|
+
"""The registry an id resolves through for this repository."""
|
|
593
|
+
return catalog_for(repo_root, home_dir).effective_registry()
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def scoped_modes(catalog: FixModeCatalog) -> Iterable[tuple[str, FixMode]]:
|
|
597
|
+
"""Every physical definition with its scope, in listing order."""
|
|
598
|
+
for scope in ("builtin", "user", "project"):
|
|
599
|
+
for mode in catalog.scoped(scope):
|
|
600
|
+
yield scope, mode
|