dna-cli 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.
- dna_cli/__init__.py +87 -0
- dna_cli/_active_story.py +134 -0
- dna_cli/_banner.py +61 -0
- dna_cli/_ctx.py +433 -0
- dna_cli/_git_symbiosis.py +167 -0
- dna_cli/_github_bridge.py +294 -0
- dna_cli/_methodology_gates.py +164 -0
- dna_cli/data/git-hooks/prepare-commit-msg +132 -0
- dna_cli/doc_cmd.py +842 -0
- dna_cli/docs_cmd.py +79 -0
- dna_cli/eval_cmd.py +274 -0
- dna_cli/hooks_cmd.py +146 -0
- dna_cli/install_cmd.py +496 -0
- dna_cli/issue_bridge_cmd.py +305 -0
- dna_cli/kind_cmd.py +84 -0
- dna_cli/memory_cmd.py +298 -0
- dna_cli/pr_cmd.py +251 -0
- dna_cli/recall_cmd.py +161 -0
- dna_cli/research_cmd.py +284 -0
- dna_cli/scope_cmd.py +129 -0
- dna_cli/sdlc_cmd.py +6549 -0
- dna_cli/source_cmd.py +437 -0
- dna_cli/testkit_cmd.py +352 -0
- dna_cli-0.1.0.dist-info/METADATA +57 -0
- dna_cli-0.1.0.dist-info/RECORD +28 -0
- dna_cli-0.1.0.dist-info/WHEEL +4 -0
- dna_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dna_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dna_cli/_ctx.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Shared CLI context — kernel session + output formatting (kernel-local).
|
|
2
|
+
|
|
3
|
+
The ``dna`` CLI is a thin wrapper over the DNA kernel: every command
|
|
4
|
+
boots a local Kernel against ``DNA_SOURCE_URL`` / ``DNA_BASE_DIR``
|
|
5
|
+
(filesystem source), runs one command, exits. No service, no HTTP —
|
|
6
|
+
the kernel IS the backend.
|
|
7
|
+
|
|
8
|
+
Two session patterns:
|
|
9
|
+
|
|
10
|
+
- ``dna_session(scope)`` — context manager that owns ONE event loop
|
|
11
|
+
for the entire command. Use for any command that does writes /
|
|
12
|
+
deletes / async ops.
|
|
13
|
+
|
|
14
|
+
with dna_session(scope) as s:
|
|
15
|
+
s.run(s.kernel.write_document(...))
|
|
16
|
+
|
|
17
|
+
- ``get_holder(scope)`` — sync helper for read-only commands that
|
|
18
|
+
only touch ``query_list`` / ``get_doc`` (no async afterwards).
|
|
19
|
+
|
|
20
|
+
Plus ``dna_client()`` — a LOCAL facade with the resource shape some
|
|
21
|
+
commands consume (``client.docs(scope).get/put/delete/list`` and
|
|
22
|
+
``client.scopes.list/tree/kinds/kind_schema``), backed by the same
|
|
23
|
+
local kernel. In the upstream platform this surface is an HTTP API
|
|
24
|
+
client; here it resolves in-process.
|
|
25
|
+
|
|
26
|
+
Output helpers: ``print_json``, ``print_table``, ``fail``.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
from contextlib import contextmanager
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Iterator
|
|
38
|
+
from urllib.parse import urlparse
|
|
39
|
+
|
|
40
|
+
import click
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ─── source resolution ────────────────────────────────────────────────
|
|
44
|
+
#
|
|
45
|
+
# Priority: DNA_SOURCE_URL > DNA_BASE_DIR (rewritten to file://<dir>) >
|
|
46
|
+
# ./.dna. This distribution ships the filesystem adapter on the boot
|
|
47
|
+
# path; the sqlite/postgres adapters are importable from dna.adapters
|
|
48
|
+
# and can be wired programmatically (kernel.source(...)) by host code.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_source_url(base_dir_override: str | None = None) -> str:
|
|
52
|
+
url = os.getenv("DNA_SOURCE_URL")
|
|
53
|
+
if url:
|
|
54
|
+
return url
|
|
55
|
+
base = base_dir_override or os.getenv("DNA_BASE_DIR")
|
|
56
|
+
if base:
|
|
57
|
+
p = Path(base).resolve()
|
|
58
|
+
# mirror the classic convention: a project dir with a .dna/ child
|
|
59
|
+
if (p / ".dna").is_dir():
|
|
60
|
+
p = p / ".dna"
|
|
61
|
+
return f"file://{p}"
|
|
62
|
+
return f"file://{Path('.dna').resolve()}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def build_source_from_env(kernel: Any, *, _source_url: str | None = None) -> Any:
|
|
66
|
+
"""Build a writable source from the environment (filesystem-only boot
|
|
67
|
+
path — see module docstring). Async for signature uniformity with the
|
|
68
|
+
upstream factory this replaces."""
|
|
69
|
+
url = _source_url or _resolve_source_url()
|
|
70
|
+
parsed = urlparse(url)
|
|
71
|
+
scheme = parsed.scheme or "file"
|
|
72
|
+
if scheme in ("file", "fs", ""):
|
|
73
|
+
from dna.adapters.filesystem.writable import FilesystemWritableSource
|
|
74
|
+
|
|
75
|
+
# Join netloc + path (same rule as source_cmd._resolve_url_to_path):
|
|
76
|
+
# 'fs://./copy' parses as netloc='.', path='/copy' — dropping the
|
|
77
|
+
# netloc silently resolved to the ABSOLUTE '/copy', so `dna source
|
|
78
|
+
# diff fs://./copy` digested a nonexistent dir as {} and reported a
|
|
79
|
+
# bogus "in sync" (found while fixing i-006).
|
|
80
|
+
if parsed.scheme:
|
|
81
|
+
path = (parsed.netloc + parsed.path) if parsed.netloc else parsed.path
|
|
82
|
+
else:
|
|
83
|
+
path = url
|
|
84
|
+
return FilesystemWritableSource(
|
|
85
|
+
path,
|
|
86
|
+
writers=list(getattr(kernel, "active_writers", []) or []),
|
|
87
|
+
kernel=kernel,
|
|
88
|
+
)
|
|
89
|
+
raise click.ClickException(
|
|
90
|
+
f"unsupported DNA_SOURCE_URL scheme '{scheme}://' — the dna CLI "
|
|
91
|
+
f"boots filesystem sources (file:// or a plain path). The sqlite/"
|
|
92
|
+
f"postgres adapters ship in dna.adapters and can be wired "
|
|
93
|
+
f"programmatically via kernel.source(...)."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _build_holder_async(scope: str | None = None):
|
|
98
|
+
"""Boot a full kernel (every installed extension via entry-points) on
|
|
99
|
+
the caller's event loop and return a holder for ``scope``."""
|
|
100
|
+
from dna.kernel import Kernel
|
|
101
|
+
from dna.adapters.filesystem import FilesystemCache
|
|
102
|
+
|
|
103
|
+
kernel = Kernel.auto()
|
|
104
|
+
url = _resolve_source_url()
|
|
105
|
+
source = await build_source_from_env(kernel, _source_url=url)
|
|
106
|
+
kernel.source(source)
|
|
107
|
+
try:
|
|
108
|
+
kernel.cache(FilesystemCache(str(getattr(source, "base_dir", ".dna"))))
|
|
109
|
+
except Exception: # noqa: BLE001 — cache is optional for CLI reads
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
resolved = scope or os.getenv("DNA_SCOPE_DEFAULT")
|
|
113
|
+
if resolved is None:
|
|
114
|
+
scopes = await source.list_scopes()
|
|
115
|
+
if not scopes:
|
|
116
|
+
raise click.ClickException(
|
|
117
|
+
f"No scopes found in source ({url}). Create one with a "
|
|
118
|
+
f"manifest.yaml under <base>/<scope>/ first."
|
|
119
|
+
)
|
|
120
|
+
resolved = scopes[0]
|
|
121
|
+
|
|
122
|
+
mi = await kernel.instance_async(resolved, lazy=True)
|
|
123
|
+
return _Holder(kernel, resolved, mi)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class _Holder:
|
|
127
|
+
"""Kernel-local stand-in for the upstream MIHolder: the surface the
|
|
128
|
+
CLI commands actually use (.mi / .kernel / .scope / .query_list /
|
|
129
|
+
.get_doc / .reload)."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, kernel: Any, scope: str, mi: Any = None):
|
|
132
|
+
self._kernel = kernel
|
|
133
|
+
self._scope = scope
|
|
134
|
+
self._mi = mi
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def kernel(self) -> Any:
|
|
138
|
+
return self._kernel
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def scope(self) -> str:
|
|
142
|
+
return self._scope
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def mi(self) -> Any:
|
|
146
|
+
if self._mi is None:
|
|
147
|
+
self._mi = self._kernel.instance(self._scope)
|
|
148
|
+
return self._mi
|
|
149
|
+
|
|
150
|
+
def query_list(
|
|
151
|
+
self, kind: str, *, filter: dict | None = None,
|
|
152
|
+
tenant: str | None = None,
|
|
153
|
+
) -> list:
|
|
154
|
+
effective = tenant if tenant is not None else (os.getenv("DNA_TENANT") or None)
|
|
155
|
+
return self._kernel.query_list_sync(
|
|
156
|
+
self._scope, kind, filter=filter, tenant=effective,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def get_doc(self, kind: str, name: str, *, tenant: str | None = None):
|
|
160
|
+
effective = tenant if tenant is not None else (os.getenv("DNA_TENANT") or None)
|
|
161
|
+
return self._kernel.get_document_sync(
|
|
162
|
+
self._scope, kind, name, tenant=effective,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def reload(self) -> None:
|
|
166
|
+
self._mi = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class Session:
|
|
171
|
+
"""Owns the holder + event loop for one CLI command.
|
|
172
|
+
|
|
173
|
+
Use ``s.run(coro)`` to execute async ops on the same loop the
|
|
174
|
+
kernel was built in. Sync helpers (``s.mi``, ``s.kernel``,
|
|
175
|
+
``s.holder``, ``s.scope``) are passthroughs.
|
|
176
|
+
"""
|
|
177
|
+
holder: Any
|
|
178
|
+
_loop: asyncio.AbstractEventLoop
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def mi(self):
|
|
182
|
+
return self.holder.mi
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def kernel(self):
|
|
186
|
+
return self.holder.kernel
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def scope(self) -> str:
|
|
190
|
+
return self.holder.scope
|
|
191
|
+
|
|
192
|
+
def query_list(
|
|
193
|
+
self, kind: str, *, filter: dict | None = None,
|
|
194
|
+
tenant: str | None = None,
|
|
195
|
+
) -> list:
|
|
196
|
+
return self.holder.query_list(kind, filter=filter, tenant=tenant)
|
|
197
|
+
|
|
198
|
+
def get_doc(self, kind: str, name: str, *, tenant: str | None = None):
|
|
199
|
+
return self.holder.get_doc(kind, name, tenant=tenant)
|
|
200
|
+
|
|
201
|
+
def run(self, coro):
|
|
202
|
+
"""Run an async coroutine on the session's event loop."""
|
|
203
|
+
return self._loop.run_until_complete(coro)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@contextmanager
|
|
207
|
+
def dna_session(scope: str | None = None) -> Iterator[Session]:
|
|
208
|
+
"""Open a CLI-scoped kernel session (one loop for the whole block)."""
|
|
209
|
+
loop = asyncio.new_event_loop()
|
|
210
|
+
asyncio.set_event_loop(loop)
|
|
211
|
+
holder = None
|
|
212
|
+
try:
|
|
213
|
+
holder = loop.run_until_complete(_build_holder_async(scope))
|
|
214
|
+
# Register the session's loop as the kernel's main loop so sync
|
|
215
|
+
# helpers (query_list_sync / get_document_sync) dispatch onto it
|
|
216
|
+
# instead of spawning fresh loops.
|
|
217
|
+
kernel = getattr(holder, "kernel", None)
|
|
218
|
+
if kernel is not None and hasattr(kernel, "register_main_loop"):
|
|
219
|
+
try:
|
|
220
|
+
kernel.register_main_loop(loop)
|
|
221
|
+
except Exception: # noqa: BLE001 — never block session boot
|
|
222
|
+
pass
|
|
223
|
+
yield Session(holder=holder, _loop=loop)
|
|
224
|
+
finally:
|
|
225
|
+
try:
|
|
226
|
+
pending = asyncio.all_tasks(loop)
|
|
227
|
+
for t in pending:
|
|
228
|
+
t.cancel()
|
|
229
|
+
if pending:
|
|
230
|
+
loop.run_until_complete(
|
|
231
|
+
asyncio.gather(*pending, return_exceptions=True)
|
|
232
|
+
)
|
|
233
|
+
finally:
|
|
234
|
+
loop.close()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_holder(scope: str | None = None):
|
|
238
|
+
"""Sync helper for read-only commands (loop closed on return)."""
|
|
239
|
+
return asyncio.run(_build_holder_async(scope))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def run_async(coro):
|
|
243
|
+
"""Block until ``coro`` completes — sync wrapper around asyncio.
|
|
244
|
+
|
|
245
|
+
Reuses the current thread's event loop when one is already set
|
|
246
|
+
(typical flow: ``dna_client()`` installs a loop, then every
|
|
247
|
+
``run_async`` call within the with-block uses that same loop).
|
|
248
|
+
Falls back to ``asyncio.run()`` when no loop is set.
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
loop = asyncio.get_event_loop_policy().get_event_loop()
|
|
252
|
+
except RuntimeError:
|
|
253
|
+
loop = None
|
|
254
|
+
if loop is None or loop.is_closed():
|
|
255
|
+
return asyncio.run(coro)
|
|
256
|
+
if loop.is_running():
|
|
257
|
+
return asyncio.run(coro)
|
|
258
|
+
return loop.run_until_complete(coro)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ─── dna_client — LOCAL facade with the resource-client shape ─────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class _LocalDocs:
|
|
265
|
+
"""``client.docs(scope)`` — document CRUD against the local kernel.
|
|
266
|
+
|
|
267
|
+
Methods are async and run INSIDE the client's event loop, so they use
|
|
268
|
+
the kernel's async surface (``query`` / ``get_document``) — the sync
|
|
269
|
+
wrappers would trip ``_run_sync_helper`` from a running loop."""
|
|
270
|
+
|
|
271
|
+
def __init__(self, client: "_LocalClient", scope: str):
|
|
272
|
+
self._c = client
|
|
273
|
+
self._scope = scope
|
|
274
|
+
|
|
275
|
+
async def list(self, kind: str | None = None) -> dict:
|
|
276
|
+
if not kind:
|
|
277
|
+
return {"items": []}
|
|
278
|
+
kernel = self._c._holder.kernel
|
|
279
|
+
items: list[dict] = []
|
|
280
|
+
async for row in kernel.query(self._scope, kind, tenant=self._c._tenant):
|
|
281
|
+
meta = row.get("metadata") if isinstance(row, dict) else None
|
|
282
|
+
name = (meta or {}).get("name") if isinstance(meta, dict) else None
|
|
283
|
+
name = name or (row.get("name") if isinstance(row, dict) else None)
|
|
284
|
+
items.append({"kind": kind, "metadata": {"name": name}})
|
|
285
|
+
return {"items": items}
|
|
286
|
+
|
|
287
|
+
async def get(self, kind: str, name: str) -> dict:
|
|
288
|
+
raw = await self._c._holder.kernel.get_document(
|
|
289
|
+
self._scope, kind, name, tenant=self._c._tenant,
|
|
290
|
+
)
|
|
291
|
+
if raw is None:
|
|
292
|
+
raise KeyError(f"404 not found: {kind}/{name}")
|
|
293
|
+
return {"raw": raw}
|
|
294
|
+
|
|
295
|
+
async def put(self, kind: str, name: str, raw: dict) -> Any:
|
|
296
|
+
kernel = self._c._holder.kernel
|
|
297
|
+
if self._c._tenant:
|
|
298
|
+
kernel = kernel.with_tenant(self._c._tenant)
|
|
299
|
+
return await kernel.write_document(self._scope, kind, name, raw)
|
|
300
|
+
|
|
301
|
+
async def delete(self, kind: str, name: str) -> Any:
|
|
302
|
+
kernel = self._c._holder.kernel
|
|
303
|
+
if self._c._tenant:
|
|
304
|
+
kernel = kernel.with_tenant(self._c._tenant)
|
|
305
|
+
return await kernel.delete_document(self._scope, kind, name)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _LocalScopes:
|
|
309
|
+
"""``client.scopes`` — scope/kind introspection against the kernel."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, client: "_LocalClient"):
|
|
312
|
+
self._c = client
|
|
313
|
+
|
|
314
|
+
async def list(self) -> list[str]:
|
|
315
|
+
source = getattr(self._c._holder.kernel, "_source", None)
|
|
316
|
+
if source is None:
|
|
317
|
+
return []
|
|
318
|
+
return list(await source.list_scopes())
|
|
319
|
+
|
|
320
|
+
async def tree(self, scope: str) -> dict[str, list[str]]:
|
|
321
|
+
mi = await self._c._holder.kernel.instance_async(scope)
|
|
322
|
+
by_kind: dict[str, list[str]] = {}
|
|
323
|
+
for d in mi.documents:
|
|
324
|
+
by_kind.setdefault(d.kind, []).append(d.name)
|
|
325
|
+
return {k: sorted(v) for k, v in by_kind.items()}
|
|
326
|
+
|
|
327
|
+
async def kinds(self, scope: str) -> dict[str, list]:
|
|
328
|
+
del scope # kinds are kernel-global in the local facade
|
|
329
|
+
kernel = self._c._holder.kernel
|
|
330
|
+
return {kp.kind: [] for kp in kernel._kinds.values() if getattr(kp, "kind", None)}
|
|
331
|
+
|
|
332
|
+
async def kind_schema(self, scope: str, kind: str) -> dict:
|
|
333
|
+
del scope
|
|
334
|
+
kernel = self._c._holder.kernel
|
|
335
|
+
kp = next(
|
|
336
|
+
(k for k in kernel._kinds.values() if getattr(k, "kind", None) == kind),
|
|
337
|
+
None,
|
|
338
|
+
)
|
|
339
|
+
if kp is None:
|
|
340
|
+
raise KeyError(f"404 not found: kind {kind!r}")
|
|
341
|
+
try:
|
|
342
|
+
schema = kp.schema() or {}
|
|
343
|
+
except Exception: # noqa: BLE001
|
|
344
|
+
schema = {}
|
|
345
|
+
sd = getattr(kp, "storage", None)
|
|
346
|
+
return {
|
|
347
|
+
"kind": kp.kind,
|
|
348
|
+
"alias": getattr(kp, "alias", None),
|
|
349
|
+
"api_version": getattr(kp, "api_version", None),
|
|
350
|
+
"display_label": getattr(kp, "display_label", None),
|
|
351
|
+
"schema": schema,
|
|
352
|
+
"is_root": bool(getattr(kp, "is_root", False)),
|
|
353
|
+
"is_runtime_artifact": bool(getattr(kp, "is_runtime_artifact", False)),
|
|
354
|
+
"storage": {
|
|
355
|
+
"marker": getattr(sd, "marker", None),
|
|
356
|
+
"directory": getattr(sd, "directory", None),
|
|
357
|
+
} if sd is not None else None,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class _LocalClient:
|
|
362
|
+
"""Local (in-process) twin of the upstream API client surface."""
|
|
363
|
+
|
|
364
|
+
def __init__(self, holder: Any, tenant: str | None):
|
|
365
|
+
self._holder = holder
|
|
366
|
+
self._tenant = tenant
|
|
367
|
+
self.scopes = _LocalScopes(self)
|
|
368
|
+
|
|
369
|
+
def docs(self, scope: str) -> _LocalDocs:
|
|
370
|
+
return _LocalDocs(self, scope)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@contextmanager
|
|
374
|
+
def dna_client(timeout: float = 30.0, tenant: str | None = None):
|
|
375
|
+
"""Yield a local-kernel client facade (see class docstrings).
|
|
376
|
+
|
|
377
|
+
``timeout`` is accepted for signature compatibility and ignored —
|
|
378
|
+
there is no network here. Installs a dedicated event loop for the
|
|
379
|
+
with-block so every ``run_async`` call reuses the SAME loop.
|
|
380
|
+
"""
|
|
381
|
+
del timeout
|
|
382
|
+
effective_tenant = tenant if tenant is not None else os.getenv("DNA_TENANT")
|
|
383
|
+
loop = asyncio.new_event_loop()
|
|
384
|
+
asyncio.set_event_loop(loop)
|
|
385
|
+
try:
|
|
386
|
+
holder = loop.run_until_complete(_build_holder_async(None))
|
|
387
|
+
kernel = getattr(holder, "kernel", None)
|
|
388
|
+
if kernel is not None and hasattr(kernel, "register_main_loop"):
|
|
389
|
+
try:
|
|
390
|
+
kernel.register_main_loop(loop)
|
|
391
|
+
except Exception: # noqa: BLE001
|
|
392
|
+
pass
|
|
393
|
+
yield _LocalClient(holder, effective_tenant)
|
|
394
|
+
finally:
|
|
395
|
+
try:
|
|
396
|
+
pending = asyncio.all_tasks(loop)
|
|
397
|
+
for t in pending:
|
|
398
|
+
t.cancel()
|
|
399
|
+
if pending:
|
|
400
|
+
loop.run_until_complete(
|
|
401
|
+
asyncio.gather(*pending, return_exceptions=True)
|
|
402
|
+
)
|
|
403
|
+
finally:
|
|
404
|
+
loop.close()
|
|
405
|
+
asyncio.set_event_loop(None)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ─── output helpers ───────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def print_json(value: Any) -> None:
|
|
412
|
+
"""Serialize to stdout. Used as default for machine-readable output."""
|
|
413
|
+
click.echo(json.dumps(value, default=str, indent=2, ensure_ascii=False))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def print_table(rows: list[dict[str, Any]], columns: list[str]) -> None:
|
|
417
|
+
"""Render rows as a borderless aligned table (no rich dependency)."""
|
|
418
|
+
if not rows:
|
|
419
|
+
click.echo("(no rows)", err=True)
|
|
420
|
+
return
|
|
421
|
+
widths = {c: max(len(c), max(len(str(r.get(c, ""))) for r in rows)) for c in columns}
|
|
422
|
+
header = " ".join(c.ljust(widths[c]) for c in columns)
|
|
423
|
+
click.echo(header)
|
|
424
|
+
click.echo(" ".join("-" * widths[c] for c in columns))
|
|
425
|
+
for r in rows:
|
|
426
|
+
click.echo(" ".join(str(r.get(c, "")).ljust(widths[c]) for c in columns))
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def fail(message: str, code: int = 1) -> "click.exceptions.Exit":
|
|
430
|
+
"""Print an error to stderr and exit. Returns an exception instance
|
|
431
|
+
so callers can `raise fail("...")` for static analyzers."""
|
|
432
|
+
click.secho(message, fg="red", err=True)
|
|
433
|
+
sys.exit(code)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Git ↔ SDLC symbiosis — trailer convention + commit queries.
|
|
2
|
+
|
|
3
|
+
The convention (s-sdlc-git-symbiosis)
|
|
4
|
+
-------------------------------------
|
|
5
|
+
Every commit made while a Story is active (``.dna/active-story.txt``,
|
|
6
|
+
written by ``dna sdlc story start``) is stamped with two trailers by the
|
|
7
|
+
versioned ``prepare-commit-msg`` hook (``scripts/git-hooks/``):
|
|
8
|
+
|
|
9
|
+
Work-Item: Story/<story-name>
|
|
10
|
+
Co-Authored-By: dna-sdlc[bot] <dna-sdlc[bot]@users.noreply.github.com>
|
|
11
|
+
|
|
12
|
+
``Work-Item: <Kind>/<name>`` is the machine-readable link back to the
|
|
13
|
+
SDLC document. The co-author is **the dna sdlc tool itself** (GitHub
|
|
14
|
+
bot-identity convention) — a provenance seal meaning "this commit was
|
|
15
|
+
born under story governance: it has a plan, a timeline, a test gate".
|
|
16
|
+
It is NOT a human co-author; human/agent authorship stays in the normal
|
|
17
|
+
git author + their own trailers. Override the identity with the
|
|
18
|
+
``DNA_SDLC_COAUTHOR`` env var.
|
|
19
|
+
|
|
20
|
+
No active story → no stamp. Absence is signal too.
|
|
21
|
+
|
|
22
|
+
The way back: ``git log --grep "Work-Item: Story/<name>"`` materializes
|
|
23
|
+
the commit list for a Story with zero manual bookkeeping — surfaced by
|
|
24
|
+
``dna sdlc story show`` (Commits section) and ``dna sdlc story commits``.
|
|
25
|
+
|
|
26
|
+
This module is the single home for the constants + the (fail-soft) git
|
|
27
|
+
queries. The hook itself is a standalone python3 script (zero deps — it
|
|
28
|
+
must run without any venv); ``tests/test_git_symbiosis_hook.py`` asserts
|
|
29
|
+
the hook's constants match this module so the two can't drift.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import subprocess
|
|
35
|
+
from importlib.resources import files as _pkg_files
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
WORK_ITEM_TRAILER = "Work-Item"
|
|
40
|
+
COAUTHOR_TRAILER = "Co-Authored-By"
|
|
41
|
+
DEFAULT_SDLC_COAUTHOR = "dna-sdlc[bot] <dna-sdlc[bot]@users.noreply.github.com>"
|
|
42
|
+
COAUTHOR_ENV = "DNA_SDLC_COAUTHOR"
|
|
43
|
+
|
|
44
|
+
#: PR-side twin of the commit trailer (s-sdlc-pr-attribution): the same way
|
|
45
|
+
#: Claude Code signs its PRs, DNA signs the PRs born from its Stories. The
|
|
46
|
+
#: footer goes at the END of the PR body, after a ``---`` rule, and carries
|
|
47
|
+
#: the machine-readable ``Work-Item: <Kind>/<name>`` reference. Override the
|
|
48
|
+
#: whole line via ``$DNA_SDLC_PR_FOOTER`` — a ``{work_item}`` placeholder in
|
|
49
|
+
#: the override is substituted; a literal string is used as-is.
|
|
50
|
+
DEFAULT_PR_FOOTER_TEMPLATE = (
|
|
51
|
+
"\U0001f9ec Tracked with [DNA SDLC](https://github.com/ruinosus/dna) — "
|
|
52
|
+
f"{WORK_ITEM_TRAILER}: {{work_item}}"
|
|
53
|
+
)
|
|
54
|
+
PR_FOOTER_ENV = "DNA_SDLC_PR_FOOTER"
|
|
55
|
+
|
|
56
|
+
#: Repo-relative hooks dir that ``dna sdlc hooks install`` points
|
|
57
|
+
#: ``core.hooksPath`` at. Versioned in-repo so the hook ships with the clone.
|
|
58
|
+
HOOKS_DIR = "scripts/git-hooks"
|
|
59
|
+
HOOK_NAME = "prepare-commit-msg"
|
|
60
|
+
|
|
61
|
+
_GIT_TIMEOUT = 10 # seconds — every git call here is local + bounded
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sdlc_coauthor() -> str:
|
|
65
|
+
"""Effective tool-identity co-author (env override → default bot)."""
|
|
66
|
+
return os.environ.get(COAUTHOR_ENV, "").strip() or DEFAULT_SDLC_COAUTHOR
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def work_item_ref(kind: str, name: str) -> str:
|
|
70
|
+
"""Canonical work-item reference used in the trailer: ``<Kind>/<name>``."""
|
|
71
|
+
return f"{kind}/{name}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def trailer_lines(kind: str, name: str) -> list[str]:
|
|
75
|
+
"""The exact trailer lines the hook stamps for a work item."""
|
|
76
|
+
return [
|
|
77
|
+
f"{WORK_ITEM_TRAILER}: {work_item_ref(kind, name)}",
|
|
78
|
+
f"{COAUTHOR_TRAILER}: {sdlc_coauthor()}",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pr_footer(kind: str, name: str) -> str:
|
|
83
|
+
"""The one-line PR attribution footer for a work item.
|
|
84
|
+
|
|
85
|
+
``$DNA_SDLC_PR_FOOTER`` overrides the template; an override without a
|
|
86
|
+
``{work_item}`` placeholder is emitted verbatim (some hosts want a
|
|
87
|
+
fixed banner), and a malformed format string falls back to verbatim
|
|
88
|
+
too — the footer must never crash PR creation.
|
|
89
|
+
"""
|
|
90
|
+
template = os.environ.get(PR_FOOTER_ENV, "").strip() or DEFAULT_PR_FOOTER_TEMPLATE
|
|
91
|
+
try:
|
|
92
|
+
return template.format(work_item=work_item_ref(kind, name))
|
|
93
|
+
except (KeyError, IndexError, ValueError):
|
|
94
|
+
return template
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pr_footer_block(kind: str, name: str) -> str:
|
|
98
|
+
"""The footer as it appears at the end of a PR body: ``---`` + footer."""
|
|
99
|
+
return f"---\n{pr_footer(kind, name)}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def hook_source_path() -> Path:
|
|
103
|
+
"""Path of the canonical hook script shipped inside the package.
|
|
104
|
+
|
|
105
|
+
``scripts/git-hooks/prepare-commit-msg`` in the repo is a byte-identical
|
|
106
|
+
copy (enforced by test) so clones get the hook without installing dna-cli.
|
|
107
|
+
"""
|
|
108
|
+
return Path(str(_pkg_files("dna_cli").joinpath("data/git-hooks/prepare-commit-msg")))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run_git(args: list[str], *, cwd: str | Path | None = None) -> str | None:
|
|
112
|
+
"""Run a git command; return stdout or ``None`` on any failure.
|
|
113
|
+
|
|
114
|
+
Fail-soft by design — callers treat ``None`` as "no git information
|
|
115
|
+
available" (git missing, not a repo, etc). Never raises.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
proc = subprocess.run(
|
|
119
|
+
["git", *args],
|
|
120
|
+
capture_output=True, text=True, timeout=_GIT_TIMEOUT,
|
|
121
|
+
cwd=str(cwd) if cwd else None,
|
|
122
|
+
)
|
|
123
|
+
except Exception: # noqa: BLE001 — git absent / timeout / OS error
|
|
124
|
+
return None
|
|
125
|
+
if proc.returncode != 0:
|
|
126
|
+
return None
|
|
127
|
+
return proc.stdout
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def repo_root(*, cwd: str | Path | None = None) -> Path | None:
|
|
131
|
+
"""Top-level of the enclosing git working tree, or ``None``."""
|
|
132
|
+
out = _run_git(["rev-parse", "--show-toplevel"], cwd=cwd)
|
|
133
|
+
if not out:
|
|
134
|
+
return None
|
|
135
|
+
top = out.strip()
|
|
136
|
+
return Path(top) if top else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def commits_for_work_item(
|
|
140
|
+
kind: str, name: str, *, cwd: str | Path | None = None,
|
|
141
|
+
) -> list[dict[str, Any]] | None:
|
|
142
|
+
"""Commits whose message carries ``Work-Item: <Kind>/<name>``.
|
|
143
|
+
|
|
144
|
+
Returns ``None`` when git / a repo isn't available (fail-soft), else a
|
|
145
|
+
list (possibly empty) of ``{sha, full_sha, date, subject}`` dicts,
|
|
146
|
+
newest first — straight from ``git log --grep`` on the trailer.
|
|
147
|
+
"""
|
|
148
|
+
ref = f"{WORK_ITEM_TRAILER}: {work_item_ref(kind, name)}"
|
|
149
|
+
out = _run_git(
|
|
150
|
+
[
|
|
151
|
+
"log", "--fixed-strings", f"--grep={ref}",
|
|
152
|
+
"--date=short", "--format=%h%x1f%H%x1f%ad%x1f%s",
|
|
153
|
+
],
|
|
154
|
+
cwd=cwd,
|
|
155
|
+
)
|
|
156
|
+
if out is None:
|
|
157
|
+
return None
|
|
158
|
+
rows: list[dict[str, Any]] = []
|
|
159
|
+
for line in out.splitlines():
|
|
160
|
+
parts = line.split("\x1f")
|
|
161
|
+
if len(parts) != 4:
|
|
162
|
+
continue
|
|
163
|
+
sha, full_sha, date, subject = parts
|
|
164
|
+
rows.append({
|
|
165
|
+
"sha": sha, "full_sha": full_sha, "date": date, "subject": subject,
|
|
166
|
+
})
|
|
167
|
+
return rows
|