devmemory-cli 0.1.0.dev0__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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
"""The development-version repository: persistence + hydration + full-text index.
|
|
2
|
+
|
|
3
|
+
The heaviest data-access object - a version fans out to changed files, tests,
|
|
4
|
+
metrics, regressions, analysis, artifacts, doc flags, and checkpoint links, all
|
|
5
|
+
written and read here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from devmemory.domain.enums import AssociationMethod, ChangeType, MetricDirection, VersionStatus
|
|
15
|
+
from devmemory.domain.models import (
|
|
16
|
+
Analysis,
|
|
17
|
+
Artifact,
|
|
18
|
+
ChangedFile,
|
|
19
|
+
DevelopmentEvent,
|
|
20
|
+
DevelopmentVersion,
|
|
21
|
+
DocFlag,
|
|
22
|
+
EnvironmentInfo,
|
|
23
|
+
Metric,
|
|
24
|
+
Regression,
|
|
25
|
+
TestOutcome,
|
|
26
|
+
)
|
|
27
|
+
from devmemory.storage.db import Database
|
|
28
|
+
from devmemory.storage.repositories import CheckpointRepository
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _utcnow() -> datetime:
|
|
32
|
+
return datetime.now(UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dt(value: str | None) -> datetime | None:
|
|
36
|
+
if not value:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return datetime.fromisoformat(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _iso(value: datetime | None) -> str | None:
|
|
45
|
+
return value.isoformat() if value else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class VersionRepository:
|
|
49
|
+
def __init__(self, db: Database) -> None:
|
|
50
|
+
self._db = db
|
|
51
|
+
self._checkpoints = CheckpointRepository(db)
|
|
52
|
+
|
|
53
|
+
# -- numbering / lookup ------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def next_version_number(self, project_id: str) -> int:
|
|
56
|
+
row = self._db.query_one(
|
|
57
|
+
"SELECT COALESCE(MAX(version_number), 0) + 1 AS n FROM versions WHERE project_id = ?",
|
|
58
|
+
(project_id,),
|
|
59
|
+
)
|
|
60
|
+
return int(row["n"]) if row else 1
|
|
61
|
+
|
|
62
|
+
def find_by_commit(self, project_id: str, git_commit: str) -> DevelopmentVersion | None:
|
|
63
|
+
row = self._db.query_one(
|
|
64
|
+
"SELECT * FROM versions WHERE project_id = ? AND git_commit = ?",
|
|
65
|
+
(project_id, git_commit),
|
|
66
|
+
)
|
|
67
|
+
return self._hydrate(row) if row else None
|
|
68
|
+
|
|
69
|
+
def get(self, version_id: str) -> DevelopmentVersion | None:
|
|
70
|
+
row = self._db.query_one("SELECT * FROM versions WHERE version_id = ?", (version_id,))
|
|
71
|
+
return self._hydrate(row) if row else None
|
|
72
|
+
|
|
73
|
+
def get_by_number(self, project_id: str, number: int) -> DevelopmentVersion | None:
|
|
74
|
+
row = self._db.query_one(
|
|
75
|
+
"SELECT * FROM versions WHERE project_id = ? AND version_number = ?",
|
|
76
|
+
(project_id, number),
|
|
77
|
+
)
|
|
78
|
+
return self._hydrate(row) if row else None
|
|
79
|
+
|
|
80
|
+
def resolve(self, project_id: str, ref: str) -> DevelopmentVersion | None:
|
|
81
|
+
"""Accept ``v7`` / ``V7`` / ``7`` / a git sha prefix."""
|
|
82
|
+
token = ref.strip().lower()
|
|
83
|
+
if token.startswith("v"):
|
|
84
|
+
token = token[1:]
|
|
85
|
+
if token.isdigit():
|
|
86
|
+
return self.get_by_number(project_id, int(token))
|
|
87
|
+
row = self._db.query_one(
|
|
88
|
+
"SELECT * FROM versions WHERE project_id = ? AND git_commit LIKE ?",
|
|
89
|
+
(project_id, f"{ref}%"),
|
|
90
|
+
)
|
|
91
|
+
return self._hydrate(row) if row else None
|
|
92
|
+
|
|
93
|
+
def latest(self, project_id: str) -> DevelopmentVersion | None:
|
|
94
|
+
row = self._db.query_one(
|
|
95
|
+
"SELECT * FROM versions WHERE project_id = ? ORDER BY version_number DESC LIMIT 1",
|
|
96
|
+
(project_id,),
|
|
97
|
+
)
|
|
98
|
+
return self._hydrate(row) if row else None
|
|
99
|
+
|
|
100
|
+
def count(self, project_id: str) -> int:
|
|
101
|
+
row = self._db.query_one(
|
|
102
|
+
"SELECT COUNT(*) AS c FROM versions WHERE project_id = ?", (project_id,)
|
|
103
|
+
)
|
|
104
|
+
return int(row["c"]) if row else 0
|
|
105
|
+
|
|
106
|
+
def page(
|
|
107
|
+
self,
|
|
108
|
+
project_id: str,
|
|
109
|
+
*,
|
|
110
|
+
limit: int = 100,
|
|
111
|
+
offset: int = 0,
|
|
112
|
+
ascending: bool = True,
|
|
113
|
+
) -> list[DevelopmentVersion]:
|
|
114
|
+
order = "ASC" if ascending else "DESC" # literal, not user input
|
|
115
|
+
rows = self._db.query(
|
|
116
|
+
"SELECT * FROM versions WHERE project_id = ? " # noqa: S608
|
|
117
|
+
f"ORDER BY version_number {order} LIMIT ? OFFSET ?",
|
|
118
|
+
(project_id, limit, offset),
|
|
119
|
+
)
|
|
120
|
+
return [self._hydrate(r) for r in rows]
|
|
121
|
+
|
|
122
|
+
def previous_relevant(
|
|
123
|
+
self, project_id: str, before_number: int, *, feature_id: str | None = None
|
|
124
|
+
) -> DevelopmentVersion | None:
|
|
125
|
+
"""The version to compare against for regression detection."""
|
|
126
|
+
if feature_id:
|
|
127
|
+
row = self._db.query_one(
|
|
128
|
+
"SELECT * FROM versions WHERE project_id = ? AND version_number < ? "
|
|
129
|
+
"AND feature_id = ? ORDER BY version_number DESC LIMIT 1",
|
|
130
|
+
(project_id, before_number, feature_id),
|
|
131
|
+
)
|
|
132
|
+
if row:
|
|
133
|
+
return self._hydrate(row)
|
|
134
|
+
row = self._db.query_one(
|
|
135
|
+
"SELECT * FROM versions WHERE project_id = ? AND version_number < ? "
|
|
136
|
+
"ORDER BY version_number DESC LIMIT 1",
|
|
137
|
+
(project_id, before_number),
|
|
138
|
+
)
|
|
139
|
+
return self._hydrate(row) if row else None
|
|
140
|
+
|
|
141
|
+
def for_feature(self, feature_id: str) -> list[DevelopmentVersion]:
|
|
142
|
+
rows = self._db.query(
|
|
143
|
+
"SELECT * FROM versions WHERE feature_id = ? ORDER BY version_number ASC",
|
|
144
|
+
(feature_id,),
|
|
145
|
+
)
|
|
146
|
+
return [self._hydrate(r) for r in rows]
|
|
147
|
+
|
|
148
|
+
# -- write ----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def create(
|
|
151
|
+
self,
|
|
152
|
+
version: DevelopmentVersion,
|
|
153
|
+
*,
|
|
154
|
+
source_event: DevelopmentEvent | None = None,
|
|
155
|
+
) -> DevelopmentVersion:
|
|
156
|
+
"""Persist a fully-assembled version and all its children in one transaction."""
|
|
157
|
+
with self._db.transaction() as conn:
|
|
158
|
+
self._insert_version(conn, version, source_event)
|
|
159
|
+
self._insert_changed_files(conn, version)
|
|
160
|
+
self._insert_tests(conn, version)
|
|
161
|
+
self._insert_metrics(conn, version)
|
|
162
|
+
self._insert_regressions(conn, version)
|
|
163
|
+
self._insert_analysis(conn, version)
|
|
164
|
+
self._insert_doc_flags(conn, version)
|
|
165
|
+
self._index_fts(conn, version)
|
|
166
|
+
|
|
167
|
+
if version.primary_checkpoint is not None:
|
|
168
|
+
self._checkpoints.upsert(version.project_id, version.primary_checkpoint)
|
|
169
|
+
self._checkpoints.link(
|
|
170
|
+
version.version_id,
|
|
171
|
+
version.primary_checkpoint.checkpoint_id,
|
|
172
|
+
is_primary=True,
|
|
173
|
+
)
|
|
174
|
+
return self.get(version.version_id) or version
|
|
175
|
+
|
|
176
|
+
def replace(
|
|
177
|
+
self,
|
|
178
|
+
version: DevelopmentVersion,
|
|
179
|
+
*,
|
|
180
|
+
source_event: DevelopmentEvent | None = None,
|
|
181
|
+
) -> DevelopmentVersion:
|
|
182
|
+
"""Re-record an existing version in place (same id/number), replacing all
|
|
183
|
+
derived data. Used by ``devmemory checkpoint --force``.
|
|
184
|
+
"""
|
|
185
|
+
with self._db.transaction() as conn:
|
|
186
|
+
# Child rows cascade on the version delete; do the version last.
|
|
187
|
+
for table in (
|
|
188
|
+
"changed_files",
|
|
189
|
+
"tests",
|
|
190
|
+
"metrics",
|
|
191
|
+
"regressions",
|
|
192
|
+
"analysis",
|
|
193
|
+
"doc_flags",
|
|
194
|
+
"version_checkpoints",
|
|
195
|
+
):
|
|
196
|
+
conn.execute(f"DELETE FROM {table} WHERE version_id = ?", (version.version_id,)) # noqa: S608
|
|
197
|
+
conn.execute("DELETE FROM versions WHERE version_id = ?", (version.version_id,))
|
|
198
|
+
self._insert_version(conn, version, source_event)
|
|
199
|
+
self._insert_changed_files(conn, version)
|
|
200
|
+
self._insert_tests(conn, version)
|
|
201
|
+
self._insert_metrics(conn, version)
|
|
202
|
+
self._insert_regressions(conn, version)
|
|
203
|
+
self._insert_analysis(conn, version)
|
|
204
|
+
self._insert_doc_flags(conn, version)
|
|
205
|
+
self._index_fts(conn, version)
|
|
206
|
+
|
|
207
|
+
if version.primary_checkpoint is not None:
|
|
208
|
+
self._checkpoints.upsert(version.project_id, version.primary_checkpoint)
|
|
209
|
+
self._checkpoints.link(
|
|
210
|
+
version.version_id,
|
|
211
|
+
version.primary_checkpoint.checkpoint_id,
|
|
212
|
+
is_primary=True,
|
|
213
|
+
)
|
|
214
|
+
return self.get(version.version_id) or version
|
|
215
|
+
|
|
216
|
+
def replace_analysis(self, version_id: str, analysis: Analysis) -> None:
|
|
217
|
+
version = self.get(version_id)
|
|
218
|
+
if version is None:
|
|
219
|
+
return
|
|
220
|
+
with self._db.transaction() as conn:
|
|
221
|
+
conn.execute("DELETE FROM analysis WHERE version_id = ?", (version_id,))
|
|
222
|
+
version.analysis = analysis
|
|
223
|
+
self._insert_analysis(conn, version)
|
|
224
|
+
self._index_fts(conn, version)
|
|
225
|
+
|
|
226
|
+
def add_artifact(self, artifact: Artifact) -> None:
|
|
227
|
+
with self._db.transaction() as conn:
|
|
228
|
+
conn.execute(
|
|
229
|
+
"INSERT OR REPLACE INTO artifacts "
|
|
230
|
+
"(artifact_id, version_id, path, type, size_bytes, sha256, created_at) "
|
|
231
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
232
|
+
(
|
|
233
|
+
artifact.artifact_id,
|
|
234
|
+
artifact.version_id,
|
|
235
|
+
artifact.path,
|
|
236
|
+
artifact.type,
|
|
237
|
+
artifact.size_bytes,
|
|
238
|
+
artifact.sha256,
|
|
239
|
+
_iso(artifact.created_at or _utcnow()),
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# -- insert helpers --------------------------------------------
|
|
244
|
+
|
|
245
|
+
def _insert_version(
|
|
246
|
+
self,
|
|
247
|
+
conn: sqlite3.Connection,
|
|
248
|
+
v: DevelopmentVersion,
|
|
249
|
+
event: DevelopmentEvent | None,
|
|
250
|
+
) -> None:
|
|
251
|
+
conn.execute(
|
|
252
|
+
"""
|
|
253
|
+
INSERT INTO versions (
|
|
254
|
+
version_number, version_id, project_id, intent, agent, model,
|
|
255
|
+
git_commit, parent_commit, branch, feature_id, status,
|
|
256
|
+
files_changed, lines_added, lines_removed,
|
|
257
|
+
entire_association_method, entire_association_confidence,
|
|
258
|
+
environment_json, source_event_json, run_id, created_at, committed_at
|
|
259
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
260
|
+
""",
|
|
261
|
+
(
|
|
262
|
+
v.version_number,
|
|
263
|
+
v.version_id,
|
|
264
|
+
v.project_id,
|
|
265
|
+
v.intent,
|
|
266
|
+
v.agent,
|
|
267
|
+
v.model,
|
|
268
|
+
v.git_commit,
|
|
269
|
+
v.parent_commit,
|
|
270
|
+
v.branch,
|
|
271
|
+
v.feature_id,
|
|
272
|
+
v.status.value,
|
|
273
|
+
v.files_changed,
|
|
274
|
+
v.lines_added,
|
|
275
|
+
v.lines_removed,
|
|
276
|
+
v.entire_association_method.value,
|
|
277
|
+
v.entire_association_confidence,
|
|
278
|
+
v.environment.model_dump_json() if v.environment else None,
|
|
279
|
+
event.model_dump_json() if event else None,
|
|
280
|
+
v.run_id,
|
|
281
|
+
_iso(v.created_at),
|
|
282
|
+
_iso(v.committed_at),
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _insert_changed_files(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
|
|
287
|
+
conn.executemany(
|
|
288
|
+
"INSERT INTO changed_files "
|
|
289
|
+
"(version_id, path, old_path, change_type, additions, deletions, binary) "
|
|
290
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
291
|
+
[
|
|
292
|
+
(
|
|
293
|
+
v.version_id,
|
|
294
|
+
f.path,
|
|
295
|
+
f.old_path,
|
|
296
|
+
f.change_type.value,
|
|
297
|
+
f.additions,
|
|
298
|
+
f.deletions,
|
|
299
|
+
1 if f.binary else 0,
|
|
300
|
+
)
|
|
301
|
+
for f in v.changed_files
|
|
302
|
+
],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def _insert_tests(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
|
|
306
|
+
if v.tests is None or not v.tests.ran:
|
|
307
|
+
return
|
|
308
|
+
t = v.tests
|
|
309
|
+
conn.execute(
|
|
310
|
+
"""
|
|
311
|
+
INSERT INTO tests (version_id, command, framework, total, passed, failed,
|
|
312
|
+
skipped, errors, exit_code, duration_seconds, failing_json,
|
|
313
|
+
output, collected_at)
|
|
314
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
315
|
+
""",
|
|
316
|
+
(
|
|
317
|
+
v.version_id,
|
|
318
|
+
t.command,
|
|
319
|
+
t.framework,
|
|
320
|
+
t.total,
|
|
321
|
+
t.passed,
|
|
322
|
+
t.failed,
|
|
323
|
+
t.skipped,
|
|
324
|
+
t.errors,
|
|
325
|
+
t.exit_code,
|
|
326
|
+
t.duration_seconds,
|
|
327
|
+
json.dumps(t.failing),
|
|
328
|
+
t.output,
|
|
329
|
+
_iso(_utcnow()),
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _insert_metrics(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
|
|
334
|
+
conn.executemany(
|
|
335
|
+
"INSERT INTO metrics (version_id, name, before_value, after_value, unit, "
|
|
336
|
+
"direction, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
337
|
+
[
|
|
338
|
+
(
|
|
339
|
+
v.version_id,
|
|
340
|
+
m.name,
|
|
341
|
+
m.before,
|
|
342
|
+
m.after,
|
|
343
|
+
m.unit,
|
|
344
|
+
m.direction.value,
|
|
345
|
+
json.dumps(m.metadata) if m.metadata else None,
|
|
346
|
+
)
|
|
347
|
+
for m in v.metrics
|
|
348
|
+
],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def _insert_regressions(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
|
|
352
|
+
conn.executemany(
|
|
353
|
+
"INSERT INTO regressions (version_id, kind, metric, before_value, after_value, "
|
|
354
|
+
"change_percent, severity, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
355
|
+
[
|
|
356
|
+
(
|
|
357
|
+
v.version_id,
|
|
358
|
+
r.kind,
|
|
359
|
+
r.metric,
|
|
360
|
+
r.before,
|
|
361
|
+
r.after,
|
|
362
|
+
r.change_percent,
|
|
363
|
+
r.severity,
|
|
364
|
+
r.detail,
|
|
365
|
+
)
|
|
366
|
+
for r in v.regressions
|
|
367
|
+
],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _insert_analysis(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
|
|
371
|
+
if v.analysis is None:
|
|
372
|
+
return
|
|
373
|
+
a = v.analysis
|
|
374
|
+
conn.execute(
|
|
375
|
+
"""
|
|
376
|
+
INSERT INTO analysis (version_id, summary, reasoning, recommendation,
|
|
377
|
+
warnings_json, risk, provider, model, generated_at)
|
|
378
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
379
|
+
""",
|
|
380
|
+
(
|
|
381
|
+
v.version_id,
|
|
382
|
+
a.summary,
|
|
383
|
+
a.reasoning,
|
|
384
|
+
a.recommendation,
|
|
385
|
+
json.dumps(a.warnings),
|
|
386
|
+
a.risk,
|
|
387
|
+
a.provider,
|
|
388
|
+
a.model,
|
|
389
|
+
_iso(a.generated_at or _utcnow()),
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _insert_doc_flags(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
|
|
394
|
+
conn.executemany(
|
|
395
|
+
"INSERT INTO doc_flags (version_id, doc_path, reason) VALUES (?, ?, ?)",
|
|
396
|
+
[(v.version_id, d.doc_path, d.reason) for d in v.doc_flags],
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _index_fts(self, conn: sqlite3.Connection, v: DevelopmentVersion) -> None:
|
|
400
|
+
conn.execute("DELETE FROM version_search WHERE version_id = ?", (v.version_id,))
|
|
401
|
+
files = " ".join(f.path for f in v.changed_files)
|
|
402
|
+
errors = " ".join(
|
|
403
|
+
(v.tests.failing if v.tests else []) + [r.detail or "" for r in v.regressions]
|
|
404
|
+
)
|
|
405
|
+
conn.execute(
|
|
406
|
+
"""
|
|
407
|
+
INSERT INTO version_search (version_id, intent, feature, agent, model, status,
|
|
408
|
+
files, commit_sha, checkpoint_id, analysis,
|
|
409
|
+
recommendation, errors)
|
|
410
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
411
|
+
""",
|
|
412
|
+
(
|
|
413
|
+
v.version_id,
|
|
414
|
+
v.intent or "",
|
|
415
|
+
v.feature_id or "",
|
|
416
|
+
v.agent or "",
|
|
417
|
+
v.model or "",
|
|
418
|
+
v.status.value,
|
|
419
|
+
files,
|
|
420
|
+
v.git_commit,
|
|
421
|
+
" ".join(v.checkpoint_ids),
|
|
422
|
+
(v.analysis.summary if v.analysis else ""),
|
|
423
|
+
(v.analysis.recommendation if v.analysis and v.analysis.recommendation else ""),
|
|
424
|
+
errors,
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# -- read: hydration -----------------------------------------
|
|
429
|
+
|
|
430
|
+
def _hydrate(self, row: sqlite3.Row) -> DevelopmentVersion:
|
|
431
|
+
vid = row["version_id"]
|
|
432
|
+
q = self._db.query
|
|
433
|
+
|
|
434
|
+
changed_files = [
|
|
435
|
+
ChangedFile(
|
|
436
|
+
path=r["path"],
|
|
437
|
+
old_path=r["old_path"],
|
|
438
|
+
change_type=ChangeType(r["change_type"]),
|
|
439
|
+
additions=r["additions"],
|
|
440
|
+
deletions=r["deletions"],
|
|
441
|
+
binary=bool(r["binary"]),
|
|
442
|
+
)
|
|
443
|
+
for r in q("SELECT * FROM changed_files WHERE version_id = ? ORDER BY id", (vid,))
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
test_row = self._db.query_one(
|
|
447
|
+
"SELECT * FROM tests WHERE version_id = ? ORDER BY id DESC LIMIT 1", (vid,)
|
|
448
|
+
)
|
|
449
|
+
tests = _test_from_row(test_row) if test_row else None
|
|
450
|
+
|
|
451
|
+
metrics = [
|
|
452
|
+
Metric(
|
|
453
|
+
name=r["name"],
|
|
454
|
+
before=r["before_value"],
|
|
455
|
+
after=r["after_value"],
|
|
456
|
+
unit=r["unit"],
|
|
457
|
+
direction=MetricDirection(r["direction"] or MetricDirection.HIGHER_IS_BETTER.value),
|
|
458
|
+
metadata=json.loads(r["metadata_json"]) if r["metadata_json"] else {},
|
|
459
|
+
)
|
|
460
|
+
for r in q("SELECT * FROM metrics WHERE version_id = ? ORDER BY id", (vid,))
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
regressions = [
|
|
464
|
+
Regression(
|
|
465
|
+
version_id=vid,
|
|
466
|
+
kind=r["kind"],
|
|
467
|
+
metric=r["metric"],
|
|
468
|
+
before=r["before_value"],
|
|
469
|
+
after=r["after_value"],
|
|
470
|
+
change_percent=r["change_percent"],
|
|
471
|
+
severity=r["severity"],
|
|
472
|
+
detail=r["detail"],
|
|
473
|
+
)
|
|
474
|
+
for r in q("SELECT * FROM regressions WHERE version_id = ? ORDER BY id", (vid,))
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
analysis_row = self._db.query_one("SELECT * FROM analysis WHERE version_id = ?", (vid,))
|
|
478
|
+
analysis = _analysis_from_row(analysis_row) if analysis_row else None
|
|
479
|
+
|
|
480
|
+
artifacts = [
|
|
481
|
+
Artifact(
|
|
482
|
+
artifact_id=r["artifact_id"],
|
|
483
|
+
version_id=vid,
|
|
484
|
+
path=r["path"],
|
|
485
|
+
type=r["type"],
|
|
486
|
+
size_bytes=r["size_bytes"],
|
|
487
|
+
sha256=r["sha256"],
|
|
488
|
+
created_at=_dt(r["created_at"]),
|
|
489
|
+
)
|
|
490
|
+
for r in q("SELECT * FROM artifacts WHERE version_id = ? ORDER BY created_at", (vid,))
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
doc_flags = [
|
|
494
|
+
DocFlag(doc_path=r["doc_path"], reason=r["reason"])
|
|
495
|
+
for r in q("SELECT * FROM doc_flags WHERE version_id = ?", (vid,))
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
checkpoint_links = self._checkpoints.for_version(vid)
|
|
499
|
+
primary = next((c for c, is_primary in checkpoint_links if is_primary), None)
|
|
500
|
+
if primary is None and checkpoint_links:
|
|
501
|
+
primary = checkpoint_links[0][0]
|
|
502
|
+
|
|
503
|
+
env_raw = row["environment_json"]
|
|
504
|
+
environment = EnvironmentInfo.model_validate_json(env_raw) if env_raw else None
|
|
505
|
+
|
|
506
|
+
return DevelopmentVersion(
|
|
507
|
+
version_id=vid,
|
|
508
|
+
version_number=row["version_number"],
|
|
509
|
+
project_id=row["project_id"],
|
|
510
|
+
intent=row["intent"],
|
|
511
|
+
agent=row["agent"],
|
|
512
|
+
model=row["model"],
|
|
513
|
+
git_commit=row["git_commit"],
|
|
514
|
+
parent_commit=row["parent_commit"],
|
|
515
|
+
branch=row["branch"],
|
|
516
|
+
feature_id=row["feature_id"],
|
|
517
|
+
status=VersionStatus(row["status"]),
|
|
518
|
+
files_changed=row["files_changed"],
|
|
519
|
+
lines_added=row["lines_added"],
|
|
520
|
+
lines_removed=row["lines_removed"],
|
|
521
|
+
changed_files=changed_files,
|
|
522
|
+
primary_checkpoint=primary,
|
|
523
|
+
checkpoint_ids=[c.checkpoint_id for c, _ in checkpoint_links],
|
|
524
|
+
entire_association_method=AssociationMethod(
|
|
525
|
+
row["entire_association_method"] or AssociationMethod.NONE.value
|
|
526
|
+
),
|
|
527
|
+
entire_association_confidence=row["entire_association_confidence"] or 0.0,
|
|
528
|
+
tests=tests,
|
|
529
|
+
metrics=metrics,
|
|
530
|
+
regressions=regressions,
|
|
531
|
+
analysis=analysis,
|
|
532
|
+
artifacts=artifacts,
|
|
533
|
+
doc_flags=doc_flags,
|
|
534
|
+
environment=environment,
|
|
535
|
+
run_id=row["run_id"],
|
|
536
|
+
created_at=_dt(row["created_at"]) or _utcnow(),
|
|
537
|
+
committed_at=_dt(row["committed_at"]),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# -- search ------------------------------------------------
|
|
541
|
+
|
|
542
|
+
def search_ids(self, project_id: str, query: str, *, limit: int = 50) -> list[str]:
|
|
543
|
+
if not query.strip():
|
|
544
|
+
return []
|
|
545
|
+
try:
|
|
546
|
+
rows = self._db.query(
|
|
547
|
+
"""
|
|
548
|
+
SELECT s.version_id FROM version_search s
|
|
549
|
+
JOIN versions v ON v.version_id = s.version_id
|
|
550
|
+
WHERE v.project_id = ? AND version_search MATCH ?
|
|
551
|
+
ORDER BY rank LIMIT ?
|
|
552
|
+
""",
|
|
553
|
+
(project_id, _fts_query(query), limit),
|
|
554
|
+
)
|
|
555
|
+
except sqlite3.OperationalError:
|
|
556
|
+
like = f"%{query}%"
|
|
557
|
+
rows = self._db.query(
|
|
558
|
+
"""
|
|
559
|
+
SELECT version_id FROM versions
|
|
560
|
+
WHERE project_id = ? AND (intent LIKE ? OR agent LIKE ? OR git_commit LIKE ?)
|
|
561
|
+
ORDER BY version_number DESC LIMIT ?
|
|
562
|
+
""",
|
|
563
|
+
(project_id, like, like, like, limit),
|
|
564
|
+
)
|
|
565
|
+
return [r["version_id"] for r in rows]
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _fts_query(raw: str) -> str:
|
|
569
|
+
"""Make user input safe for FTS5 MATCH: quote each bare term, keep it prefix-y."""
|
|
570
|
+
terms = [t for t in raw.replace('"', " ").split() if t]
|
|
571
|
+
return " ".join(f'"{t}"*' if t.isalnum() else f'"{t}"' for t in terms) or '""'
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _test_from_row(row: sqlite3.Row) -> TestOutcome:
|
|
575
|
+
return TestOutcome(
|
|
576
|
+
command=row["command"],
|
|
577
|
+
framework=row["framework"],
|
|
578
|
+
total=row["total"],
|
|
579
|
+
passed=row["passed"],
|
|
580
|
+
failed=row["failed"],
|
|
581
|
+
skipped=row["skipped"],
|
|
582
|
+
errors=row["errors"],
|
|
583
|
+
exit_code=row["exit_code"],
|
|
584
|
+
duration_seconds=row["duration_seconds"],
|
|
585
|
+
failing=json.loads(row["failing_json"]) if row["failing_json"] else [],
|
|
586
|
+
output=row["output"],
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _analysis_from_row(row: sqlite3.Row) -> Analysis:
|
|
591
|
+
return Analysis(
|
|
592
|
+
version_id=row["version_id"],
|
|
593
|
+
summary=row["summary"] or "",
|
|
594
|
+
reasoning=row["reasoning"],
|
|
595
|
+
recommendation=row["recommendation"],
|
|
596
|
+
warnings=json.loads(row["warnings_json"]) if row["warnings_json"] else [],
|
|
597
|
+
risk=row["risk"],
|
|
598
|
+
provider=row["provider"],
|
|
599
|
+
model=row["model"],
|
|
600
|
+
generated_at=_dt(row["generated_at"]),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
__all__ = ["VersionRepository"]
|