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/pr_cmd.py ADDED
@@ -0,0 +1,251 @@
1
+ """``dna sdlc story pr`` + ``dna sdlc pr-footer`` — PR attribution.
2
+
3
+ The PR-side half of the git↔SDLC symbiosis (s-sdlc-pr-attribution): the
4
+ same way Claude Code signs the PRs it generates, DNA signs the PRs born
5
+ from its Stories. ``dna sdlc story pr <s-x>`` assembles ``gh pr create``
6
+ entirely from the Story document — the PR is born FROM the story, not
7
+ the other way around:
8
+
9
+ - **title** — ``feat(<label-hint>): <story title> (<s-x>)``. Conventional-
10
+ commit shaped: Stories ship features by convention (bugs are Issues),
11
+ the scope hint is the story's first label (fallback ``sdlc``), and the
12
+ slug rides along so the PR title alone is ⌘K-pasteable back to the
13
+ work item.
14
+ - **body** — the story description, the acceptance criteria as a GitHub
15
+ task-list checklist (``- [ ] …``, pre-checked when the AC item carries
16
+ ``done: true``), and the attribution footer after a ``---`` rule::
17
+
18
+ ---
19
+ 🧬 Tracked with [DNA SDLC](https://github.com/ruinosus/dna) — Work-Item: Story/<s-x>
20
+
21
+ Footer template + ``$DNA_SDLC_PR_FOOTER`` override live in
22
+ ``_git_symbiosis.py`` next to the commit-trailer constants — one home
23
+ for the whole attribution convention.
24
+
25
+ ``--dry-run`` prints the exact title + body and never touches ``gh`` —
26
+ that's the testable (and offline) surface. ``dna sdlc pr-footer <s-x>``
27
+ emits just the footer block for hand-made PRs; it is a pure formatter
28
+ (no kernel session, no validation) so it works instantly from anywhere.
29
+
30
+ On success the PR URL is stamped onto the Story timeline (``pr_opened``
31
+ event, fail-soft) — commit carimbado (hook) + PR assinado (este) + the
32
+ timeline materializes both.
33
+
34
+ Deliberately NOT here — listing PRs back in ``story show`` via ``gh pr
35
+ list --search "Work-Item: Story/<x>"``: the GitHub *search* API is
36
+ rate-limited (~30 req/min), its body indexing is eventually consistent
37
+ (a fresh PR doesn't show up), and this codebase already documents the
38
+ gateway intermittently blocking api.github.com (i-133) — adding that to
39
+ ``story show``, a hot fully-local command, would make it slow and flaky.
40
+ The way back already closes deterministically with zero network: a
41
+ squash-merged PR lands a commit whose message carries the ``Work-Item:``
42
+ trailer, so ``story show`` / ``story commits`` surface merged PRs via
43
+ ``git log --grep``.
44
+
45
+ Registered onto the ``sdlc`` / ``sdlc story`` groups at import time
46
+ (same pattern as ``hooks_cmd``) — see ``dna_cli/__init__.py``.
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import os
51
+ import shutil
52
+ import subprocess
53
+ from pathlib import Path
54
+ from typing import Any
55
+
56
+ import click
57
+
58
+ from dna_cli import _git_symbiosis as gs
59
+ from dna_cli._ctx import dna_session, fail
60
+ from dna_cli.sdlc_cmd import (
61
+ _append_timeline,
62
+ _build_raw,
63
+ _scope_option,
64
+ sdlc,
65
+ story_group,
66
+ )
67
+
68
+ _GH_TIMEOUT = 60 # seconds — pr create talks to the network
69
+
70
+
71
+ # ─── pure builders (the --dry-run-testable surface) ───────────────────
72
+
73
+
74
+ def _label_hint(spec: dict[str, Any]) -> str:
75
+ """Scope hint for the conventional title: first label, else ``sdlc``."""
76
+ labels = spec.get("labels") or []
77
+ for lbl in labels:
78
+ if isinstance(lbl, str) and lbl.strip():
79
+ return lbl.strip()
80
+ return "sdlc"
81
+
82
+
83
+ def build_pr_title(name: str, spec: dict[str, Any]) -> str:
84
+ """``feat(<label-hint>): <story title> (<s-x>)`` — pure."""
85
+ title = " ".join(str(spec.get("title") or name).split())
86
+ return f"feat({_label_hint(spec)}): {title} ({name})"
87
+
88
+
89
+ def _ac_items(raw: Any) -> list[tuple[str, bool]]:
90
+ """Normalize acceptance_criteria items (strings or ``{text, done}``
91
+ dicts — same shapes ``story show`` renders) to ``(text, done)``."""
92
+ out: list[tuple[str, bool]] = []
93
+ for it in raw or []:
94
+ if isinstance(it, str):
95
+ if it.strip():
96
+ out.append((it.strip(), False))
97
+ elif isinstance(it, dict):
98
+ text = it.get("text") or it.get("criterion") or it.get("item") or it.get("desc") or ""
99
+ if str(text).strip():
100
+ out.append((str(text).strip(), bool(it.get("done") or it.get("checked"))))
101
+ return out
102
+
103
+
104
+ def build_pr_body(name: str, spec: dict[str, Any]) -> str:
105
+ """PR body from a Story spec: description + AC checklist + footer — pure."""
106
+ parts: list[str] = []
107
+ desc = str(spec.get("description") or "").strip()
108
+ if desc:
109
+ parts.append(desc)
110
+ ac = _ac_items(spec.get("acceptance_criteria"))
111
+ if ac:
112
+ lines = "\n".join(f"- [{'x' if done else ' '}] {text}" for text, done in ac)
113
+ parts.append(f"## Acceptance criteria\n\n{lines}")
114
+ parts.append(gs.pr_footer_block("Story", name))
115
+ return "\n\n".join(parts) + "\n"
116
+
117
+
118
+ def build_gh_args(
119
+ title: str, body: str, *,
120
+ base: str | None, head: str | None, draft: bool,
121
+ ) -> list[str]:
122
+ """The exact ``gh`` argv — pure. ``--head`` is only passed when given
123
+ (gh's own default is the current branch)."""
124
+ args = ["gh", "pr", "create", "--title", title, "--body", body]
125
+ if base:
126
+ args += ["--base", base]
127
+ if head:
128
+ args += ["--head", head]
129
+ if draft:
130
+ args.append("--draft")
131
+ return args
132
+
133
+
134
+ # ─── plumbing ─────────────────────────────────────────────────────────
135
+
136
+
137
+ def _anchor_source_to_repo_root() -> None:
138
+ """Make the default ``./.dna`` source discovery work from ANY cwd of
139
+ the repo: when no explicit source is configured and cwd has no
140
+ ``.dna/``, point ``DNA_BASE_DIR`` at the enclosing repo root (where
141
+ the scope lives by convention). Process-local — the CLI is one
142
+ command per process."""
143
+ if os.getenv("DNA_SOURCE_URL") or os.getenv("DNA_BASE_DIR"):
144
+ return
145
+ if Path(".dna").is_dir():
146
+ return
147
+ root = gs.repo_root()
148
+ if root is not None and (root / ".dna").is_dir():
149
+ os.environ["DNA_BASE_DIR"] = str(root)
150
+
151
+
152
+ def _stamp_pr_on_timeline(scope: str, name: str, url: str) -> None:
153
+ """Append a ``pr_opened`` event carrying the PR URL. Fail-soft — the
154
+ PR already exists; a timeline hiccup must not fail the command."""
155
+ try:
156
+ with dna_session(scope) as s:
157
+ existing = s.get_doc("Story", name)
158
+ if existing is None:
159
+ return
160
+ spec = dict(existing.spec) if isinstance(existing.spec, dict) else {}
161
+ _append_timeline(spec, "pr_opened", summary=f"PR aberto: {url}", pr_url=url)
162
+ raw = _build_raw("Story", name, spec)
163
+ s.run(s.kernel.write_document(scope, "Story", name, raw))
164
+ except Exception as e: # noqa: BLE001 — fail-soft by contract
165
+ click.secho(f"⚠ não consegui carimbar o PR na timeline (non-fatal): {e}",
166
+ fg="yellow", err=True)
167
+
168
+
169
+ # ─── commands ─────────────────────────────────────────────────────────
170
+
171
+
172
+ @story_group.command("pr")
173
+ @click.argument("name")
174
+ @click.option("--base", default=None,
175
+ help="Base branch do PR (passthrough pro gh; default: o do repo).")
176
+ @click.option("--head", default=None,
177
+ help="Head branch (default: a branch corrente — default do próprio gh).")
178
+ @click.option("--draft", is_flag=True, help="Abre como draft.")
179
+ @click.option("--dry-run", "dry_run", is_flag=True,
180
+ help="Só imprime title + body montados; não chama gh.")
181
+ @_scope_option
182
+ def cmd_story_pr(
183
+ name: str, base: str | None, head: str | None,
184
+ draft: bool, dry_run: bool, scope: str,
185
+ ) -> None:
186
+ """Open the Story's PR — ``gh pr create`` pre-filled FROM the story.
187
+
188
+ Title ``feat(<label>): <título> (<s-x>)``; body = description + AC
189
+ como checklist + footer de atribuição (``dna sdlc pr-footer``). O PR
190
+ nasce da story, não o contrário — e a URL volta pra timeline.
191
+ """
192
+ _anchor_source_to_repo_root()
193
+ with dna_session(scope) as s:
194
+ story = s.get_doc("Story", name)
195
+ if story is None:
196
+ raise fail(f"Story '{name}' not found in scope {scope!r}")
197
+ spec = dict(story.spec) if isinstance(story.spec, dict) else {}
198
+
199
+ title = build_pr_title(name, spec)
200
+ body = build_pr_body(name, spec)
201
+
202
+ if dry_run:
203
+ click.secho("── title ──", fg="cyan")
204
+ click.echo(title)
205
+ click.secho("── body ──", fg="cyan")
206
+ click.echo(body, nl=False)
207
+ click.secho("(dry-run — gh pr create NÃO foi chamado)", fg="yellow", err=True)
208
+ return
209
+
210
+ if shutil.which("gh") is None:
211
+ raise fail(
212
+ "gh (GitHub CLI) não encontrado no PATH — instale via "
213
+ "https://cli.github.com e autentique com `gh auth login`. "
214
+ "Sem gh você ainda pode abrir o PR à mão e colar o footer: "
215
+ f"`dna sdlc pr-footer {name}`."
216
+ )
217
+
218
+ args = build_gh_args(title, body, base=base, head=head, draft=draft)
219
+ try:
220
+ proc = subprocess.run(
221
+ args, capture_output=True, text=True, timeout=_GH_TIMEOUT,
222
+ )
223
+ except subprocess.TimeoutExpired:
224
+ raise fail(f"gh pr create excedeu {_GH_TIMEOUT}s — rede/gateway? "
225
+ f"Tente de novo ou abra à mão (dna sdlc pr-footer {name}).")
226
+ if proc.returncode != 0:
227
+ detail = (proc.stderr or proc.stdout or "").strip()
228
+ raise fail(
229
+ "gh pr create falhou:\n"
230
+ f"{detail}\n"
231
+ "Dicas: a branch precisa estar publicada (git push -u origin <branch>); "
232
+ "`gh auth status` mostra a autenticação; use --base/--head pra "
233
+ "apontar branches explicitamente."
234
+ )
235
+ url = (proc.stdout or "").strip().splitlines()[-1] if proc.stdout else ""
236
+ click.secho(f"PR CREATED from Story/{name}", fg="green")
237
+ if url:
238
+ click.echo(url)
239
+ _stamp_pr_on_timeline(scope, name, url)
240
+
241
+
242
+ @sdlc.command("pr-footer")
243
+ @click.argument("name")
244
+ def cmd_pr_footer(name: str) -> None:
245
+ """Print the attribution footer block for a hand-made PR body.
246
+
247
+ Pure formatter (no kernel session, no gh): paste the output at the
248
+ end of the PR body você abriu à mão. Template + override
249
+ (``$DNA_SDLC_PR_FOOTER``) em ``_git_symbiosis.py``.
250
+ """
251
+ click.echo(gs.pr_footer_block("Story", name))
dna_cli/recall_cmd.py ADDED
@@ -0,0 +1,161 @@
1
+ """``dna recall`` / ``dna search`` — hybrid semantic search over the scope.
2
+
3
+ Kernel-bound, no server. Registers the embeddable ``SqliteVecRecordSearchProvider``
4
+ (the ``search-sqlite`` extra) on the local kernel, indexes the requested record
5
+ kinds on demand (idempotent by text hash), and runs ``kernel.search()`` — which
6
+ uses the registered provider (dense sqlite-vec + lexical FTS5, fused with RRF).
7
+
8
+ Degrades HONESTLY: when the ``search-sqlite`` extra is not installed, it prints
9
+ a one-line notice and falls back to ``kernel.search()`` with no provider — the
10
+ kernel's lexical token-match scan (``degraded: true``). Search is a read; it
11
+ never raises on a missing extra.
12
+
13
+ dna recall "memory similarity" # over every kind in the scope
14
+ dna recall "reciprocal rank fusion" --kind Story
15
+ dna search "banana" --scope demo -k 5 --json
16
+
17
+ ``dna recall`` and ``dna search`` are aliases (recall is the memory-facing verb;
18
+ search the neutral one).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Any
23
+
24
+ import click
25
+
26
+ from dna_cli._ctx import dna_session, print_json
27
+
28
+
29
+ def _register_provider(session: Any) -> Any | None:
30
+ """Build + register the sqlite-vec provider on the session's kernel.
31
+ Returns the provider, or ``None`` when the ``search-sqlite`` extra is
32
+ absent (caller degrades to the lexical fallback)."""
33
+ try:
34
+ import sqlite_vec # noqa: F401
35
+ except ImportError:
36
+ return None
37
+ import os
38
+ from pathlib import Path
39
+
40
+ from dna.adapters.search.sqlite_vec import SqliteVecRecordSearchProvider
41
+
42
+ kernel = session.kernel
43
+ # Store lives beside the source (a `.dna-search/` sibling of `.dna/`), so
44
+ # re-runs reuse the index (hash-skip). Overridable via DNA_SEARCH_DIR.
45
+ search_dir = os.getenv("DNA_SEARCH_DIR")
46
+ if not search_dir:
47
+ base = getattr(getattr(kernel, "_source", None), "base_dir", None) or ".dna"
48
+ base_path = Path(base)
49
+ parent = base_path.parent if base_path.name == ".dna" else base_path
50
+ search_dir = str(parent / ".dna-search")
51
+ provider = SqliteVecRecordSearchProvider(kernel, db_dir=search_dir)
52
+ kernel.record_search_provider(provider)
53
+ return provider
54
+
55
+
56
+ def _scope_kinds(session: Any) -> list[str]:
57
+ """The record kinds present in the scope (from the built instance)."""
58
+ mi = session.mi
59
+ kinds: list[str] = []
60
+ for doc in getattr(mi, "documents", []) or []:
61
+ if doc.kind not in kinds:
62
+ kinds.append(doc.kind)
63
+ return kinds
64
+
65
+
66
+ async def _index_kinds(
67
+ session: Any, provider: Any, scope: str, kinds: list[str], tenant: str | None,
68
+ ) -> int:
69
+ from dna.adapters.search.sqlite_vec import document_text
70
+
71
+ kernel = session.kernel
72
+ records: list[dict[str, Any]] = []
73
+ for kind in kinds:
74
+ async for raw in kernel.query(scope, kind, tenant=tenant):
75
+ meta = raw.get("metadata") or {}
76
+ name = meta.get("name") or raw.get("name")
77
+ if not name:
78
+ continue
79
+ records.append({
80
+ "scope": scope, "kind": kind, "name": name,
81
+ "tenant": tenant or "",
82
+ "text": document_text(raw),
83
+ "title": (raw.get("spec") or {}).get("title") or name,
84
+ })
85
+ if not records:
86
+ return 0
87
+ return await provider.index(records)
88
+
89
+
90
+ @click.command(name="recall")
91
+ @click.argument("query")
92
+ @click.option("--scope", default=None, help="Scope to search (default: first/only scope).")
93
+ @click.option("--kind", "kinds", multiple=True, help="Restrict to a record kind (repeatable). Default: every kind in the scope.")
94
+ @click.option("--tenant", default=None, help="Tenant overlay (base ∪ overlay; overlay shadows base).")
95
+ @click.option("-k", "--limit", "k", default=10, show_default=True, help="Max hits.")
96
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
97
+ def recall(
98
+ query: str, scope: str | None, kinds: tuple[str, ...],
99
+ tenant: str | None, k: int, as_json: bool,
100
+ ) -> None:
101
+ """Hybrid semantic search (dense + lexical + RRF) over the scope's records."""
102
+ with dna_session(scope) as s:
103
+ provider = _register_provider(s)
104
+ target_scope = s.scope
105
+ kind_list = list(kinds) or _scope_kinds(s)
106
+
107
+ # Single-kind search keeps the kernel's lexical fallback meaningful when
108
+ # the provider is absent (the fallback requires a kind); the provider
109
+ # itself searches across all indexed kinds regardless.
110
+ single_kind = kind_list[0] if len(kind_list) == 1 else None
111
+
112
+ if provider is not None:
113
+ try:
114
+ s.run(_index_kinds(s, provider, target_scope, kind_list, tenant))
115
+ except Exception as exc: # noqa: BLE001 — indexing failure degrades to lexical
116
+ click.secho(f"⚠ index failed ({exc}); lexical fallback", fg="yellow", err=True)
117
+ else:
118
+ click.secho(
119
+ "⚠ search-sqlite extra not installed — degrading to lexical "
120
+ "scan (pip install 'dna-sdk[search-sqlite]' for semantic recall)",
121
+ fg="yellow", err=True,
122
+ )
123
+
124
+ result = s.run(s.kernel.search(
125
+ target_scope, query, kind=single_kind, k=k, tenant=tenant,
126
+ ))
127
+ if provider is not None:
128
+ provider.close()
129
+
130
+ hits = result.get("hits", [])
131
+ degraded = result.get("degraded", False)
132
+ if as_json:
133
+ print_json({"query": query, "scope": target_scope, "degraded": degraded, "hits": hits})
134
+ return
135
+
136
+ mode = "lexical (degraded)" if degraded else "hybrid (dense+lexical+RRF)"
137
+ click.secho(f"\n🔎 {mode} · scope={target_scope} · '{query}'", bold=True)
138
+ if not hits:
139
+ click.echo(" (no matches)")
140
+ return
141
+ for i, h in enumerate(hits, 1):
142
+ score = h.get("score", 0.0)
143
+ line = f" {i:>2}. {h.get('kind','?')}/{h.get('name','?')} ({score:.4f})"
144
+ click.echo(line)
145
+ snippet = h.get("snippet")
146
+ if snippet:
147
+ click.secho(f" {snippet}", fg="bright_black")
148
+
149
+
150
+ # ``dna search`` — neutral alias of the same command.
151
+ @click.command(name="search")
152
+ @click.argument("query")
153
+ @click.option("--scope", default=None)
154
+ @click.option("--kind", "kinds", multiple=True)
155
+ @click.option("--tenant", default=None)
156
+ @click.option("-k", "--limit", "k", default=10, show_default=True)
157
+ @click.option("--json", "as_json", is_flag=True)
158
+ @click.pass_context
159
+ def search(ctx: click.Context, **kwargs: Any) -> None:
160
+ """Alias of ``dna recall`` (neutral naming)."""
161
+ ctx.invoke(recall, **kwargs)