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.
Files changed (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
devmemory/api/app.py ADDED
@@ -0,0 +1,390 @@
1
+ """FastAPI app: the dashboard's JSON API plus the static single-page frontend.
2
+
3
+ Note: this module deliberately does *not* use ``from __future__ import
4
+ annotations`` - FastAPI needs the dependency ``Annotated[...]`` aliases to be real
5
+ objects at decoration time, and the local ``Ctx`` alias would not resolve as a
6
+ string.
7
+ """
8
+
9
+ import time
10
+ from collections.abc import AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+ from pathlib import Path
13
+ from typing import Annotated
14
+
15
+ from fastapi import Depends, FastAPI, HTTPException, Query
16
+ from fastapi.responses import FileResponse, PlainTextResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+
19
+ from devmemory.__about__ import __version__
20
+ from devmemory.adapters.graph import GraphImpact
21
+ from devmemory.api import mappers
22
+ from devmemory.api.schemas import (
23
+ AgentCheckRequest,
24
+ ComparisonResponse,
25
+ FeatureDetail,
26
+ IssueRequest,
27
+ ProjectBriefDoc,
28
+ ProjectBriefRequest,
29
+ ProjectSummary,
30
+ RequirementUpdateRequest,
31
+ SearchResponse,
32
+ SnapshotSummary,
33
+ TaskCreateRequest,
34
+ TaskSummary,
35
+ VersionListItem,
36
+ )
37
+ from devmemory.domain.enums import RequirementStatus
38
+ from devmemory.domain.errors import DevMemoryError
39
+ from devmemory.domain.models import (
40
+ Analysis,
41
+ CheckpointReference,
42
+ DevelopmentVersion,
43
+ EntireStatus,
44
+ )
45
+ from devmemory.domain.taskloop import NormalizedState
46
+ from devmemory.services import brief as briefsvc
47
+ from devmemory.services import taskloop
48
+ from devmemory.services.agent_context import (
49
+ ChangeGuidance,
50
+ ProjectBrief,
51
+ VersionBrief,
52
+ change_guidance,
53
+ project_brief,
54
+ recent_history,
55
+ )
56
+ from devmemory.services.analysis import analyze_version
57
+ from devmemory.services.analytics import AnalyticsSummary, analytics_summary
58
+ from devmemory.services.context import ProjectContext
59
+ from devmemory.services.features import get_feature, list_features
60
+ from devmemory.services.impact import version_impact
61
+ from devmemory.services.memory import MemoryQuery, PreviousAttempt, previous_attempts
62
+ from devmemory.services.projects import project_status
63
+ from devmemory.services.restore import (
64
+ RestorePreview,
65
+ RestoreResult,
66
+ restore_preview,
67
+ restore_version,
68
+ )
69
+ from devmemory.services.trace import DevelopmentTrace, development_trace
70
+ from devmemory.services.versions import (
71
+ get_version,
72
+ list_versions,
73
+ search_versions,
74
+ version_diff,
75
+ )
76
+
77
+ # The dashboard is a Vite/React app. Its source lives in src/devmemory/web/frontend/
78
+ # and the built bundle is committed to src/devmemory/web/static/ (the wheel ships
79
+ # it, so `devmemory serve` needs no Node). Run `npm run build` there to refresh it.
80
+ _FRONTEND_DIR = Path(__file__).parents[1] / "web" / "static"
81
+ _PROBE_TTL = 20.0
82
+
83
+
84
+ class _ProbeCache:
85
+ """The Entire probe shells out; cache it briefly so the dashboard stays snappy."""
86
+
87
+ def __init__(self) -> None:
88
+ self._at = 0.0
89
+ self._value: EntireStatus | None = None
90
+
91
+ def get(self, ctx: ProjectContext) -> EntireStatus:
92
+ now = time.monotonic()
93
+ if self._value is None or now - self._at > _PROBE_TTL:
94
+ self._value = ctx.entire.probe()
95
+ self._at = now
96
+ return self._value
97
+
98
+
99
+ def create_app(repo_path: Path | str | None = None, *, enable_restore: bool = False) -> FastAPI:
100
+ resolved = Path(repo_path) if repo_path else None
101
+ holder: dict[str, ProjectContext] = {}
102
+
103
+ @asynccontextmanager
104
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
105
+ holder["ctx"] = ProjectContext.load(resolved, thread_safe=True)
106
+ try:
107
+ yield
108
+ finally:
109
+ holder.pop("ctx").close()
110
+
111
+ app = FastAPI(
112
+ title="DevMemory",
113
+ version=__version__,
114
+ summary="Development-memory and version-intelligence dashboard API",
115
+ docs_url="/api/docs",
116
+ openapi_url="/api/openapi.json",
117
+ lifespan=lifespan,
118
+ )
119
+ app.state.enable_restore = enable_restore
120
+ probe_cache = _ProbeCache()
121
+
122
+ def get_ctx() -> ProjectContext:
123
+ # One shared, thread-safe context for the life of the server.
124
+ if "ctx" not in holder: # pragma: no cover - only outside the lifespan
125
+ holder["ctx"] = ProjectContext.load(resolved, thread_safe=True)
126
+ return holder["ctx"]
127
+
128
+ Ctx = Annotated[ProjectContext, Depends(get_ctx)] # noqa: N806 - a type alias
129
+
130
+ @app.exception_handler(DevMemoryError)
131
+ async def _domain_error(_request: object, exc: DevMemoryError) -> PlainTextResponse:
132
+ code = 404 if exc.exit_code in (3, 9) else 400
133
+ return PlainTextResponse(exc.message, status_code=code)
134
+
135
+ # -- project / status ------------------------------------------------
136
+
137
+ @app.get("/api/health")
138
+ def health() -> dict[str, str]:
139
+ return {"status": "ok", "version": __version__}
140
+
141
+ @app.get("/api/project", response_model=ProjectSummary)
142
+ @app.get("/api/status", response_model=ProjectSummary)
143
+ def project(ctx: Ctx) -> ProjectSummary:
144
+ return mappers.project_summary(project_status(ctx, entire_probe=probe_cache.get(ctx)))
145
+
146
+ @app.get("/api/project/brief", response_model=ProjectBriefDoc)
147
+ def get_project_brief(ctx: Ctx) -> ProjectBriefDoc:
148
+ doc = briefsvc.get_brief(ctx)
149
+ return ProjectBriefDoc(content=doc.content, updated_at=doc.updated_at)
150
+
151
+ @app.put("/api/project/brief", response_model=ProjectBriefDoc)
152
+ def put_project_brief(ctx: Ctx, body: ProjectBriefRequest) -> ProjectBriefDoc:
153
+ doc = briefsvc.set_brief(ctx, body.content)
154
+ return ProjectBriefDoc(content=doc.content, updated_at=doc.updated_at)
155
+
156
+ # -- versions ------------------------------------------------------
157
+
158
+ @app.get("/api/versions", response_model=list[VersionListItem])
159
+ def versions(
160
+ ctx: Ctx,
161
+ limit: Annotated[int, Query(ge=1, le=1000)] = 200,
162
+ offset: Annotated[int, Query(ge=0)] = 0,
163
+ ) -> list[VersionListItem]:
164
+ items = list_versions(ctx, limit=limit, offset=offset, ascending=True)
165
+ return [mappers.version_list_item(v) for v in items]
166
+
167
+ @app.get("/api/versions/{ref}", response_model=DevelopmentVersion)
168
+ def version(ctx: Ctx, ref: str) -> DevelopmentVersion:
169
+ return get_version(ctx, ref)
170
+
171
+ @app.get("/api/versions/{ref}/diff", response_class=PlainTextResponse)
172
+ def version_diff_text(ctx: Ctx, ref: str) -> str:
173
+ v = get_version(ctx, ref)
174
+ return ctx.git.diff_text(v.parent_commit, v.git_commit)
175
+
176
+ @app.get("/api/versions/{ref}/checkpoint", response_model=CheckpointReference)
177
+ def version_checkpoint(ctx: Ctx, ref: str) -> CheckpointReference:
178
+ v = get_version(ctx, ref)
179
+ if v.primary_checkpoint is None:
180
+ raise HTTPException(status_code=404, detail="no checkpoint linked to this version")
181
+ return v.primary_checkpoint
182
+
183
+ @app.get("/api/versions/{ref}/trace", response_model=DevelopmentTrace)
184
+ def version_trace(ctx: Ctx, ref: str) -> DevelopmentTrace:
185
+ return development_trace(ctx, ref)
186
+
187
+ @app.post("/api/versions/{ref}/analysis", response_model=Analysis)
188
+ def regenerate_analysis(ctx: Ctx, ref: str) -> Analysis:
189
+ """Re-run the analysis provider chain for a version (interpretation only)."""
190
+ return analyze_version(ctx, ref)
191
+
192
+ @app.get("/api/versions/{ref}/impact", response_model=GraphImpact)
193
+ def version_impact_endpoint(ctx: Ctx, ref: str) -> GraphImpact:
194
+ impact = version_impact(ctx, ref, compute_if_missing=ctx.config.graph.enabled)
195
+ if impact is None:
196
+ raise HTTPException(
197
+ status_code=404,
198
+ detail="no change-impact analysis for this version "
199
+ "(install the Entire `graph` plugin)",
200
+ )
201
+ return impact
202
+
203
+ # -- comparison --------------------------------------------------
204
+
205
+ @app.get("/api/compare", response_model=ComparisonResponse)
206
+ def compare(
207
+ ctx: Ctx,
208
+ from_: Annotated[str, Query(alias="from")],
209
+ to: Annotated[str, Query(alias="to")],
210
+ ) -> ComparisonResponse:
211
+ return mappers.comparison_response(version_diff(ctx, from_, to))
212
+
213
+ # -- features ---------------------------------------------------
214
+
215
+ @app.get("/api/features", response_model=list[FeatureDetail])
216
+ def features(ctx: Ctx) -> list[FeatureDetail]:
217
+ return [mappers.feature_detail(f) for f in list_features(ctx)]
218
+
219
+ @app.get("/api/features/{ref}", response_model=FeatureDetail)
220
+ def feature(ctx: Ctx, ref: str) -> FeatureDetail:
221
+ return mappers.feature_detail(get_feature(ctx, ref))
222
+
223
+ # -- analytics ---------------------------------------------
224
+
225
+ @app.get("/api/analytics", response_model=AnalyticsSummary)
226
+ def analytics(ctx: Ctx) -> AnalyticsSummary:
227
+ return analytics_summary(ctx)
228
+
229
+ # -- agent context (the REST half of the MCP surface) ------
230
+
231
+ @app.get("/api/agent/context", response_model=ProjectBrief)
232
+ def agent_context(ctx: Ctx) -> ProjectBrief:
233
+ return project_brief(ctx)
234
+
235
+ @app.get("/api/agent/history", response_model=list[VersionBrief])
236
+ def agent_history(
237
+ ctx: Ctx,
238
+ limit: Annotated[int, Query(ge=1, le=200)] = 20,
239
+ feature: Annotated[str | None, Query()] = None,
240
+ ) -> list[VersionBrief]:
241
+ return recent_history(ctx, limit=limit, feature=feature)
242
+
243
+ @app.post("/api/agent/check", response_model=ChangeGuidance)
244
+ def agent_check(ctx: Ctx, body: AgentCheckRequest) -> ChangeGuidance:
245
+ return change_guidance(ctx, files=body.files, intent=body.intent, feature=body.feature)
246
+
247
+ # -- the state-aware coding loop -----------------------------------
248
+
249
+ @app.get("/api/tasks", response_model=list[TaskSummary])
250
+ def tasks(ctx: Ctx) -> list[TaskSummary]:
251
+ return [mappers.task_summary(t) for t in taskloop.list_tasks(ctx)]
252
+
253
+ @app.post("/api/tasks", response_model=NormalizedState, status_code=201)
254
+ def create_task(ctx: Ctx, body: TaskCreateRequest) -> NormalizedState:
255
+ task = taskloop.create_task(ctx, goal=body.goal, test_command=body.test_command)
256
+ return taskloop.get_state(ctx, task.id)
257
+
258
+ @app.get("/api/tasks/{task_id}/state", response_model=NormalizedState)
259
+ def task_state(ctx: Ctx, task_id: str) -> NormalizedState:
260
+ return taskloop.get_state(ctx, task_id)
261
+
262
+ @app.post("/api/tasks/{task_id}/refresh", response_model=NormalizedState)
263
+ def task_refresh(ctx: Ctx, task_id: str) -> NormalizedState:
264
+ return taskloop.refresh_state(ctx, task_id)
265
+
266
+ @app.post("/api/tasks/{task_id}/complete", response_model=NormalizedState)
267
+ def task_complete(ctx: Ctx, task_id: str) -> NormalizedState:
268
+ return taskloop.mark_complete(ctx, task_id)
269
+
270
+ @app.get("/api/tasks/{task_id}/snapshots", response_model=list[SnapshotSummary])
271
+ def task_snapshots(
272
+ ctx: Ctx,
273
+ task_id: str,
274
+ limit: Annotated[int, Query(ge=1, le=200)] = 40,
275
+ ) -> list[SnapshotSummary]:
276
+ return [
277
+ mappers.snapshot_summary(s) for s in taskloop.list_snapshots(ctx, task_id, limit=limit)
278
+ ]
279
+
280
+ @app.post("/api/tasks/{task_id}/issues", response_model=NormalizedState)
281
+ def task_issue(ctx: Ctx, task_id: str, body: IssueRequest) -> NormalizedState:
282
+ taskloop.report_issue(
283
+ ctx, task_id=task_id, description=body.description, blocking=body.blocking
284
+ )
285
+ return taskloop.refresh_state(ctx, task_id)
286
+
287
+ @app.post("/api/tasks/{task_id}/issues/{issue_id}/resolve", response_model=NormalizedState)
288
+ def task_issue_resolve(ctx: Ctx, task_id: str, issue_id: int) -> NormalizedState:
289
+ taskloop.resolve_issue(ctx, issue_id=issue_id)
290
+ return taskloop.refresh_state(ctx, task_id)
291
+
292
+ @app.post("/api/tasks/{task_id}/requirements/{req_id}", response_model=NormalizedState)
293
+ def task_set_requirement(
294
+ ctx: Ctx, task_id: str, req_id: str, body: RequirementUpdateRequest
295
+ ) -> NormalizedState:
296
+ try:
297
+ status = RequirementStatus(body.status.upper())
298
+ except ValueError as exc:
299
+ raise HTTPException(status_code=422, detail=f"invalid status {body.status!r}") from exc
300
+ return taskloop.set_requirement_status(
301
+ ctx, task_id=task_id, requirement_id=req_id, status=status, note=body.note
302
+ )
303
+
304
+ # -- restore ------------------------------------------------
305
+
306
+ @app.get("/api/versions/{ref}/restore/preview", response_model=RestorePreview)
307
+ def restore_preview_endpoint(ctx: Ctx, ref: str) -> RestorePreview:
308
+ return restore_preview(ctx, ref)
309
+
310
+ @app.post("/api/versions/{ref}/restore", response_model=RestoreResult)
311
+ def restore_endpoint(
312
+ ctx: Ctx,
313
+ ref: str,
314
+ mode: Annotated[str, Query(pattern="^(detach|hard)$")] = "detach",
315
+ allow_dirty: Annotated[bool, Query()] = False,
316
+ ) -> RestoreResult:
317
+ if not app.state.enable_restore:
318
+ raise HTTPException(
319
+ status_code=403,
320
+ detail="restore is disabled; start the server with `devmemory serve --enable-restore`",
321
+ )
322
+ return restore_version(ctx, ref, mode=mode, allow_dirty=allow_dirty)
323
+
324
+ # -- development memory --------------------------------------
325
+
326
+ @app.get("/api/attempts", response_model=list[PreviousAttempt])
327
+ def attempts(
328
+ ctx: Ctx,
329
+ feature: Annotated[str | None, Query()] = None,
330
+ q: Annotated[str | None, Query()] = None,
331
+ file: Annotated[list[str] | None, Query()] = None,
332
+ include_successes: Annotated[bool, Query()] = False,
333
+ limit: Annotated[int, Query(ge=1, le=100)] = 25,
334
+ ) -> list[PreviousAttempt]:
335
+ return previous_attempts(
336
+ ctx,
337
+ MemoryQuery(
338
+ files=file or [],
339
+ feature=feature,
340
+ intent=q,
341
+ include_successes=include_successes,
342
+ limit=limit,
343
+ ),
344
+ )
345
+
346
+ @app.get("/api/versions/{ref}/attempts", response_model=list[PreviousAttempt])
347
+ def version_attempts(ctx: Ctx, ref: str) -> list[PreviousAttempt]:
348
+ v = get_version(ctx, ref)
349
+ found = previous_attempts(
350
+ ctx,
351
+ MemoryQuery(
352
+ files=[f.path for f in v.changed_files],
353
+ feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
354
+ intent=v.intent,
355
+ limit=6,
356
+ ),
357
+ )
358
+ return [a for a in found if a.version_id != v.version_id]
359
+
360
+ # -- search ---------------------------------------------------
361
+
362
+ @app.get("/api/search", response_model=SearchResponse)
363
+ def search(
364
+ ctx: Ctx,
365
+ q: Annotated[str, Query(min_length=1)],
366
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
367
+ ) -> SearchResponse:
368
+ hits = [mappers.search_hit(v) for v in search_versions(ctx, q, limit=limit)]
369
+ return SearchResponse(query=q, count=len(hits), results=hits)
370
+
371
+ # -- frontend (hash-routed SPA, no build step) ----------------
372
+
373
+ if (_FRONTEND_DIR / "index.html").is_file():
374
+ app.mount("/static", StaticFiles(directory=_FRONTEND_DIR), name="static")
375
+
376
+ @app.get("/", include_in_schema=False)
377
+ def index() -> FileResponse:
378
+ # Never let a browser hold a stale index.html: it points at
379
+ # content-hashed asset filenames that change on every rebuild, so a
380
+ # cached copy boots old JS against the current API. The hashed
381
+ # /static/assets/* are immutable and cache freely.
382
+ return FileResponse(
383
+ _FRONTEND_DIR / "index.html",
384
+ headers={"Cache-Control": "no-cache, must-revalidate"},
385
+ )
386
+
387
+ return app
388
+
389
+
390
+ __all__ = ["create_app"]
@@ -0,0 +1,187 @@
1
+ """Domain models -> API response schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from devmemory.api.schemas import (
6
+ ComparisonResponse,
7
+ FeatureDetail,
8
+ FeatureHistoryPoint,
9
+ MetricChange,
10
+ ProjectSummary,
11
+ SearchHit,
12
+ SnapshotSummary,
13
+ TaskSummary,
14
+ VersionListItem,
15
+ )
16
+ from devmemory.domain.enums import RequirementStatus
17
+ from devmemory.domain.models import DevelopmentVersion
18
+ from devmemory.domain.taskloop import StateSnapshot, Task
19
+ from devmemory.services.features import FeatureWithHistory
20
+ from devmemory.services.projects import ProjectStatusReport
21
+ from devmemory.services.versions import VersionDiff
22
+
23
+
24
+ def _iso(value: object) -> str | None:
25
+ return value.isoformat() if hasattr(value, "isoformat") else None
26
+
27
+
28
+ def project_summary(report: ProjectStatusReport) -> ProjectSummary:
29
+ return ProjectSummary(
30
+ project_id=report.project.project_id,
31
+ name=report.project.name,
32
+ repo_path=report.project.repo_path,
33
+ branch=report.branch,
34
+ head_sha=report.head_sha,
35
+ head_subject=report.head_subject,
36
+ working_tree_clean=report.working_tree_clean,
37
+ version_count=report.version_count,
38
+ latest_version_id=report.latest_version_id,
39
+ latest_status=report.latest_status,
40
+ latest_intent=report.latest_intent,
41
+ head_has_version=report.head_has_version,
42
+ last_regression_id=report.last_regression_id,
43
+ open_features=report.open_features,
44
+ latest_metrics=report.latest_metrics,
45
+ entire_installed=report.entire.installed,
46
+ entire_enabled=report.entire.enabled,
47
+ entire_version=report.entire.cli_version,
48
+ entire_agents=report.entire.agents,
49
+ )
50
+
51
+
52
+ def version_list_item(v: DevelopmentVersion) -> VersionListItem:
53
+ cp = v.primary_checkpoint
54
+ return VersionListItem(
55
+ version_id=v.version_id,
56
+ version_number=v.version_number,
57
+ status=v.status.value,
58
+ intent=v.intent,
59
+ agent=v.agent,
60
+ model=v.model,
61
+ feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
62
+ git_commit=v.git_commit,
63
+ branch=v.branch,
64
+ files_changed=v.files_changed,
65
+ lines_added=v.lines_added,
66
+ lines_removed=v.lines_removed,
67
+ checkpoint_id=cp.checkpoint_id if cp else None,
68
+ association_method=v.entire_association_method.value,
69
+ association_confidence=v.entire_association_confidence,
70
+ metrics={m.name: m.after for m in v.metrics},
71
+ tests_passed=v.tests.passed if v.tests and v.tests.ran else None,
72
+ tests_failed=v.tests.failed if v.tests and v.tests.ran else None,
73
+ has_regression=bool(v.regressions),
74
+ committed_at=_iso(v.committed_at),
75
+ created_at=v.created_at.isoformat(),
76
+ )
77
+
78
+
79
+ def search_hit(v: DevelopmentVersion) -> SearchHit:
80
+ return SearchHit(
81
+ version_id=v.version_id,
82
+ version_number=v.version_number,
83
+ status=v.status.value,
84
+ intent=v.intent,
85
+ agent=v.agent,
86
+ feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
87
+ git_commit=v.git_commit,
88
+ snippet=(v.analysis.summary if v.analysis and v.analysis.summary else v.intent),
89
+ )
90
+
91
+
92
+ def comparison_response(diff: VersionDiff) -> ComparisonResponse:
93
+ return ComparisonResponse(
94
+ from_version=diff.from_version_id,
95
+ to_version=diff.to_version_id,
96
+ from_commit=diff.from_commit,
97
+ to_commit=diff.to_commit,
98
+ stat=diff.stat,
99
+ files=diff.files,
100
+ diff_text=diff.diff_text,
101
+ metric_changes=[
102
+ MetricChange(
103
+ name=name,
104
+ before=change["before"],
105
+ after=change["after"],
106
+ delta=change["delta"],
107
+ unit=None,
108
+ direction="higher_is_better",
109
+ )
110
+ for name, change in diff.metric_changes.items()
111
+ ],
112
+ test_changes=diff.test_changes,
113
+ status_from=diff.status_from.value,
114
+ status_to=diff.status_to.value,
115
+ feature_from=diff.feature_from,
116
+ feature_to=diff.feature_to,
117
+ checkpoint_from=diff.checkpoint_from,
118
+ checkpoint_to=diff.checkpoint_to,
119
+ )
120
+
121
+
122
+ def feature_detail(fh: FeatureWithHistory) -> FeatureDetail:
123
+ latest_metrics: dict[str, float | None] = {}
124
+ if fh.versions:
125
+ latest_metrics = {m.name: m.after for m in fh.versions[-1].metrics}
126
+ return FeatureDetail(
127
+ feature_id=fh.feature.feature_id,
128
+ name=fh.feature.name,
129
+ status=fh.rolled_up_status.value,
130
+ derived_from=fh.feature.derived_from,
131
+ version_count=len(fh.versions),
132
+ latest_metrics=latest_metrics,
133
+ history=[
134
+ FeatureHistoryPoint(
135
+ version_id=v.version_id,
136
+ version_number=v.version_number,
137
+ status=v.status.value,
138
+ metrics={m.name: m.after for m in v.metrics},
139
+ committed_at=_iso(v.committed_at),
140
+ )
141
+ for v in fh.versions
142
+ ],
143
+ )
144
+
145
+
146
+ def task_summary(task: Task) -> TaskSummary:
147
+ return TaskSummary(
148
+ id=task.id,
149
+ goal=task.goal,
150
+ status=task.status.value,
151
+ branch=task.branch,
152
+ requirements_total=len(task.requirements),
153
+ requirements_complete=sum(
154
+ 1 for r in task.requirements if r.status is RequirementStatus.COMPLETE
155
+ ),
156
+ test_command=task.test_command,
157
+ updated_at=_iso(task.updated_at),
158
+ )
159
+
160
+
161
+ def snapshot_summary(snap: StateSnapshot) -> SnapshotSummary:
162
+ s = snap.state
163
+ return SnapshotSummary(
164
+ id=snap.id,
165
+ overall_status=snap.overall_status.value,
166
+ commit_sha=s.git.commit_sha,
167
+ checkpoint_id=s.checkpoint.current_id,
168
+ tests_passed=s.tests.passed,
169
+ tests_failed=s.tests.failed,
170
+ tests_status=s.tests.status.value,
171
+ requirements_total=len(s.requirements),
172
+ requirements_complete=sum(
173
+ 1 for r in s.requirements if r.status is RequirementStatus.COMPLETE
174
+ ),
175
+ created_at=_iso(snap.created_at),
176
+ )
177
+
178
+
179
+ __all__ = [
180
+ "comparison_response",
181
+ "feature_detail",
182
+ "project_summary",
183
+ "search_hit",
184
+ "snapshot_summary",
185
+ "task_summary",
186
+ "version_list_item",
187
+ ]