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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""DNA β GitHub Issues bridge β pure builders + ``gh`` plumbing.
|
|
2
|
+
|
|
3
|
+
The philosophy (s-github-issues-bridge)
|
|
4
|
+
---------------------------------------
|
|
5
|
+
GitHub Issues are artifacts of the github.com domain β DNA **bridges**
|
|
6
|
+
to them with provenance, it does not replace them. Same stance as the
|
|
7
|
+
PR half of the symbiosis (``pr_cmd.py``): the ``gh`` CLI is the door,
|
|
8
|
+
the 𧬠attribution footer is the signature, and the provenance fields
|
|
9
|
+
(``github_number`` / ``github_url`` / ``github_state`` /
|
|
10
|
+
``github_synced_at`` on the Issue Kind schema) are the machine-readable
|
|
11
|
+
link back.
|
|
12
|
+
|
|
13
|
+
This module is the single home for the bridge's *mechanics* so both
|
|
14
|
+
``issue_bridge_cmd.py`` (the ``dna sdlc issue publish|import|sync``
|
|
15
|
+
commands) and ``sdlc_cmd.cmd_issue_resolve`` (the fail-soft close-on-
|
|
16
|
+
resolve hook) share one implementation:
|
|
17
|
+
|
|
18
|
+
- **pure builders** β issue title/body assembly, ``owner/name`` repo
|
|
19
|
+
parsing, ``#N``/URL reference parsing, the labelβtype/severity
|
|
20
|
+
heuristic. All offline-testable, no subprocess.
|
|
21
|
+
- **gh plumbing** β a bounded ``subprocess.run`` wrapper in two
|
|
22
|
+
flavors: ``run_gh`` (raises ``GhError`` with a didactic message β
|
|
23
|
+
publish/import/sync want fail-loud) and ``close_issue_best_effort``
|
|
24
|
+
(returns a warning string β ``issue resolve`` must NEVER fail because
|
|
25
|
+
the network did).
|
|
26
|
+
|
|
27
|
+
Label β type/severity heuristic (documented contract for ``import``):
|
|
28
|
+
|
|
29
|
+
================================= =============
|
|
30
|
+
GitHub label (case-insensitive) Issue field
|
|
31
|
+
================================= =============
|
|
32
|
+
``bug`` / ``regression`` type=bug
|
|
33
|
+
``enhancement`` / ``feature`` type=enhancement
|
|
34
|
+
``question`` / ``help wanted`` type=question
|
|
35
|
+
``documentation`` / ``docs`` /
|
|
36
|
+
``task`` / ``chore`` type=task
|
|
37
|
+
(none of the above) type=task
|
|
38
|
+
``critical`` / ``urgent`` / ``p0`` severity=critical
|
|
39
|
+
``high`` / ``p1`` severity=high
|
|
40
|
+
``low`` / ``minor`` / ``p3`` /
|
|
41
|
+
``trivial`` severity=low
|
|
42
|
+
(none of the above) severity=medium
|
|
43
|
+
================================= =============
|
|
44
|
+
|
|
45
|
+
Deliberately simple: first match wins in label order; anything fancier
|
|
46
|
+
belongs to a human triage, not an import heuristic.
|
|
47
|
+
"""
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
import re
|
|
52
|
+
import shutil
|
|
53
|
+
import subprocess
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from dna_cli import _git_symbiosis as gs
|
|
57
|
+
|
|
58
|
+
GH_TIMEOUT = 60 # seconds β every gh call here talks to the network
|
|
59
|
+
|
|
60
|
+
#: One comment template for the close-on-resolve sync so tests + prose
|
|
61
|
+
#: can't drift from the implementation.
|
|
62
|
+
CLOSE_COMMENT_TEMPLATE = (
|
|
63
|
+
"Resolved in DNA SDLC{resolution}.\n\n{footer}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GhError(Exception):
|
|
68
|
+
"""A ``gh`` invocation failed in a way the caller should surface.
|
|
69
|
+
|
|
70
|
+
The message is already didactic (install/auth/network hints) β CLI
|
|
71
|
+
commands wrap it in ``fail()`` verbatim.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# βββ pure builders (the offline-testable surface) βββββββββββββββββββββ
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_repo_from_remote(remote_url: str) -> str | None:
|
|
79
|
+
"""``owner/name`` from a git remote URL, or ``None``.
|
|
80
|
+
|
|
81
|
+
Handles the three shapes GitHub remotes come in::
|
|
82
|
+
|
|
83
|
+
https://github.com/owner/name.git
|
|
84
|
+
git@github.com:owner/name.git
|
|
85
|
+
ssh://git@github.com/owner/name
|
|
86
|
+
|
|
87
|
+
Non-GitHub remotes β ``None`` (the bridge is a github.com bridge).
|
|
88
|
+
"""
|
|
89
|
+
url = (remote_url or "").strip()
|
|
90
|
+
m = re.search(r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", url)
|
|
91
|
+
if not m:
|
|
92
|
+
return None
|
|
93
|
+
return f"{m.group(1)}/{m.group(2)}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def default_repo() -> str | None:
|
|
97
|
+
"""``owner/name`` derived from the current repo's ``origin`` remote.
|
|
98
|
+
|
|
99
|
+
Fail-soft (``None``) β callers turn absence into a didactic error
|
|
100
|
+
asking for ``--repo``.
|
|
101
|
+
"""
|
|
102
|
+
out = gs._run_git(["remote", "get-url", "origin"])
|
|
103
|
+
if not out:
|
|
104
|
+
return None
|
|
105
|
+
return parse_repo_from_remote(out.strip())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_issue_ref(ref: str) -> tuple[int, str | None]:
|
|
109
|
+
"""Parse ``#N`` / ``N`` / a full GitHub issue URL β ``(number, repo)``.
|
|
110
|
+
|
|
111
|
+
The URL form also yields the repo (``owner/name``); the numeric
|
|
112
|
+
forms yield ``(N, None)`` and the caller resolves the repo. Raises
|
|
113
|
+
``ValueError`` with a didactic message on anything else.
|
|
114
|
+
"""
|
|
115
|
+
r = (ref or "").strip()
|
|
116
|
+
m = re.match(r"^https?://github\.com/([^/\s]+)/([^/\s]+)/issues/(\d+)/?$", r)
|
|
117
|
+
if m:
|
|
118
|
+
return int(m.group(3)), f"{m.group(1)}/{m.group(2)}"
|
|
119
|
+
m = re.match(r"^#?(\d+)$", r)
|
|
120
|
+
if m:
|
|
121
|
+
return int(m.group(1)), None
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"referΓͺncia de issue invΓ‘lida: {ref!r} β use '#N', 'N' ou a URL "
|
|
124
|
+
f"completa (https://github.com/<owner>/<repo>/issues/N)."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def slug_from_title(title: str, *, max_words: int = 6) -> str:
|
|
129
|
+
"""Kebab-case slug from a GitHub issue title (for ``i-NNN-<slug>``)."""
|
|
130
|
+
words = re.sub(r"[^a-z0-9]+", " ", (title or "").lower()).split()
|
|
131
|
+
return "-".join(words[:max_words]) or "imported"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_issue_title(name: str, spec: dict[str, Any]) -> str:
|
|
135
|
+
"""GitHub issue title: ``<title> (<i-x>)`` β pure.
|
|
136
|
+
|
|
137
|
+
Same convention as ``build_pr_title``: the slug rides along so the
|
|
138
|
+
GitHub title alone is βK-pasteable back to the work item. Issues
|
|
139
|
+
often have no ``title`` field β fall back to the description's
|
|
140
|
+
first line, squeezed and bounded (GitHub caps titles at 256 chars).
|
|
141
|
+
"""
|
|
142
|
+
title = str(spec.get("title") or "").strip()
|
|
143
|
+
if not title:
|
|
144
|
+
desc = " ".join(str(spec.get("description") or name).split())
|
|
145
|
+
title = desc[:120] + ("β¦" if len(desc) > 120 else "")
|
|
146
|
+
return f"{title} ({name})"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def doc_url(repo: str, scope: str, name: str) -> str:
|
|
150
|
+
"""GitHub blob URL of the Issue doc inside the repo (main branch)."""
|
|
151
|
+
return f"https://github.com/{repo}/blob/main/.dna/{scope}/issues/{name}.yaml"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_issue_body(name: str, spec: dict[str, Any], *, scope: str, repo: str) -> str:
|
|
155
|
+
"""GitHub issue body: description + facts + doc link + 𧬠footer β pure.
|
|
156
|
+
|
|
157
|
+
The footer reuses the exact PR attribution template
|
|
158
|
+
(``_git_symbiosis.pr_footer_block``) with ``Issue/<i-x>`` as the
|
|
159
|
+
work item β one convention, three surfaces (commits, PRs, issues).
|
|
160
|
+
"""
|
|
161
|
+
parts: list[str] = []
|
|
162
|
+
desc = str(spec.get("description") or "").strip()
|
|
163
|
+
if desc:
|
|
164
|
+
parts.append(desc)
|
|
165
|
+
facts = [
|
|
166
|
+
f"**{label}:** {spec.get(key)}"
|
|
167
|
+
for label, key in (("Type", "type"), ("Severity", "severity"))
|
|
168
|
+
if spec.get(key)
|
|
169
|
+
]
|
|
170
|
+
if facts:
|
|
171
|
+
parts.append(" Β· ".join(facts))
|
|
172
|
+
parts.append(f"Tracked as [`{name}`]({doc_url(repo, scope, name)}) on the DNA SDLC board.")
|
|
173
|
+
parts.append(gs.pr_footer_block("Issue", name))
|
|
174
|
+
return "\n\n".join(parts) + "\n"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def map_labels(labels: list[str]) -> tuple[str, str]:
|
|
178
|
+
"""GitHub labels β ``(type, severity)`` β the documented heuristic.
|
|
179
|
+
|
|
180
|
+
First match wins in label order; defaults ``("task", "medium")``.
|
|
181
|
+
"""
|
|
182
|
+
type_map = {
|
|
183
|
+
"bug": "bug", "regression": "bug",
|
|
184
|
+
"enhancement": "enhancement", "feature": "enhancement",
|
|
185
|
+
"question": "question", "help wanted": "question",
|
|
186
|
+
"documentation": "task", "docs": "task", "task": "task", "chore": "task",
|
|
187
|
+
}
|
|
188
|
+
sev_map = {
|
|
189
|
+
"critical": "critical", "urgent": "critical", "p0": "critical",
|
|
190
|
+
"high": "high", "p1": "high",
|
|
191
|
+
"low": "low", "minor": "low", "p3": "low", "trivial": "low",
|
|
192
|
+
}
|
|
193
|
+
issue_type = severity = None
|
|
194
|
+
for raw in labels or []:
|
|
195
|
+
lbl = str(raw).strip().lower()
|
|
196
|
+
if issue_type is None and lbl in type_map:
|
|
197
|
+
issue_type = type_map[lbl]
|
|
198
|
+
if severity is None and lbl in sev_map:
|
|
199
|
+
severity = sev_map[lbl]
|
|
200
|
+
return issue_type or "task", severity or "medium"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def close_comment(name: str, resolution: str | None) -> str:
|
|
204
|
+
"""The comment posted when ``issue resolve`` closes the GitHub twin."""
|
|
205
|
+
res = f": {resolution}" if resolution else ""
|
|
206
|
+
return CLOSE_COMMENT_TEMPLATE.format(
|
|
207
|
+
resolution=res, footer=gs.pr_footer("Issue", name),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# βββ gh plumbing ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def gh_available() -> bool:
|
|
215
|
+
return shutil.which("gh") is not None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def gh_missing_message(context: str) -> str:
|
|
219
|
+
"""The didactic no-gh error, shared by publish/import/sync."""
|
|
220
|
+
return (
|
|
221
|
+
f"gh (GitHub CLI) nΓ£o encontrado no PATH β {context} precisa dele. "
|
|
222
|
+
"Instale via https://cli.github.com e autentique com `gh auth login`."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_gh(args: list[str], *, context: str) -> str:
|
|
227
|
+
"""Run ``gh`` fail-LOUD: returns stdout, raises ``GhError`` didactically.
|
|
228
|
+
|
|
229
|
+
``context`` names the operation for the error message ("issue
|
|
230
|
+
publish", "issue import", β¦). Never lets a raw traceback escape.
|
|
231
|
+
"""
|
|
232
|
+
if not gh_available():
|
|
233
|
+
raise GhError(gh_missing_message(context))
|
|
234
|
+
try:
|
|
235
|
+
proc = subprocess.run(args, capture_output=True, text=True, timeout=GH_TIMEOUT)
|
|
236
|
+
except subprocess.TimeoutExpired:
|
|
237
|
+
raise GhError(
|
|
238
|
+
f"gh excedeu {GH_TIMEOUT}s durante {context} β rede/gateway? Tente de novo."
|
|
239
|
+
) from None
|
|
240
|
+
if proc.returncode != 0:
|
|
241
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
242
|
+
raise GhError(
|
|
243
|
+
f"gh falhou durante {context}:\n{detail}\n"
|
|
244
|
+
"Dicas: `gh auth status` mostra a autenticaΓ§Γ£o; --repo "
|
|
245
|
+
"aponta o repositΓ³rio explicitamente."
|
|
246
|
+
)
|
|
247
|
+
return proc.stdout or ""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def fetch_issue(number: int, repo: str, *, context: str) -> dict[str, Any]:
|
|
251
|
+
"""``gh issue view N --json β¦`` β parsed dict (fail-loud via GhError)."""
|
|
252
|
+
out = run_gh(
|
|
253
|
+
[
|
|
254
|
+
"gh", "issue", "view", str(number), "--repo", repo,
|
|
255
|
+
"--json", "number,title,body,state,url,author,labels,createdAt,closedAt",
|
|
256
|
+
],
|
|
257
|
+
context=context,
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
return json.loads(out)
|
|
261
|
+
except json.JSONDecodeError:
|
|
262
|
+
raise GhError(
|
|
263
|
+
f"gh devolveu JSON invΓ‘lido durante {context} β saΓda: {out[:200]!r}"
|
|
264
|
+
) from None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def close_issue_best_effort(
|
|
268
|
+
number: int, repo: str | None, comment: str,
|
|
269
|
+
) -> str | None:
|
|
270
|
+
"""Close the GitHub twin with a comment β NEVER raises.
|
|
271
|
+
|
|
272
|
+
Returns ``None`` on success, else a human warning string the caller
|
|
273
|
+
prints (the local resolve already happened; the sync is best-effort
|
|
274
|
+
by contract).
|
|
275
|
+
"""
|
|
276
|
+
if not gh_available():
|
|
277
|
+
return (
|
|
278
|
+
f"gh nΓ£o encontrado β issue #{number} NΓO foi fechada no GitHub "
|
|
279
|
+
f"(feche Γ mΓ£o ou rode `dna sdlc issue sync` depois)."
|
|
280
|
+
)
|
|
281
|
+
args = ["gh", "issue", "close", str(number), "--comment", comment]
|
|
282
|
+
if repo:
|
|
283
|
+
args += ["--repo", repo]
|
|
284
|
+
try:
|
|
285
|
+
proc = subprocess.run(args, capture_output=True, text=True, timeout=GH_TIMEOUT)
|
|
286
|
+
except Exception as e: # noqa: BLE001 β best-effort by contract
|
|
287
|
+
return f"nΓ£o consegui fechar a issue #{number} no GitHub ({e}) β feche Γ mΓ£o."
|
|
288
|
+
if proc.returncode != 0:
|
|
289
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
290
|
+
return (
|
|
291
|
+
f"gh issue close #{number} falhou (non-fatal): {detail} β "
|
|
292
|
+
f"o resolve local jΓ‘ aconteceu; feche no GitHub Γ mΓ£o."
|
|
293
|
+
)
|
|
294
|
+
return None
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Methodology gates β pure functions enforcing the superpowers contract.
|
|
2
|
+
|
|
3
|
+
`methodology=superpowers` was historically just a string label in
|
|
4
|
+
`JOURNEY_METHODOLOGIES`. These gates turn it into a verifiable
|
|
5
|
+
contract: spec/plan artifacts must exist, build β reflect requires
|
|
6
|
+
test files in the diff, and the Auditor blocks ad-hoc streaks.
|
|
7
|
+
|
|
8
|
+
The four gates are PURE FUNCTIONS β no I/O beyond filesystem stat and
|
|
9
|
+
a single subprocess call in `_git_diff_files` (which is monkeypatched
|
|
10
|
+
in tests). The CLI in `sdlc_cmd.py` calls these at phase boundaries
|
|
11
|
+
and translates GateResult.FAIL into exit code 2.
|
|
12
|
+
|
|
13
|
+
Spec: docs/superpowers/specs/2026-05-11-f-superpowers-skill-integration.md
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import subprocess
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GateResult(Enum):
|
|
23
|
+
"""Outcome of a gate check.
|
|
24
|
+
|
|
25
|
+
PASS β gate satisfied, proceed.
|
|
26
|
+
FAIL β gate violated, caller should exit 2 unless --force --reason.
|
|
27
|
+
SKIP β gate not applicable in this context (e.g. methodology=ad-hoc).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
PASS = "pass"
|
|
31
|
+
FAIL = "fail"
|
|
32
|
+
SKIP = "skip"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# βββββ spec_gate βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def spec_gate(*, methodology: str, phase: str, artifact: str | None) -> GateResult:
|
|
39
|
+
"""Specify phase under superpowers requires a real Spec doc.
|
|
40
|
+
|
|
41
|
+
Returns SKIP unless `methodology == "superpowers"` and `phase == "specify"`.
|
|
42
|
+
Returns FAIL if `artifact` is None or points to a missing file.
|
|
43
|
+
Returns PASS otherwise.
|
|
44
|
+
"""
|
|
45
|
+
if methodology != "superpowers" or phase != "specify":
|
|
46
|
+
return GateResult.SKIP
|
|
47
|
+
if not artifact:
|
|
48
|
+
return GateResult.FAIL
|
|
49
|
+
return GateResult.PASS if Path(artifact).exists() else GateResult.FAIL
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# βββββ plan_gate βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def plan_gate(
|
|
56
|
+
*,
|
|
57
|
+
methodology: str,
|
|
58
|
+
phase: str,
|
|
59
|
+
artifact: str | None,
|
|
60
|
+
auto_stub: bool,
|
|
61
|
+
) -> GateResult:
|
|
62
|
+
"""Plan phase under superpowers requires Plan doc OR --auto-stub.
|
|
63
|
+
|
|
64
|
+
SKIP if not (superpowers + plan).
|
|
65
|
+
PASS if --auto-stub (caller will stub the plan file) or artifact exists.
|
|
66
|
+
FAIL otherwise.
|
|
67
|
+
"""
|
|
68
|
+
if methodology != "superpowers" or phase != "plan":
|
|
69
|
+
return GateResult.SKIP
|
|
70
|
+
if auto_stub:
|
|
71
|
+
return GateResult.PASS
|
|
72
|
+
if not artifact:
|
|
73
|
+
return GateResult.FAIL
|
|
74
|
+
return GateResult.PASS if Path(artifact).exists() else GateResult.FAIL
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# βββββ tdd_gate ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _git_diff_files(since: str) -> list[str]:
|
|
81
|
+
"""Return list of files changed between ``since..HEAD``.
|
|
82
|
+
|
|
83
|
+
Returns [] on any git error (we fail-open at the caller via SKIP
|
|
84
|
+
when since_sha is missing).
|
|
85
|
+
"""
|
|
86
|
+
out = subprocess.run(
|
|
87
|
+
["git", "diff", "--name-only", f"{since}..HEAD"],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
if out.returncode != 0:
|
|
93
|
+
return []
|
|
94
|
+
return [line for line in out.stdout.splitlines() if line]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _looks_like_test_file(path: str) -> bool:
|
|
98
|
+
"""Heuristic: does this path look like a test file?
|
|
99
|
+
|
|
100
|
+
Accepts: anything under ``tests/`` (Python convention) or files
|
|
101
|
+
matching ``test_*.py`` / ``*_test.py`` / ``*.test.ts`` / ``*.spec.ts``.
|
|
102
|
+
"""
|
|
103
|
+
if "tests/" in path or "/test_" in path:
|
|
104
|
+
return True
|
|
105
|
+
name = path.rsplit("/", 1)[-1]
|
|
106
|
+
if name.startswith("test_") and name.endswith(".py"):
|
|
107
|
+
return True
|
|
108
|
+
if name.endswith("_test.py"):
|
|
109
|
+
return True
|
|
110
|
+
if name.endswith(".test.ts") or name.endswith(".test.tsx"):
|
|
111
|
+
return True
|
|
112
|
+
if name.endswith(".spec.ts") or name.endswith(".spec.tsx"):
|
|
113
|
+
return True
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def tdd_gate(
|
|
118
|
+
*,
|
|
119
|
+
methodology: str,
|
|
120
|
+
prev_phase: str,
|
|
121
|
+
next_phase: str,
|
|
122
|
+
since_sha: str | None,
|
|
123
|
+
) -> GateResult:
|
|
124
|
+
"""Transition build β reflect under superpowers requires test files in diff.
|
|
125
|
+
|
|
126
|
+
SKIP if methodology is not superpowers, transition is not buildβreflect,
|
|
127
|
+
or `since_sha` is missing (cannot verify honestly).
|
|
128
|
+
PASS if any file in the git diff since `since_sha` looks like a test.
|
|
129
|
+
FAIL otherwise.
|
|
130
|
+
"""
|
|
131
|
+
if methodology != "superpowers" or prev_phase != "build" or next_phase != "reflect":
|
|
132
|
+
return GateResult.SKIP
|
|
133
|
+
if not since_sha:
|
|
134
|
+
return GateResult.SKIP
|
|
135
|
+
files = _git_diff_files(since_sha)
|
|
136
|
+
return GateResult.PASS if any(_looks_like_test_file(f) for f in files) else GateResult.FAIL
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# βββββ auditor_gate ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_AUDITOR_WINDOW = 5
|
|
143
|
+
_AUDITOR_THRESHOLD = 3
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def auditor_gate(
|
|
147
|
+
*,
|
|
148
|
+
recent_methodologies: list[str],
|
|
149
|
+
next_methodology: str,
|
|
150
|
+
) -> GateResult:
|
|
151
|
+
"""Block ad-hoc streaks. Looks at the last 5 methodologies.
|
|
152
|
+
|
|
153
|
+
SKIP when history < 4 (insufficient data).
|
|
154
|
+
PASS when next_methodology is `superpowers` (any streak satisfied by upgrade).
|
|
155
|
+
PASS when last 5 contain < 3 ad-hoc entries.
|
|
156
|
+
FAIL when last 5 contain >= 3 ad-hoc AND next is not superpowers.
|
|
157
|
+
"""
|
|
158
|
+
if len(recent_methodologies) < 4:
|
|
159
|
+
return GateResult.SKIP
|
|
160
|
+
window = recent_methodologies[-_AUDITOR_WINDOW:]
|
|
161
|
+
ad_hoc_count = sum(1 for m in window if m == "ad-hoc")
|
|
162
|
+
if ad_hoc_count >= _AUDITOR_THRESHOLD and next_methodology != "superpowers":
|
|
163
|
+
return GateResult.FAIL
|
|
164
|
+
return GateResult.PASS
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""prepare-commit-msg β DNA gitβSDLC symbiosis hook (s-sdlc-git-symbiosis).
|
|
3
|
+
|
|
4
|
+
Stamps every commit made while a Story is active with two trailers:
|
|
5
|
+
|
|
6
|
+
Work-Item: Story/<story-name>
|
|
7
|
+
Co-Authored-By: dna-sdlc[bot] <dna-sdlc[bot]@users.noreply.github.com>
|
|
8
|
+
|
|
9
|
+
The active Story comes from `.dna/active-story.txt` at the repo root
|
|
10
|
+
(single line, `<scope>:<story-name>` β written by `dna sdlc story start`,
|
|
11
|
+
cleared by `story done|block|cancel`). No active Story β no stamp:
|
|
12
|
+
absence is signal too. The co-author is the dna sdlc TOOL identity (a
|
|
13
|
+
provenance seal: "this commit was born under story governance"), not a
|
|
14
|
+
human co-author; override it with $DNA_SDLC_COAUTHOR.
|
|
15
|
+
|
|
16
|
+
Rules:
|
|
17
|
+
- zero dependencies: python3 + git only (runs without any venv);
|
|
18
|
+
- idempotent: never duplicates a trailer already present
|
|
19
|
+
(`git interpret-trailers --if-exists addIfDifferent`);
|
|
20
|
+
- respects merge/squash/amend messages (2nd hook arg in
|
|
21
|
+
{merge, squash, commit}) and empty messages: no stamp;
|
|
22
|
+
- fail-soft: any unexpected error exits 0 β never blocks a commit.
|
|
23
|
+
|
|
24
|
+
Install: `dna sdlc hooks install` (β git config core.hooksPath scripts/git-hooks)
|
|
25
|
+
|
|
26
|
+
Keep in sync with packages/cli/dna_cli/_git_symbiosis.py β the CLI test
|
|
27
|
+
suite asserts the constants below match, and that the repo copy at
|
|
28
|
+
scripts/git-hooks/prepare-commit-msg is byte-identical to the packaged
|
|
29
|
+
copy in dna_cli/data/git-hooks/.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
WORK_ITEM_TRAILER = "Work-Item"
|
|
38
|
+
COAUTHOR_TRAILER = "Co-Authored-By"
|
|
39
|
+
DEFAULT_SDLC_COAUTHOR = "dna-sdlc[bot] <dna-sdlc[bot]@users.noreply.github.com>"
|
|
40
|
+
COAUTHOR_ENV = "DNA_SDLC_COAUTHOR"
|
|
41
|
+
|
|
42
|
+
ACTIVE_STORY_FILE = ".dna/active-story.txt"
|
|
43
|
+
|
|
44
|
+
# 2nd hook argument values that must NEVER be stamped: merges, squashes and
|
|
45
|
+
# amend/-c/-C reuse an existing message that was (or wasn't) stamped when it
|
|
46
|
+
# was born β rewriting it here would be surprising.
|
|
47
|
+
SKIP_SOURCES = {"merge", "squash", "commit"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _git(args, cwd=None):
|
|
51
|
+
"""Run git; return stdout or None on any failure (fail-soft)."""
|
|
52
|
+
try:
|
|
53
|
+
proc = subprocess.run(
|
|
54
|
+
["git"] + list(args),
|
|
55
|
+
capture_output=True, text=True, timeout=10, cwd=cwd,
|
|
56
|
+
)
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
if proc.returncode != 0:
|
|
60
|
+
return None
|
|
61
|
+
return proc.stdout
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_active_story(repo_root):
|
|
65
|
+
"""Parse `.dna/active-story.txt` β (scope, name) or None."""
|
|
66
|
+
path = os.path.join(repo_root, ACTIVE_STORY_FILE)
|
|
67
|
+
try:
|
|
68
|
+
with open(path, encoding="utf-8") as fh:
|
|
69
|
+
raw = fh.read().strip()
|
|
70
|
+
except OSError:
|
|
71
|
+
return None
|
|
72
|
+
if not raw or ":" not in raw:
|
|
73
|
+
return None
|
|
74
|
+
scope, _, name = raw.partition(":")
|
|
75
|
+
scope, name = scope.strip(), name.strip()
|
|
76
|
+
if not scope or not name:
|
|
77
|
+
return None
|
|
78
|
+
return scope, name
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def message_is_empty(msg_file):
|
|
82
|
+
"""True when the message has no content outside comment lines."""
|
|
83
|
+
try:
|
|
84
|
+
with open(msg_file, encoding="utf-8") as fh:
|
|
85
|
+
text = fh.read()
|
|
86
|
+
except OSError:
|
|
87
|
+
return True
|
|
88
|
+
comment_char = (_git(["config", "--get", "core.commentchar"]) or "#").strip() or "#"
|
|
89
|
+
if comment_char == "auto":
|
|
90
|
+
comment_char = "#"
|
|
91
|
+
for line in text.splitlines():
|
|
92
|
+
if line.strip() and not line.startswith(comment_char):
|
|
93
|
+
return False
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stamp(msg_file, trailers):
|
|
98
|
+
"""Append trailers idempotently via git interpret-trailers."""
|
|
99
|
+
args = ["interpret-trailers", "--in-place", "--if-exists", "addIfDifferent"]
|
|
100
|
+
for t in trailers:
|
|
101
|
+
args += ["--trailer", t]
|
|
102
|
+
args.append(msg_file)
|
|
103
|
+
return _git(args) is not None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv):
|
|
107
|
+
if len(argv) < 2:
|
|
108
|
+
return 0
|
|
109
|
+
msg_file = argv[1]
|
|
110
|
+
source = argv[2] if len(argv) > 2 else ""
|
|
111
|
+
if source in SKIP_SOURCES:
|
|
112
|
+
return 0
|
|
113
|
+
root = (_git(["rev-parse", "--show-toplevel"]) or "").strip() or os.getcwd()
|
|
114
|
+
active = read_active_story(root)
|
|
115
|
+
if active is None:
|
|
116
|
+
return 0 # no active story β absence is signal
|
|
117
|
+
if message_is_empty(msg_file):
|
|
118
|
+
return 0 # don't turn an aborted (empty) commit into a non-empty one
|
|
119
|
+
_scope, name = active
|
|
120
|
+
coauthor = os.environ.get(COAUTHOR_ENV, "").strip() or DEFAULT_SDLC_COAUTHOR
|
|
121
|
+
stamp(msg_file, [
|
|
122
|
+
"%s: Story/%s" % (WORK_ITEM_TRAILER, name),
|
|
123
|
+
"%s: %s" % (COAUTHOR_TRAILER, coauthor),
|
|
124
|
+
])
|
|
125
|
+
return 0 # fail-soft: never block the commit
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
try:
|
|
130
|
+
sys.exit(main(sys.argv))
|
|
131
|
+
except Exception:
|
|
132
|
+
sys.exit(0) # fail-soft by contract
|