@deftai/directive-content 0.58.0 → 0.60.0
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.
- package/.githooks/pre-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
package/scripts/issue_emit.py
DELETED
|
@@ -1,670 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
r"""issue_emit.py -- Emit GitHub issues FROM scope vBRIEFs (the write path).
|
|
3
|
-
|
|
4
|
-
This is the symmetric reverse of :mod:`scripts.issue_ingest`. Where
|
|
5
|
-
``task issue:ingest`` reads a GitHub issue and materialises a scope vBRIEF,
|
|
6
|
-
``task issue:emit`` reads one or more scope vBRIEFs and files GitHub
|
|
7
|
-
issue(s), then records the resulting issue URL back into each source
|
|
8
|
-
vBRIEF's ``plan.references[]`` as an ``x-vbrief/github-issue`` entry with
|
|
9
|
-
``TrustLevel: external``. Together the two verbs close the
|
|
10
|
-
vBRIEF <-> GitHub-issue trust loop (#1274 Change 2 / epic #1284).
|
|
11
|
-
|
|
12
|
-
Modes:
|
|
13
|
-
uv run python scripts/issue_emit.py <vbrief-path>
|
|
14
|
-
File ONE issue for the named vBRIEF and write the URL back into it.
|
|
15
|
-
uv run python scripts/issue_emit.py --umbrella <glob> [<glob> ...]
|
|
16
|
-
File ONE umbrella issue with a checklist of the matched vBRIEFs and
|
|
17
|
-
write the umbrella URL back into EVERY matched vBRIEF.
|
|
18
|
-
uv run python scripts/issue_emit.py --per-vbrief <glob> [<glob> ...]
|
|
19
|
-
File one issue per matched vBRIEF.
|
|
20
|
-
|
|
21
|
-
Flags:
|
|
22
|
-
--dry-run Print the plan of issues that WOULD be filed; make no
|
|
23
|
-
forge write and no on-disk vBRIEF mutation.
|
|
24
|
-
--repo OWNER/REPO Target repo (highest precedence; falls back to
|
|
25
|
-
$DEFT_PROJECT_REPO / git remote detection).
|
|
26
|
-
--project-root Consumer project root for repo / glob anchoring.
|
|
27
|
-
--title Umbrella issue title override (--umbrella mode only).
|
|
28
|
-
--json Emit a machine-readable JSON summary instead of prose.
|
|
29
|
-
|
|
30
|
-
Network honour:
|
|
31
|
-
``DEFT_NO_NETWORK=1`` is treated identically to ``--dry-run`` -- a plan
|
|
32
|
-
is printed, no issue is filed, and no source vBRIEF is mutated on disk.
|
|
33
|
-
|
|
34
|
-
Idempotency:
|
|
35
|
-
A source vBRIEF that already carries a matching ``x-vbrief/github-issue``
|
|
36
|
-
reference is detected and skipped rather than re-filed, so a re-run does
|
|
37
|
-
not create duplicate issues.
|
|
38
|
-
|
|
39
|
-
All ``gh`` invocations route through :func:`scripts.scm.call` (#1145 / N5)
|
|
40
|
-
so the SCM boundary holds; the issue body is passed via
|
|
41
|
-
``gh issue create --body-file`` (written as pathlib UTF-8) rather than an
|
|
42
|
-
inline ``--body`` so non-ASCII narrative glyphs survive the round-trip.
|
|
43
|
-
|
|
44
|
-
Exit codes:
|
|
45
|
-
0 -- emit completed (including dry-run / no-network plans and pure-skip)
|
|
46
|
-
2 -- usage / configuration / forge error
|
|
47
|
-
|
|
48
|
-
Story: #1274 Change 2 (task issue:emit); epic #1284.
|
|
49
|
-
"""
|
|
50
|
-
|
|
51
|
-
from __future__ import annotations
|
|
52
|
-
|
|
53
|
-
import argparse
|
|
54
|
-
import contextlib
|
|
55
|
-
import glob as globlib
|
|
56
|
-
import json
|
|
57
|
-
import os
|
|
58
|
-
import re
|
|
59
|
-
import sys
|
|
60
|
-
import tempfile
|
|
61
|
-
from collections.abc import Callable
|
|
62
|
-
from pathlib import Path
|
|
63
|
-
from typing import Any
|
|
64
|
-
|
|
65
|
-
# Make sibling scripts importable both when run as __main__ and when imported
|
|
66
|
-
# by tests that pre-populate sys.path with the ``scripts/`` directory.
|
|
67
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
68
|
-
|
|
69
|
-
import scm # noqa: E402 -- sibling-first path insertion above is intentional
|
|
70
|
-
from _project_context import resolve_project_repo, resolve_project_root # noqa: E402
|
|
71
|
-
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
72
|
-
|
|
73
|
-
reconfigure_stdio()
|
|
74
|
-
|
|
75
|
-
# --- Constants --------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
#: Canonical reference type recorded on a source vBRIEF after a successful
|
|
78
|
-
#: emit. Matches ``conventions/references.md`` and the
|
|
79
|
-
#: ``EXTERNAL_REFERENCE_TYPES`` set in ``scripts/_vbrief_build.py``.
|
|
80
|
-
GITHUB_ISSUE_REF_TYPE = "x-vbrief/github-issue"
|
|
81
|
-
|
|
82
|
-
#: TrustLevel stamped on the emitted reference. An issue filed from a
|
|
83
|
-
#: vBRIEF lives on the external forge, so it is ``external`` (the same
|
|
84
|
-
#: default ``reference_with_default_trust`` would assign).
|
|
85
|
-
EXTERNAL_TRUST_LEVEL = "external"
|
|
86
|
-
|
|
87
|
-
#: Extracts the browser issue URL from ``gh issue create`` stdout. gh prints
|
|
88
|
-
#: the created issue URL (e.g. ``https://github.com/o/r/issues/42``) as the
|
|
89
|
-
#: final line of stdout on success.
|
|
90
|
-
_ISSUE_URL_RE = re.compile(r"https?://\S+?/issues/\d+")
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
class IssueEmitError(RuntimeError):
|
|
94
|
-
"""Raised when filing a GitHub issue through the scm shim fails."""
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
# --- vBRIEF helpers ---------------------------------------------------------
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def load_vbrief(path: Path) -> dict:
|
|
101
|
-
"""Read and parse a vBRIEF JSON file (UTF-8)."""
|
|
102
|
-
data: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
103
|
-
return data if isinstance(data, dict) else {}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def write_vbrief(path: Path, data: dict) -> None:
|
|
107
|
-
"""Write a vBRIEF dict back to disk as pretty-printed UTF-8 JSON.
|
|
108
|
-
|
|
109
|
-
Uses ``ensure_ascii=False`` so non-ASCII narrative glyphs round-trip as
|
|
110
|
-
real UTF-8 bytes (``task verify:encoding`` flags mojibake / BOM, and a
|
|
111
|
-
locale-default write would risk both on Windows hosts).
|
|
112
|
-
"""
|
|
113
|
-
path.write_text(
|
|
114
|
-
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
115
|
-
encoding="utf-8",
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def vbrief_title(data: dict) -> str:
|
|
120
|
-
"""Resolve a human title for a vBRIEF.
|
|
121
|
-
|
|
122
|
-
Prefers ``plan.title``; falls back to ``vBRIEFInfo.description`` and then
|
|
123
|
-
a generic placeholder so a malformed vBRIEF still produces a usable issue
|
|
124
|
-
title rather than an empty string.
|
|
125
|
-
"""
|
|
126
|
-
plan = data.get("plan", {}) if isinstance(data, dict) else {}
|
|
127
|
-
title = plan.get("title") if isinstance(plan, dict) else None
|
|
128
|
-
if isinstance(title, str) and title.strip():
|
|
129
|
-
return title.strip()
|
|
130
|
-
info = data.get("vBRIEFInfo", {}) if isinstance(data, dict) else {}
|
|
131
|
-
desc = info.get("description", "") if isinstance(info, dict) else ""
|
|
132
|
-
if isinstance(desc, str) and desc.strip():
|
|
133
|
-
return desc.strip()
|
|
134
|
-
return "Untitled vBRIEF"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def existing_github_issue_ref(data: dict) -> str | None:
|
|
138
|
-
"""Return the first ``x-vbrief/github-issue`` reference URI, or None.
|
|
139
|
-
|
|
140
|
-
Used for idempotency: a vBRIEF that already carries a github-issue
|
|
141
|
-
reference has already been tracked, so ``emit`` skips it instead of
|
|
142
|
-
filing a duplicate. Returns the empty string when a matching reference
|
|
143
|
-
exists but carries no URI (still a positive "already tracked" signal).
|
|
144
|
-
"""
|
|
145
|
-
if not isinstance(data, dict):
|
|
146
|
-
return None
|
|
147
|
-
plan = data.get("plan", {})
|
|
148
|
-
refs = plan.get("references", []) if isinstance(plan, dict) else []
|
|
149
|
-
for ref in refs:
|
|
150
|
-
if isinstance(ref, dict) and ref.get("type") == GITHUB_ISSUE_REF_TYPE:
|
|
151
|
-
uri = ref.get("uri") or ref.get("url")
|
|
152
|
-
return uri if isinstance(uri, str) and uri else ""
|
|
153
|
-
return None
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def add_github_issue_reference(data: dict, url: str) -> dict:
|
|
157
|
-
"""Append an external github-issue reference to ``plan.references[]``.
|
|
158
|
-
|
|
159
|
-
Mutates ``data`` in place (and returns it for convenience). The appended
|
|
160
|
-
entry is the canonical ``{uri, type, TrustLevel}`` shape required by the
|
|
161
|
-
#1274 acceptance criteria.
|
|
162
|
-
"""
|
|
163
|
-
plan = data.setdefault("plan", {})
|
|
164
|
-
refs = plan.setdefault("references", [])
|
|
165
|
-
refs.append(
|
|
166
|
-
{
|
|
167
|
-
"uri": url,
|
|
168
|
-
"type": GITHUB_ISSUE_REF_TYPE,
|
|
169
|
-
"TrustLevel": EXTERNAL_TRUST_LEVEL,
|
|
170
|
-
}
|
|
171
|
-
)
|
|
172
|
-
return data
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# --- Issue body rendering ---------------------------------------------------
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def render_issue_body(data: dict) -> str:
|
|
179
|
-
"""Render a GitHub issue body from a vBRIEF's narratives.
|
|
180
|
-
|
|
181
|
-
Sections, in order: Description, Acceptance (plan-level narrative plus
|
|
182
|
-
per-item ``narrative.Acceptance`` bullets), and Traces. Empty sections
|
|
183
|
-
are omitted. A vBRIEF with no usable narrative still yields a non-empty
|
|
184
|
-
body naming the scope so ``gh issue create`` never receives an empty
|
|
185
|
-
``--body-file``.
|
|
186
|
-
"""
|
|
187
|
-
plan = data.get("plan", {}) if isinstance(data, dict) else {}
|
|
188
|
-
narratives = plan.get("narratives", {}) if isinstance(plan, dict) else {}
|
|
189
|
-
if not isinstance(narratives, dict):
|
|
190
|
-
narratives = {}
|
|
191
|
-
|
|
192
|
-
parts: list[str] = []
|
|
193
|
-
|
|
194
|
-
desc = narratives.get("Description")
|
|
195
|
-
if isinstance(desc, str) and desc.strip():
|
|
196
|
-
parts.append("## Description\n\n" + desc.strip())
|
|
197
|
-
|
|
198
|
-
acceptance_lines: list[str] = []
|
|
199
|
-
plan_acceptance = narratives.get("Acceptance")
|
|
200
|
-
if isinstance(plan_acceptance, str) and plan_acceptance.strip():
|
|
201
|
-
acceptance_lines.append(plan_acceptance.strip())
|
|
202
|
-
for item in plan.get("items", []) if isinstance(plan, dict) else []:
|
|
203
|
-
if not isinstance(item, dict):
|
|
204
|
-
continue
|
|
205
|
-
item_narrative = item.get("narrative", {})
|
|
206
|
-
acc = item_narrative.get("Acceptance") if isinstance(item_narrative, dict) else None
|
|
207
|
-
if isinstance(acc, str) and acc.strip():
|
|
208
|
-
item_title = str(item.get("title", "")).strip()
|
|
209
|
-
if item_title:
|
|
210
|
-
acceptance_lines.append(f"- **{item_title}**: {acc.strip()}")
|
|
211
|
-
else:
|
|
212
|
-
acceptance_lines.append(f"- {acc.strip()}")
|
|
213
|
-
if acceptance_lines:
|
|
214
|
-
parts.append("## Acceptance\n\n" + "\n".join(acceptance_lines))
|
|
215
|
-
|
|
216
|
-
traces = narratives.get("Traces")
|
|
217
|
-
if isinstance(traces, str) and traces.strip():
|
|
218
|
-
parts.append("## Traces\n\n" + traces.strip())
|
|
219
|
-
|
|
220
|
-
if not parts:
|
|
221
|
-
return f"Scope vBRIEF: {vbrief_title(data)}\n"
|
|
222
|
-
return "\n\n".join(parts) + "\n"
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def render_umbrella_body(entries: list[tuple[str, dict]], *, intro: str | None = None) -> str:
|
|
226
|
-
"""Render an umbrella issue body with a checklist of tracked vBRIEFs.
|
|
227
|
-
|
|
228
|
-
``entries`` is a list of ``(relative-or-display-path, vbrief-data)``
|
|
229
|
-
tuples. Each becomes an unchecked task-list item naming the vBRIEF title
|
|
230
|
-
and its path so the umbrella reads as a roadmap.
|
|
231
|
-
"""
|
|
232
|
-
lines: list[str] = []
|
|
233
|
-
if intro:
|
|
234
|
-
lines.append(intro.strip())
|
|
235
|
-
lines.append("")
|
|
236
|
-
lines.append("## Tracked vBRIEFs")
|
|
237
|
-
lines.append("")
|
|
238
|
-
for display_path, data in entries:
|
|
239
|
-
lines.append(f"- [ ] {vbrief_title(data)} (`{display_path}`)")
|
|
240
|
-
return "\n".join(lines) + "\n"
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
# --- Forge interaction ------------------------------------------------------
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def file_issue(
|
|
247
|
-
repo: str,
|
|
248
|
-
title: str,
|
|
249
|
-
body: str,
|
|
250
|
-
*,
|
|
251
|
-
scm_call: Callable[..., Any] | None = None,
|
|
252
|
-
) -> str:
|
|
253
|
-
"""File a single GitHub issue via the scm shim and return its URL.
|
|
254
|
-
|
|
255
|
-
The body is written to a temporary UTF-8 file and passed through
|
|
256
|
-
``gh issue create --body-file`` so non-ASCII glyphs survive (an inline
|
|
257
|
-
``--body`` risks codepage corruption on Windows hosts). The call routes
|
|
258
|
-
through :func:`scripts.scm.call` with ``source="github-issue"`` so the
|
|
259
|
-
#1145 SCM boundary holds, and forces ``encoding="utf-8",
|
|
260
|
-
errors="replace"`` per the #1366 safe-capture rule.
|
|
261
|
-
|
|
262
|
-
Raises :class:`IssueEmitError` on a non-zero exit or when no issue URL
|
|
263
|
-
can be parsed from stdout.
|
|
264
|
-
"""
|
|
265
|
-
# Resolve the binding at call time (not as a default arg) so tests that
|
|
266
|
-
# monkeypatch ``issue_emit.scm.call`` take effect.
|
|
267
|
-
if scm_call is None:
|
|
268
|
-
scm_call = scm.call
|
|
269
|
-
fd, tmp_name = tempfile.mkstemp(suffix=".md", prefix="deft-issue-emit-")
|
|
270
|
-
os.close(fd)
|
|
271
|
-
body_path = Path(tmp_name)
|
|
272
|
-
try:
|
|
273
|
-
body_path.write_text(body, encoding="utf-8")
|
|
274
|
-
result = scm_call(
|
|
275
|
-
"github-issue",
|
|
276
|
-
"issue",
|
|
277
|
-
[
|
|
278
|
-
"create",
|
|
279
|
-
"--repo",
|
|
280
|
-
repo,
|
|
281
|
-
"--title",
|
|
282
|
-
title,
|
|
283
|
-
"--body-file",
|
|
284
|
-
str(body_path),
|
|
285
|
-
],
|
|
286
|
-
timeout=60,
|
|
287
|
-
encoding="utf-8",
|
|
288
|
-
errors="replace",
|
|
289
|
-
)
|
|
290
|
-
finally:
|
|
291
|
-
with contextlib.suppress(OSError):
|
|
292
|
-
body_path.unlink()
|
|
293
|
-
|
|
294
|
-
if result.returncode != 0:
|
|
295
|
-
stderr = (result.stderr or "").strip()
|
|
296
|
-
raise IssueEmitError(f"gh issue create failed (exit {result.returncode}): {stderr}")
|
|
297
|
-
stdout = (result.stdout or "").strip()
|
|
298
|
-
match = _ISSUE_URL_RE.search(stdout)
|
|
299
|
-
if match:
|
|
300
|
-
return match.group(0)
|
|
301
|
-
if stdout:
|
|
302
|
-
return stdout
|
|
303
|
-
raise IssueEmitError("gh issue create succeeded but emitted no issue URL on stdout")
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
# --- Emit actions -----------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def emit_single(
|
|
310
|
-
path: Path,
|
|
311
|
-
*,
|
|
312
|
-
repo: str,
|
|
313
|
-
scm_call: Callable[..., Any] | None = None,
|
|
314
|
-
no_network: bool = False,
|
|
315
|
-
display_path: str | None = None,
|
|
316
|
-
) -> dict:
|
|
317
|
-
"""File one issue for a single vBRIEF and write the URL back into it.
|
|
318
|
-
|
|
319
|
-
Returns an action dict with ``result`` one of ``"created"`` /
|
|
320
|
-
``"dryrun"`` / ``"skipped"``. ``no_network`` (dry-run or DEFT_NO_NETWORK)
|
|
321
|
-
prints nothing here -- it returns a ``"dryrun"`` action and makes no
|
|
322
|
-
forge write and no on-disk mutation. A vBRIEF that already carries a
|
|
323
|
-
github-issue reference returns ``"skipped"`` (idempotency).
|
|
324
|
-
"""
|
|
325
|
-
shown = display_path or str(path)
|
|
326
|
-
data = load_vbrief(path)
|
|
327
|
-
existing = existing_github_issue_ref(data)
|
|
328
|
-
if existing is not None:
|
|
329
|
-
return {
|
|
330
|
-
"result": "skipped",
|
|
331
|
-
"vbrief": shown,
|
|
332
|
-
"url": existing or None,
|
|
333
|
-
"title": vbrief_title(data),
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
title = vbrief_title(data)
|
|
337
|
-
if no_network:
|
|
338
|
-
return {
|
|
339
|
-
"result": "dryrun",
|
|
340
|
-
"vbrief": shown,
|
|
341
|
-
"url": None,
|
|
342
|
-
"title": title,
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
body = render_issue_body(data)
|
|
346
|
-
url = file_issue(repo, title, body, scm_call=scm_call)
|
|
347
|
-
add_github_issue_reference(data, url)
|
|
348
|
-
write_vbrief(path, data)
|
|
349
|
-
return {"result": "created", "vbrief": shown, "url": url, "title": title}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
def emit_per_vbrief(
|
|
353
|
-
paths: list[Path],
|
|
354
|
-
*,
|
|
355
|
-
repo: str,
|
|
356
|
-
scm_call: Callable[..., Any] | None = None,
|
|
357
|
-
no_network: bool = False,
|
|
358
|
-
display_paths: list[str] | None = None,
|
|
359
|
-
) -> list[dict]:
|
|
360
|
-
"""File one issue per matched vBRIEF (delegates to :func:`emit_single`)."""
|
|
361
|
-
shown = display_paths or [str(p) for p in paths]
|
|
362
|
-
actions: list[dict] = []
|
|
363
|
-
for path, disp in zip(paths, shown, strict=True):
|
|
364
|
-
actions.append(
|
|
365
|
-
emit_single(
|
|
366
|
-
path,
|
|
367
|
-
repo=repo,
|
|
368
|
-
scm_call=scm_call,
|
|
369
|
-
no_network=no_network,
|
|
370
|
-
display_path=disp,
|
|
371
|
-
)
|
|
372
|
-
)
|
|
373
|
-
return actions
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
def emit_umbrella(
|
|
377
|
-
paths: list[Path],
|
|
378
|
-
*,
|
|
379
|
-
repo: str,
|
|
380
|
-
scm_call: Callable[..., Any] | None = None,
|
|
381
|
-
no_network: bool = False,
|
|
382
|
-
title: str | None = None,
|
|
383
|
-
display_paths: list[str] | None = None,
|
|
384
|
-
) -> dict:
|
|
385
|
-
"""File ONE umbrella issue tracking the matched vBRIEFs.
|
|
386
|
-
|
|
387
|
-
Writes the umbrella URL back into every matched vBRIEF that does not
|
|
388
|
-
already carry a github-issue reference. When EVERY matched vBRIEF is
|
|
389
|
-
already tracked, the umbrella is treated as a no-op (``"skipped"``) so a
|
|
390
|
-
re-run does not file a duplicate roadmap issue.
|
|
391
|
-
|
|
392
|
-
Returns an action dict with ``result`` one of ``"created"`` /
|
|
393
|
-
``"dryrun"`` / ``"skipped"`` and a ``vbriefs`` list of per-file outcomes.
|
|
394
|
-
"""
|
|
395
|
-
shown = display_paths or [str(p) for p in paths]
|
|
396
|
-
loaded: list[tuple[Path, str, dict]] = []
|
|
397
|
-
for path, disp in zip(paths, shown, strict=True):
|
|
398
|
-
loaded.append((path, disp, load_vbrief(path)))
|
|
399
|
-
|
|
400
|
-
pending = [
|
|
401
|
-
(path, disp, data) for path, disp, data in loaded if existing_github_issue_ref(data) is None
|
|
402
|
-
]
|
|
403
|
-
already = [
|
|
404
|
-
{"vbrief": disp, "result": "skipped"}
|
|
405
|
-
for path, disp, data in loaded
|
|
406
|
-
if existing_github_issue_ref(data) is not None
|
|
407
|
-
]
|
|
408
|
-
|
|
409
|
-
umbrella_title = title or _default_umbrella_title(loaded)
|
|
410
|
-
|
|
411
|
-
if not pending:
|
|
412
|
-
return {
|
|
413
|
-
"result": "skipped",
|
|
414
|
-
"url": None,
|
|
415
|
-
"title": umbrella_title,
|
|
416
|
-
"vbriefs": already,
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
if no_network:
|
|
420
|
-
return {
|
|
421
|
-
"result": "dryrun",
|
|
422
|
-
"url": None,
|
|
423
|
-
"title": umbrella_title,
|
|
424
|
-
"vbriefs": [{"vbrief": disp, "result": "dryrun"} for _path, disp, _data in pending]
|
|
425
|
-
+ already,
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
body = render_umbrella_body([(disp, data) for _path, disp, data in pending])
|
|
429
|
-
url = file_issue(repo, umbrella_title, body, scm_call=scm_call)
|
|
430
|
-
|
|
431
|
-
written: list[dict] = []
|
|
432
|
-
for path, disp, data in pending:
|
|
433
|
-
add_github_issue_reference(data, url)
|
|
434
|
-
write_vbrief(path, data)
|
|
435
|
-
written.append({"vbrief": disp, "result": "created"})
|
|
436
|
-
|
|
437
|
-
return {
|
|
438
|
-
"result": "created",
|
|
439
|
-
"url": url,
|
|
440
|
-
"title": umbrella_title,
|
|
441
|
-
"vbriefs": written + already,
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
def _default_umbrella_title(loaded: list[tuple[Path, str, dict]]) -> str:
|
|
446
|
-
"""Synthesise an umbrella title when the caller did not supply one."""
|
|
447
|
-
count = len(loaded)
|
|
448
|
-
noun = "vBRIEF" if count == 1 else "vBRIEFs"
|
|
449
|
-
return f"Umbrella: {count} tracked {noun}"
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
# --- Path expansion ---------------------------------------------------------
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def expand_patterns(patterns: list[str], *, root: Path | None = None) -> list[Path]:
|
|
456
|
-
"""Expand glob ``patterns`` into a de-duplicated, ordered list of paths.
|
|
457
|
-
|
|
458
|
-
Patterns are resolved relative to ``root`` (the project root) when given
|
|
459
|
-
and not already absolute. A pattern with no glob matches that names an
|
|
460
|
-
existing file is taken literally so ``emit <one-file>`` works without a
|
|
461
|
-
wildcard. Document order is preserved; duplicates are dropped.
|
|
462
|
-
"""
|
|
463
|
-
seen: set[str] = set()
|
|
464
|
-
out: list[Path] = []
|
|
465
|
-
for pattern in patterns:
|
|
466
|
-
candidate = pattern
|
|
467
|
-
if root is not None and not os.path.isabs(pattern):
|
|
468
|
-
candidate = str(root / pattern)
|
|
469
|
-
matches = sorted(globlib.glob(candidate))
|
|
470
|
-
if not matches and Path(candidate).exists():
|
|
471
|
-
matches = [candidate]
|
|
472
|
-
for match in matches:
|
|
473
|
-
resolved = str(Path(match).resolve())
|
|
474
|
-
if resolved in seen:
|
|
475
|
-
continue
|
|
476
|
-
seen.add(resolved)
|
|
477
|
-
out.append(Path(match))
|
|
478
|
-
return out
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
# --- CLI --------------------------------------------------------------------
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def build_parser() -> argparse.ArgumentParser:
|
|
485
|
-
parser = argparse.ArgumentParser(
|
|
486
|
-
description=(
|
|
487
|
-
"File GitHub issue(s) from scope vBRIEFs and record the issue "
|
|
488
|
-
"URL back into each vBRIEF's references[] (#1274 Change 2)."
|
|
489
|
-
),
|
|
490
|
-
)
|
|
491
|
-
parser.add_argument(
|
|
492
|
-
"patterns",
|
|
493
|
-
nargs="*",
|
|
494
|
-
help="vBRIEF path (single mode) or glob(s) (--umbrella / --per-vbrief)",
|
|
495
|
-
)
|
|
496
|
-
mode = parser.add_mutually_exclusive_group()
|
|
497
|
-
mode.add_argument(
|
|
498
|
-
"--umbrella",
|
|
499
|
-
action="store_true",
|
|
500
|
-
help="File ONE umbrella issue with a checklist of matched vBRIEFs",
|
|
501
|
-
)
|
|
502
|
-
mode.add_argument(
|
|
503
|
-
"--per-vbrief",
|
|
504
|
-
action="store_true",
|
|
505
|
-
help="File one issue per matched vBRIEF",
|
|
506
|
-
)
|
|
507
|
-
parser.add_argument(
|
|
508
|
-
"--title",
|
|
509
|
-
default=None,
|
|
510
|
-
help="Umbrella issue title (--umbrella mode only)",
|
|
511
|
-
)
|
|
512
|
-
parser.add_argument(
|
|
513
|
-
"--dry-run",
|
|
514
|
-
action="store_true",
|
|
515
|
-
help="Print the plan without filing issues or mutating vBRIEFs",
|
|
516
|
-
)
|
|
517
|
-
parser.add_argument(
|
|
518
|
-
"--json",
|
|
519
|
-
action="store_true",
|
|
520
|
-
help="Emit a machine-readable JSON summary instead of prose",
|
|
521
|
-
)
|
|
522
|
-
parser.add_argument(
|
|
523
|
-
"--repo",
|
|
524
|
-
default=None,
|
|
525
|
-
help="GitHub repo OWNER/REPO (highest precedence; beats env / git remote)",
|
|
526
|
-
)
|
|
527
|
-
parser.add_argument(
|
|
528
|
-
"--project-root",
|
|
529
|
-
default=None,
|
|
530
|
-
help="Consumer project root used for repo detection and glob anchoring",
|
|
531
|
-
)
|
|
532
|
-
return parser
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def _is_no_network(dry_run: bool) -> bool:
|
|
536
|
-
"""Return True when network access is disabled (dry-run or env opt-out)."""
|
|
537
|
-
return dry_run or os.environ.get("DEFT_NO_NETWORK") == "1"
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
def _print_summary(summary: dict, *, as_json: bool) -> None:
|
|
541
|
-
if as_json:
|
|
542
|
-
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
543
|
-
return
|
|
544
|
-
mode = summary["mode"]
|
|
545
|
-
no_network = summary["no_network"]
|
|
546
|
-
banner = "issue:emit plan (no network)" if no_network else "issue:emit"
|
|
547
|
-
print(f"{banner} -- mode: {mode}")
|
|
548
|
-
if mode == "umbrella":
|
|
549
|
-
action = summary["umbrella"]
|
|
550
|
-
verb = {
|
|
551
|
-
"created": "FILED umbrella",
|
|
552
|
-
"dryrun": "WOULD FILE umbrella",
|
|
553
|
-
"skipped": "SKIP umbrella (already tracked)",
|
|
554
|
-
}[action["result"]]
|
|
555
|
-
url = f" -> {action['url']}" if action.get("url") else ""
|
|
556
|
-
print(f" {verb}: {action['title']}{url}")
|
|
557
|
-
for child in action["vbriefs"]:
|
|
558
|
-
print(f" - {child['result'].upper():8} {child['vbrief']}")
|
|
559
|
-
else:
|
|
560
|
-
for action in summary["actions"]:
|
|
561
|
-
verb = {
|
|
562
|
-
"created": "FILED",
|
|
563
|
-
"dryrun": "WOULD FILE",
|
|
564
|
-
"skipped": "SKIP (already tracked)",
|
|
565
|
-
}[action["result"]]
|
|
566
|
-
url = f" -> {action['url']}" if action.get("url") else ""
|
|
567
|
-
print(f" {verb:22} {action['vbrief']}{url}")
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
def main(argv: list[str] | None = None) -> int:
|
|
571
|
-
parser = build_parser()
|
|
572
|
-
args = parser.parse_args(argv)
|
|
573
|
-
|
|
574
|
-
if not args.patterns:
|
|
575
|
-
parser.error("Provide a vBRIEF path or glob(s) to emit")
|
|
576
|
-
|
|
577
|
-
if args.title and not args.umbrella:
|
|
578
|
-
parser.error("--title is only valid with --umbrella")
|
|
579
|
-
|
|
580
|
-
project_root = resolve_project_root(args.project_root)
|
|
581
|
-
paths = expand_patterns(args.patterns, root=project_root)
|
|
582
|
-
if not paths:
|
|
583
|
-
print(
|
|
584
|
-
f"Error: no vBRIEF files matched {args.patterns!r}.",
|
|
585
|
-
file=sys.stderr,
|
|
586
|
-
)
|
|
587
|
-
return 2
|
|
588
|
-
|
|
589
|
-
no_network = _is_no_network(args.dry_run)
|
|
590
|
-
|
|
591
|
-
# Repo is only required for a real (network) filing. Dry-run / no-network
|
|
592
|
-
# plans never call the forge, so a missing repo is not fatal there.
|
|
593
|
-
repo = ""
|
|
594
|
-
if not no_network:
|
|
595
|
-
repo = resolve_project_repo(args.repo, project_root=project_root) or ""
|
|
596
|
-
if not repo:
|
|
597
|
-
print(
|
|
598
|
-
"Error: could not detect repo. Pass --repo OWNER/NAME, set "
|
|
599
|
-
"$DEFT_PROJECT_REPO, or run from the consumer repo (#538).",
|
|
600
|
-
file=sys.stderr,
|
|
601
|
-
)
|
|
602
|
-
return 2
|
|
603
|
-
|
|
604
|
-
display = [_display_path(p, project_root) for p in paths]
|
|
605
|
-
|
|
606
|
-
try:
|
|
607
|
-
if args.umbrella:
|
|
608
|
-
action = emit_umbrella(
|
|
609
|
-
paths,
|
|
610
|
-
repo=repo,
|
|
611
|
-
no_network=no_network,
|
|
612
|
-
title=args.title,
|
|
613
|
-
display_paths=display,
|
|
614
|
-
)
|
|
615
|
-
summary = {
|
|
616
|
-
"mode": "umbrella",
|
|
617
|
-
"no_network": no_network,
|
|
618
|
-
"umbrella": action,
|
|
619
|
-
}
|
|
620
|
-
elif args.per_vbrief:
|
|
621
|
-
actions = emit_per_vbrief(
|
|
622
|
-
paths,
|
|
623
|
-
repo=repo,
|
|
624
|
-
no_network=no_network,
|
|
625
|
-
display_paths=display,
|
|
626
|
-
)
|
|
627
|
-
summary = {
|
|
628
|
-
"mode": "per-vbrief",
|
|
629
|
-
"no_network": no_network,
|
|
630
|
-
"actions": actions,
|
|
631
|
-
}
|
|
632
|
-
else:
|
|
633
|
-
if len(paths) != 1:
|
|
634
|
-
print(
|
|
635
|
-
"Error: single mode expects exactly one vBRIEF; matched "
|
|
636
|
-
f"{len(paths)}. Use --umbrella or --per-vbrief for globs.",
|
|
637
|
-
file=sys.stderr,
|
|
638
|
-
)
|
|
639
|
-
return 2
|
|
640
|
-
action = emit_single(
|
|
641
|
-
paths[0],
|
|
642
|
-
repo=repo,
|
|
643
|
-
no_network=no_network,
|
|
644
|
-
display_path=display[0],
|
|
645
|
-
)
|
|
646
|
-
summary = {
|
|
647
|
-
"mode": "single",
|
|
648
|
-
"no_network": no_network,
|
|
649
|
-
"actions": [action],
|
|
650
|
-
}
|
|
651
|
-
except IssueEmitError as exc:
|
|
652
|
-
print(f"Error: {exc}", file=sys.stderr)
|
|
653
|
-
return 2
|
|
654
|
-
|
|
655
|
-
_print_summary(summary, as_json=args.json)
|
|
656
|
-
return 0
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
def _display_path(path: Path, project_root: Path | None) -> str:
|
|
660
|
-
"""Return ``path`` relative to the project root when possible."""
|
|
661
|
-
if project_root is not None:
|
|
662
|
-
try:
|
|
663
|
-
return str(path.resolve().relative_to(project_root.resolve()))
|
|
664
|
-
except ValueError:
|
|
665
|
-
pass
|
|
666
|
-
return str(path)
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
if __name__ == "__main__":
|
|
670
|
-
raise SystemExit(main())
|