snapdelta-web 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.
- snapdelta_web/__init__.py +1 -0
- snapdelta_web/access.py +66 -0
- snapdelta_web/api.py +327 -0
- snapdelta_web/app.py +807 -0
- snapdelta_web/auth.py +58 -0
- snapdelta_web/cli.py +81 -0
- snapdelta_web/jobs.py +104 -0
- snapdelta_web/mcp_guide.md +34 -0
- snapdelta_web/mcp_server.py +361 -0
- snapdelta_web/settings_form.py +233 -0
- snapdelta_web/state.py +21 -0
- snapdelta_web/static/app.css +561 -0
- snapdelta_web/static/app.js +392 -0
- snapdelta_web/static/favicon.svg +1 -0
- snapdelta_web/static/vendor/VERSIONS +4 -0
- snapdelta_web/static/vendor/fonts/bricolage-grotesque-latin-ext-opsz-normal.woff2 +0 -0
- snapdelta_web/static/vendor/fonts/bricolage-grotesque-latin-opsz-normal.woff2 +0 -0
- snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- snapdelta_web/static/vendor/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- snapdelta_web/static/vendor/fonts.css +55 -0
- snapdelta_web/static/vendor/htmx.min.js +1 -0
- snapdelta_web/static/vendor/licenses/bricolage-grotesque-OFL.txt +93 -0
- snapdelta_web/static/vendor/licenses/htmx-LICENSE +13 -0
- snapdelta_web/static/vendor/licenses/jetbrains-mono-OFL.txt +93 -0
- snapdelta_web/static/vendor/licenses/swagger-ui-LICENSE +202 -0
- snapdelta_web/static/vendor/swagger-ui/swagger-ui-bundle.js +2 -0
- snapdelta_web/static/vendor/swagger-ui/swagger-ui.css +3 -0
- snapdelta_web/templates/_admin_nav.html +8 -0
- snapdelta_web/templates/_cards.html +4 -0
- snapdelta_web/templates/_macros.html +110 -0
- snapdelta_web/templates/_mcp_result.html +1 -0
- snapdelta_web/templates/_model_rows.html +4 -0
- snapdelta_web/templates/_models.html +12 -0
- snapdelta_web/templates/_palette.html +8 -0
- snapdelta_web/templates/_review.html +3 -0
- snapdelta_web/templates/_snapshot_options.html +8 -0
- snapdelta_web/templates/_take_job.html +31 -0
- snapdelta_web/templates/_take_targets.html +4 -0
- snapdelta_web/templates/_target_options.html +2 -0
- snapdelta_web/templates/_timeline.html +52 -0
- snapdelta_web/templates/activity.html +35 -0
- snapdelta_web/templates/admin.html +42 -0
- snapdelta_web/templates/admin_about.html +40 -0
- snapdelta_web/templates/admin_inventory.html +34 -0
- snapdelta_web/templates/admin_models.html +35 -0
- snapdelta_web/templates/admin_settings.html +69 -0
- snapdelta_web/templates/base.html +68 -0
- snapdelta_web/templates/diff.html +78 -0
- snapdelta_web/templates/diffs.html +42 -0
- snapdelta_web/templates/error.html +23 -0
- snapdelta_web/templates/login.html +14 -0
- snapdelta_web/templates/mcp_docs.html +69 -0
- snapdelta_web/templates/new.html +35 -0
- snapdelta_web/templates/not_found.html +22 -0
- snapdelta_web/templates/run.html +25 -0
- snapdelta_web/templates/snapshot.html +37 -0
- snapdelta_web/templates/snapshots.html +28 -0
- snapdelta_web/templates/take.html +31 -0
- snapdelta_web/templates/take_job.html +6 -0
- snapdelta_web/templates/target.html +31 -0
- snapdelta_web/views.py +744 -0
- snapdelta_web-0.1.0.dist-info/METADATA +75 -0
- snapdelta_web-0.1.0.dist-info/RECORD +68 -0
- snapdelta_web-0.1.0.dist-info/WHEEL +4 -0
- snapdelta_web-0.1.0.dist-info/entry_points.txt +3 -0
- snapdelta_web-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""snapdelta-web: local web UI for the snapdelta store."""
|
snapdelta_web/access.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Store access from the web: names resolve through the store backend, which refuses names that leave the store."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from collections.abc import Callable, Hashable, Mapping
|
|
6
|
+
|
|
7
|
+
from snapdelta.errors import NotFoundError, SnapdeltaError
|
|
8
|
+
from snapdelta.output import Side
|
|
9
|
+
from snapdelta.profile import paired
|
|
10
|
+
from snapdelta.snapshot import StoredSource
|
|
11
|
+
from snapdelta.store import Store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LruCache[K: Hashable, V]:
|
|
15
|
+
"""The last `size` values; the web server runs requests in threads, so a lock guards the order."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, size: int) -> None:
|
|
18
|
+
self.size = size
|
|
19
|
+
self._values: OrderedDict[K, V] = OrderedDict()
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
|
|
22
|
+
def get(self, key: K, load: Callable[[], V]) -> V:
|
|
23
|
+
with self._lock:
|
|
24
|
+
if key in self._values:
|
|
25
|
+
self._values.move_to_end(key)
|
|
26
|
+
return self._values[key]
|
|
27
|
+
value = load()
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._values[key] = value
|
|
30
|
+
self._values.move_to_end(key)
|
|
31
|
+
while len(self._values) > self.size:
|
|
32
|
+
self._values.popitem(last=False)
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
def discard(self, key: K) -> None:
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._values.pop(key, None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SnapshotCache:
|
|
41
|
+
"""Loaded snapshots for the pages that only read them: a saved snapshot never changes, it can only go away.
|
|
42
|
+
|
|
43
|
+
Never give a cached snapshot to a compare: a compare changes `top_level` while it runs.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, size: int = 4) -> None:
|
|
47
|
+
self._cache: LruCache[tuple[str, str], StoredSource] = LruCache(size)
|
|
48
|
+
|
|
49
|
+
def load(self, store: Store, reference: str) -> StoredSource:
|
|
50
|
+
# A delete from the CLI or from another request leaves a stale entry, so the store decides first.
|
|
51
|
+
if not store.has_snapshot(reference):
|
|
52
|
+
raise NotFoundError(f"no snapshot {reference!r} in {store.location}")
|
|
53
|
+
return self._cache.get((store.location, reference), lambda: store.load_snapshot(reference))
|
|
54
|
+
|
|
55
|
+
def side(self, store: Store, side: Side, pairs: Mapping[str, str] | None = None) -> StoredSource | None:
|
|
56
|
+
"""The snapshot that an output side names, with the names of the pairs of its profile; None when it is gone."""
|
|
57
|
+
if side.snapshot is None:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
loaded = self.load(store, side.snapshot)
|
|
61
|
+
if not pairs:
|
|
62
|
+
return loaded
|
|
63
|
+
key = (store.location, f"{side.snapshot} {sorted(pairs.items())}")
|
|
64
|
+
return self._cache.get(key, lambda: paired(loaded, pairs))
|
|
65
|
+
except SnapdeltaError:
|
|
66
|
+
return None
|
snapdelta_web/api.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""The JSON API, for a pipeline or an AI agent: take snapshots, compare them, read and describe the diffs."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Any, Literal, Self
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, Query, Request
|
|
7
|
+
from fastapi.openapi.docs import get_swagger_ui_html
|
|
8
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from snapdelta.about import components, package_version, plugins
|
|
12
|
+
from snapdelta.activity import Run, RunStatus
|
|
13
|
+
from snapdelta.errors import NotFoundError, SnapdeltaError
|
|
14
|
+
from snapdelta.output import Output, Review, Side, new_review
|
|
15
|
+
from snapdelta.render import renderer
|
|
16
|
+
from snapdelta.snapshot import SnapshotFile
|
|
17
|
+
from snapdelta.source import source_classes
|
|
18
|
+
from snapdelta.take import diff_snapshots, snapshot_targets
|
|
19
|
+
|
|
20
|
+
from .access import LruCache
|
|
21
|
+
from .state import AppState
|
|
22
|
+
|
|
23
|
+
PREFIX = "/api/v1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Component(BaseModel):
|
|
27
|
+
name: str
|
|
28
|
+
version: str
|
|
29
|
+
sources: list[str] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VersionInfo(BaseModel):
|
|
33
|
+
components: list[Component]
|
|
34
|
+
plugins: list[Component]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SourceInfo(BaseModel):
|
|
38
|
+
name: str
|
|
39
|
+
distribution: str | None
|
|
40
|
+
fetches: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SnapshotInfo(BaseModel):
|
|
44
|
+
reference: str
|
|
45
|
+
target: str
|
|
46
|
+
label: str | None
|
|
47
|
+
captured_at: datetime
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SnapshotRequest(BaseModel):
|
|
51
|
+
source: str | None = Field(default=None, description="The source; none: the platform of each inventory host.")
|
|
52
|
+
targets: list[str] = Field(default=[], description="Target names.")
|
|
53
|
+
groups: list[str] = Field(default=[], description="Inventory groups: every host of each group.")
|
|
54
|
+
all_targets: bool = Field(default=False, description="Every target of the source, or every inventory host.")
|
|
55
|
+
label: str | None = Field(default=None, description="The label of the snapshots, for example before.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SnapshotResult(BaseModel):
|
|
59
|
+
ok: bool = Field(description="True when every target was saved.")
|
|
60
|
+
saved: dict[str, str] = Field(description="The reference of the snapshot of each saved target.")
|
|
61
|
+
failed: dict[str, str] = Field(description="The error of each failed target.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DiffRequest(BaseModel):
|
|
65
|
+
left: str = Field(description="A snapshot: TARGET/NAME, TARGET@latest or TARGET@LABEL (newest with that label).")
|
|
66
|
+
right: str | None = Field(default=None, description="The right snapshot; none with live.")
|
|
67
|
+
live: bool = Field(default=False, description="Fetch the target of left now as the right side.")
|
|
68
|
+
target: str | None = Field(default=None, description="With live: another target than the one of left.")
|
|
69
|
+
models: list[str] = Field(default=[], description="Only these models; none: every model of both sides.")
|
|
70
|
+
profile: str | None = Field(default=None, description="A compare profile of the settings.")
|
|
71
|
+
title: str | None = None
|
|
72
|
+
description: str | None = None
|
|
73
|
+
save: bool = Field(default=True, description="Save the output, and with live the new snapshot.")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DiffResult(BaseModel):
|
|
77
|
+
name: str | None = Field(description="The name of the saved output; none with save false.")
|
|
78
|
+
url: str | None = Field(description="The page of the diff in the web UI.")
|
|
79
|
+
has_differences: bool
|
|
80
|
+
summary: dict[str, int]
|
|
81
|
+
left: Side
|
|
82
|
+
right: Side
|
|
83
|
+
title: str | None
|
|
84
|
+
description: str | None
|
|
85
|
+
output: Output | None = Field(default=None, description="The whole output, with include_output.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DiffItem(BaseModel):
|
|
89
|
+
name: str
|
|
90
|
+
created_at: datetime
|
|
91
|
+
title: str | None
|
|
92
|
+
left: Side
|
|
93
|
+
right: Side
|
|
94
|
+
has_differences: bool
|
|
95
|
+
summary: dict[str, int]
|
|
96
|
+
accepted: int = Field(description="How many changed objects a person accepted.")
|
|
97
|
+
changes: int = Field(description="How many objects have a change of their own.")
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def of(cls, name: str, output: Output) -> Self:
|
|
101
|
+
accepted, changes = output.review_counts()
|
|
102
|
+
return cls(
|
|
103
|
+
name=name,
|
|
104
|
+
created_at=output.created_at,
|
|
105
|
+
title=output.title,
|
|
106
|
+
left=output.left,
|
|
107
|
+
right=output.right,
|
|
108
|
+
has_differences=output.has_differences(),
|
|
109
|
+
summary=output.summary,
|
|
110
|
+
accepted=accepted,
|
|
111
|
+
changes=changes,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RunItem(BaseModel):
|
|
116
|
+
"""A run without its log: what the activity list shows."""
|
|
117
|
+
|
|
118
|
+
id: str
|
|
119
|
+
kind: str
|
|
120
|
+
origin: str
|
|
121
|
+
user: str | None
|
|
122
|
+
started_at: datetime
|
|
123
|
+
duration_s: float
|
|
124
|
+
status: RunStatus
|
|
125
|
+
targets: list[str]
|
|
126
|
+
saved: list[str]
|
|
127
|
+
output: str | None
|
|
128
|
+
errors: dict[str, str]
|
|
129
|
+
log_lines: int
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def of(cls, run: Run) -> Self:
|
|
133
|
+
values = run.model_dump(exclude={"log", "ended_at"})
|
|
134
|
+
return cls(**values, duration_s=run.duration_s, log_lines=len(run.log))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ReviewRequest(BaseModel):
|
|
138
|
+
key: str = Field(description="MODEL:UNIQUE_ID of a changed object, as in the reviews of the output.")
|
|
139
|
+
accepted: bool = Field(default=True, description="False withdraws the acceptance.")
|
|
140
|
+
by: str | None = Field(default=None, description="Who accepts.")
|
|
141
|
+
comment: str | None = Field(default=None, description="Why the change is right.")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ReviewState(BaseModel):
|
|
145
|
+
accepted: int
|
|
146
|
+
changes: int
|
|
147
|
+
to_review: list[str] = Field(description="The keys of the changes that nobody accepted yet.")
|
|
148
|
+
reviews: dict[str, Review]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class DescribeRequest(BaseModel):
|
|
152
|
+
title: str | None = Field(default=None, description="The new title; none keeps it, an empty text removes it.")
|
|
153
|
+
description: str | None = Field(default=None, description="The new description; none keeps it.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def create_api(state: AppState, row_cache: LruCache[Any, Any]) -> FastAPI:
|
|
157
|
+
"""The API application, mounted under PREFIX; its OpenAPI description is at PREFIX/openapi.json."""
|
|
158
|
+
api = FastAPI(
|
|
159
|
+
title="snapdelta API",
|
|
160
|
+
version=package_version("snapdelta-web"),
|
|
161
|
+
description="Take snapshots, compare them and read the diffs. With a token, send Authorization: Bearer TOKEN.",
|
|
162
|
+
docs_url=None,
|
|
163
|
+
redoc_url=None,
|
|
164
|
+
openapi_url="/openapi.json",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@api.exception_handler(SnapdeltaError)
|
|
168
|
+
async def snapdelta_error(request: Request, error: SnapdeltaError) -> JSONResponse:
|
|
169
|
+
return JSONResponse({"error": str(error)}, status_code=404 if isinstance(error, NotFoundError) else 400)
|
|
170
|
+
|
|
171
|
+
@api.exception_handler(Exception)
|
|
172
|
+
async def unexpected_error(request: Request, error: Exception) -> JSONResponse:
|
|
173
|
+
# The details stay in the server log: they can hold paths and data of the store.
|
|
174
|
+
return JSONResponse({"error": "unexpected error: see the log of snapdelta-web"}, status_code=500)
|
|
175
|
+
|
|
176
|
+
@api.get("/docs", include_in_schema=False)
|
|
177
|
+
def docs(request: Request) -> HTMLResponse:
|
|
178
|
+
# The Swagger UI files come from this server, so that the page works with no internet access.
|
|
179
|
+
return get_swagger_ui_html(
|
|
180
|
+
openapi_url=f"{request.scope.get('root_path', '')}/openapi.json",
|
|
181
|
+
title="snapdelta API",
|
|
182
|
+
swagger_js_url="/static/vendor/swagger-ui/swagger-ui-bundle.js",
|
|
183
|
+
swagger_css_url="/static/vendor/swagger-ui/swagger-ui.css",
|
|
184
|
+
swagger_favicon_url="/static/favicon.svg",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@api.get("/version")
|
|
188
|
+
def version() -> VersionInfo:
|
|
189
|
+
"""The versions of snapdelta, the web UI, diffsync, Python and the plugins."""
|
|
190
|
+
return VersionInfo(
|
|
191
|
+
components=[Component(**vars(item)) for item in components(["snapdelta-web"])],
|
|
192
|
+
plugins=[Component(**vars(item)) for item in plugins()],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
@api.get("/sources")
|
|
196
|
+
def sources() -> list[SourceInfo]:
|
|
197
|
+
"""The installed sources."""
|
|
198
|
+
return [
|
|
199
|
+
SourceInfo(name=name, distribution=cls.distribution, fetches=cls.fetches)
|
|
200
|
+
for name, cls in sorted(source_classes().items())
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
@api.get("/targets")
|
|
204
|
+
def targets(q: str = "") -> list[str]:
|
|
205
|
+
"""The targets that have snapshots in the store, whose name contains q."""
|
|
206
|
+
return state.store.targets(q)
|
|
207
|
+
|
|
208
|
+
@api.get("/snapshots")
|
|
209
|
+
def snapshots(target: str) -> list[SnapshotInfo]:
|
|
210
|
+
"""The snapshots of one target, newest first."""
|
|
211
|
+
return [
|
|
212
|
+
SnapshotInfo(
|
|
213
|
+
reference=entry.reference, target=entry.target, label=entry.label, captured_at=entry.captured_at
|
|
214
|
+
)
|
|
215
|
+
for entry in state.store.snapshot_entries(target)
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
@api.get("/snapshots/{target}/{name}")
|
|
219
|
+
def snapshot(target: str, name: str) -> SnapshotFile:
|
|
220
|
+
"""The whole snapshot document: metadata and data."""
|
|
221
|
+
return state.store.read_snapshot(state.store.resolve_snapshot(f"{target}/{name}"))
|
|
222
|
+
|
|
223
|
+
@api.post("/snapshots")
|
|
224
|
+
def take(request: SnapshotRequest) -> SnapshotResult:
|
|
225
|
+
"""Fetch the targets, at the same time, and save a snapshot of each; the call returns when all are done."""
|
|
226
|
+
taken, failures = snapshot_targets(
|
|
227
|
+
state.store,
|
|
228
|
+
state.settings,
|
|
229
|
+
request.source,
|
|
230
|
+
request.targets,
|
|
231
|
+
request.groups,
|
|
232
|
+
request.all_targets,
|
|
233
|
+
request.label,
|
|
234
|
+
origin="api",
|
|
235
|
+
)
|
|
236
|
+
saved = {target: item.reference for target, item in taken.items()}
|
|
237
|
+
return SnapshotResult(ok=not failures, saved=saved, failed=failures)
|
|
238
|
+
|
|
239
|
+
@api.post("/diffs")
|
|
240
|
+
def run_diff(request: DiffRequest, include_output: bool = False) -> DiffResult:
|
|
241
|
+
"""Compare two snapshots, or a snapshot and its target now; has_differences tells the result."""
|
|
242
|
+
done = diff_snapshots(
|
|
243
|
+
state.store,
|
|
244
|
+
state.settings,
|
|
245
|
+
request.left,
|
|
246
|
+
request.right,
|
|
247
|
+
live=request.live,
|
|
248
|
+
target=request.target,
|
|
249
|
+
models=request.models,
|
|
250
|
+
profile=request.profile,
|
|
251
|
+
title=request.title,
|
|
252
|
+
description=request.description,
|
|
253
|
+
save=request.save,
|
|
254
|
+
origin="api",
|
|
255
|
+
)
|
|
256
|
+
output, name = done.output, done.name
|
|
257
|
+
return DiffResult(
|
|
258
|
+
name=name,
|
|
259
|
+
url=f"/diffs/{name}" if name else None,
|
|
260
|
+
has_differences=output.has_differences(),
|
|
261
|
+
summary=output.summary,
|
|
262
|
+
left=output.left,
|
|
263
|
+
right=output.right,
|
|
264
|
+
title=output.title,
|
|
265
|
+
description=output.description,
|
|
266
|
+
output=output if include_output else None,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
@api.get("/diffs")
|
|
270
|
+
def diffs(q: str = "", limit: Annotated[int, Query(ge=1, le=500)] = 50) -> list[DiffItem]:
|
|
271
|
+
"""The newest saved diffs, whose left or right target contains q."""
|
|
272
|
+
return [
|
|
273
|
+
DiffItem.of(entry.name, state.store.load_output(entry.name))
|
|
274
|
+
for entry in state.store.output_entries(q)[:limit]
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
@api.get("/diffs/{name}", response_model=None)
|
|
278
|
+
def diff(name: str, format: Literal["json", "text", "markdown", "csv"] = "json") -> Response:
|
|
279
|
+
"""A saved diff: the output document, or the same text as `snapdelta output --format`."""
|
|
280
|
+
output = state.store.load_output(state.store.resolve_output(name))
|
|
281
|
+
if format == "json":
|
|
282
|
+
return Response(output.model_dump_json(by_alias=True), media_type="application/json")
|
|
283
|
+
chosen = renderer(format)
|
|
284
|
+
return PlainTextResponse(chosen.render(output), media_type=chosen.media_type)
|
|
285
|
+
|
|
286
|
+
@api.patch("/diffs/{name}")
|
|
287
|
+
def describe(name: str, request: DescribeRequest) -> DiffItem:
|
|
288
|
+
"""Change the title or the description of a saved diff."""
|
|
289
|
+
resolved = state.store.resolve_output(name)
|
|
290
|
+
current = state.store.load_output(resolved)
|
|
291
|
+
title = current.title if request.title is None else request.title
|
|
292
|
+
description = current.description if request.description is None else request.description
|
|
293
|
+
output = state.store.describe_output(resolved, title, description)
|
|
294
|
+
row_cache.discard((state.store.location, resolved))
|
|
295
|
+
return DiffItem.of(resolved, output)
|
|
296
|
+
|
|
297
|
+
@api.get("/runs")
|
|
298
|
+
def runs(kind: str | None = None, limit: Annotated[int, Query(ge=1, le=500)] = 50) -> list[RunItem]:
|
|
299
|
+
"""The newest runs (snapshot or diff), from the CLI, the web UI, the API and MCP."""
|
|
300
|
+
return [RunItem.of(run) for run in state.store.runs(limit, kind)]
|
|
301
|
+
|
|
302
|
+
@api.get("/runs/{run_id}")
|
|
303
|
+
def run(run_id: str) -> Run:
|
|
304
|
+
"""One run, with the log messages of its loads."""
|
|
305
|
+
return state.store.load_run(run_id)
|
|
306
|
+
|
|
307
|
+
@api.get("/diffs/{name}/reviews")
|
|
308
|
+
def reviews(name: str) -> ReviewState:
|
|
309
|
+
"""The accepted changes of a saved diff, and the ones still to review."""
|
|
310
|
+
return review_state(state.store.load_output(state.store.resolve_output(name)))
|
|
311
|
+
|
|
312
|
+
@api.post("/diffs/{name}/reviews")
|
|
313
|
+
def review(name: str, request: ReviewRequest) -> ReviewState:
|
|
314
|
+
"""Accept one change of a saved diff after a review, or withdraw the acceptance."""
|
|
315
|
+
resolved = state.store.resolve_output(name)
|
|
316
|
+
given = new_review(request.by, request.comment) if request.accepted else None
|
|
317
|
+
output = state.store.review_output(resolved, request.key, given)
|
|
318
|
+
row_cache.discard((state.store.location, resolved))
|
|
319
|
+
return review_state(output)
|
|
320
|
+
|
|
321
|
+
return api
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def review_state(output: Output) -> ReviewState:
|
|
325
|
+
accepted, changes = output.review_counts()
|
|
326
|
+
to_review = [key for key in output.change_keys() if key not in output.reviews]
|
|
327
|
+
return ReviewState(accepted=accepted, changes=changes, to_review=to_review, reviews=output.reviews)
|