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.
@@ -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