@deftai/directive-content 0.59.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 +48 -58
- 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/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/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- 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 -2552
- 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
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""vbrief_reconcile_labels.py -- SCM label reconciliation (#1288).
|
|
3
|
-
|
|
4
|
-
The reverse-direction companion to ``task vbrief:reconcile:graph`` (#1287):
|
|
5
|
-
where the graph walker promotes proposed/ vBRIEFs as their dependencies
|
|
6
|
-
clear, this verb (``task vbrief:reconcile:labels``) keeps the *forge*
|
|
7
|
-
label surface in sync with canonical vBRIEF state so reviewers never see
|
|
8
|
-
drift between an issue's labels and its lifecycle.
|
|
9
|
-
|
|
10
|
-
Mapping table (canonical vBRIEF state -> managed labels):
|
|
11
|
-
|
|
12
|
-
* ``plan.status == "blocked"`` OR any unresolved
|
|
13
|
-
``plan.metadata.swarm.depends_on[]`` entry -> ``status:blocked``
|
|
14
|
-
* ``plan.metadata.kind == "epic"`` -> ``epic`` + ``status:tracker``
|
|
15
|
-
* ``plan.metadata.kind == "research"`` -> ``rfc``
|
|
16
|
-
|
|
17
|
-
A dependency is *unresolved* when the brief it names does not (yet) live
|
|
18
|
-
in a terminal lifecycle folder (``completed/`` or ``cancelled/``) -- the
|
|
19
|
-
exact same resolution rule the #1287 graph walker uses, reused here via
|
|
20
|
-
:data:`vbrief_reconcile_graph.RESOLVED_FOLDERS` /
|
|
21
|
-
:func:`vbrief_reconcile_graph._dep_resolved`.
|
|
22
|
-
|
|
23
|
-
Design contract:
|
|
24
|
-
|
|
25
|
-
* **Mirror, don't accumulate.** The verb manages exactly the four labels
|
|
26
|
-
in :data:`MANAGED_LABELS`. On each run it ADDS the managed labels the
|
|
27
|
-
mapping currently demands and REMOVES managed labels that no longer
|
|
28
|
-
apply. Labels outside the managed set (``bug``, ``priority:high``, ...)
|
|
29
|
-
are never touched.
|
|
30
|
-
* **Forge-agnostic.** Every forge call routes through ``scripts/scm.py``
|
|
31
|
-
(#1145) via :func:`scm.call`; ``task verify:scm-boundary`` enforces no
|
|
32
|
-
direct ``gh`` invocation remains. The default :class:`ScmLabelClient`
|
|
33
|
-
is the only thing that talks to the forge, and it is injectable so the
|
|
34
|
-
test suite never makes a live ``gh`` call.
|
|
35
|
-
* **Idempotent.** A second run is a no-op: the first run already brought
|
|
36
|
-
the label set to the desired state, so the computed add/remove diff is
|
|
37
|
-
empty and no mutation fires.
|
|
38
|
-
|
|
39
|
-
Exit codes (three-state, mirrors ``scripts/vbrief_reconcile_graph.py``):
|
|
40
|
-
|
|
41
|
-
0 -- ran successfully (zero or more labels reconciled).
|
|
42
|
-
1 -- one or more per-issue forge calls failed.
|
|
43
|
-
2 -- usage / config error (no ``vbrief/`` directory under
|
|
44
|
-
``--project-root``).
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
from __future__ import annotations
|
|
48
|
-
|
|
49
|
-
import argparse
|
|
50
|
-
import json
|
|
51
|
-
import sys
|
|
52
|
-
from collections.abc import Sequence
|
|
53
|
-
from dataclasses import dataclass, field
|
|
54
|
-
from pathlib import Path
|
|
55
|
-
from typing import Protocol
|
|
56
|
-
|
|
57
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
58
|
-
|
|
59
|
-
import scm # noqa: E402
|
|
60
|
-
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
61
|
-
from swarm_readiness import _all_scope_ids, _as_str_list # noqa: E402
|
|
62
|
-
from triage_reconcile import _extract_issue_ref # noqa: E402
|
|
63
|
-
from vbrief_reconcile_graph import _dep_resolved # noqa: E402
|
|
64
|
-
|
|
65
|
-
reconfigure_stdio()
|
|
66
|
-
|
|
67
|
-
#: Lifecycle folders whose vBRIEFs carry an actionable label state. The
|
|
68
|
-
#: mapping concepts (blocked / epic / research) are all in-flight, so the
|
|
69
|
-
#: terminal folders (completed/cancelled) are not scanned for label
|
|
70
|
-
#: application; they DO participate in dependency resolution via
|
|
71
|
-
#: :func:`_all_scope_ids` below.
|
|
72
|
-
SCAN_FOLDERS = ("proposed", "pending", "active")
|
|
73
|
-
|
|
74
|
-
#: The complete set of labels this verb owns. Only these are ever added
|
|
75
|
-
#: or removed; everything else on an issue is left untouched.
|
|
76
|
-
MANAGED_LABELS = ("status:blocked", "epic", "status:tracker", "rfc")
|
|
77
|
-
|
|
78
|
-
#: scm.call source identity (#1145). v1 supports only github-issue.
|
|
79
|
-
SCM_SOURCE = "github-issue"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class ScmLabelError(RuntimeError):
|
|
83
|
-
"""Raised when a forge label read / mutation fails."""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
# ---------------------------------------------------------------------------
|
|
87
|
-
# Mapping
|
|
88
|
-
# ---------------------------------------------------------------------------
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def compute_desired_labels(plan: dict, *, unresolved_deps: bool) -> set[str]:
|
|
92
|
-
"""Return the managed labels the mapping table demands for *plan*.
|
|
93
|
-
|
|
94
|
-
The result is always a subset of :data:`MANAGED_LABELS`. ``epic`` and
|
|
95
|
-
``research`` are mutually exclusive ``kind`` values, so the kind arm
|
|
96
|
-
uses ``elif``; ``status:blocked`` is orthogonal (a blocked epic gets
|
|
97
|
-
all three).
|
|
98
|
-
"""
|
|
99
|
-
desired: set[str] = set()
|
|
100
|
-
status = plan.get("status")
|
|
101
|
-
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
|
|
102
|
-
kind = metadata.get("kind")
|
|
103
|
-
if status == "blocked" or unresolved_deps:
|
|
104
|
-
desired.add("status:blocked")
|
|
105
|
-
if kind == "epic":
|
|
106
|
-
desired.update(("epic", "status:tracker"))
|
|
107
|
-
elif kind == "research":
|
|
108
|
-
desired.add("rfc")
|
|
109
|
-
return desired
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
# ---------------------------------------------------------------------------
|
|
113
|
-
# Forge client (injectable)
|
|
114
|
-
# ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
class LabelClient(Protocol):
|
|
118
|
-
"""The seam the reconciler talks to. Tests inject an in-memory fake."""
|
|
119
|
-
|
|
120
|
-
def fetch_labels(self, repo: str, issue_number: int) -> list[str]:
|
|
121
|
-
...
|
|
122
|
-
|
|
123
|
-
def apply(
|
|
124
|
-
self,
|
|
125
|
-
repo: str,
|
|
126
|
-
issue_number: int,
|
|
127
|
-
add: Sequence[str],
|
|
128
|
-
remove: Sequence[str],
|
|
129
|
-
) -> None:
|
|
130
|
-
...
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
class ScmLabelClient:
|
|
134
|
-
"""Forge-backed label client routing every call through ``scripts/scm.py``.
|
|
135
|
-
|
|
136
|
-
Both the read (``issue view --json labels``) and the mutation
|
|
137
|
-
(``issue edit --add-label/--remove-label``) go through
|
|
138
|
-
:func:`scm.call` with ``source="github-issue"`` so the #1145 scm
|
|
139
|
-
boundary is honoured -- ``task verify:scm-boundary`` flags any direct
|
|
140
|
-
``gh`` invocation, and this client deliberately has none.
|
|
141
|
-
"""
|
|
142
|
-
|
|
143
|
-
def fetch_labels(self, repo: str, issue_number: int) -> list[str]:
|
|
144
|
-
proc = scm.call(
|
|
145
|
-
SCM_SOURCE,
|
|
146
|
-
"issue",
|
|
147
|
-
["view", str(issue_number), "--repo", repo, "--json", "labels"],
|
|
148
|
-
)
|
|
149
|
-
if proc.returncode != 0:
|
|
150
|
-
raise ScmLabelError(
|
|
151
|
-
f"issue view #{issue_number} ({repo}) failed: "
|
|
152
|
-
f"{(proc.stderr or '').strip()}"
|
|
153
|
-
)
|
|
154
|
-
try:
|
|
155
|
-
data = json.loads(proc.stdout or "{}")
|
|
156
|
-
except json.JSONDecodeError as exc:
|
|
157
|
-
raise ScmLabelError(
|
|
158
|
-
f"issue view #{issue_number} ({repo}) returned non-JSON: {exc}"
|
|
159
|
-
) from exc
|
|
160
|
-
labels = data.get("labels") if isinstance(data, dict) else None
|
|
161
|
-
if not isinstance(labels, list):
|
|
162
|
-
return []
|
|
163
|
-
names: list[str] = []
|
|
164
|
-
for entry in labels:
|
|
165
|
-
if isinstance(entry, dict) and isinstance(entry.get("name"), str):
|
|
166
|
-
names.append(entry["name"])
|
|
167
|
-
elif isinstance(entry, str):
|
|
168
|
-
names.append(entry)
|
|
169
|
-
return names
|
|
170
|
-
|
|
171
|
-
def apply(
|
|
172
|
-
self,
|
|
173
|
-
repo: str,
|
|
174
|
-
issue_number: int,
|
|
175
|
-
add: Sequence[str],
|
|
176
|
-
remove: Sequence[str],
|
|
177
|
-
) -> None:
|
|
178
|
-
args = ["edit", str(issue_number), "--repo", repo]
|
|
179
|
-
for name in add:
|
|
180
|
-
args += ["--add-label", name]
|
|
181
|
-
for name in remove:
|
|
182
|
-
args += ["--remove-label", name]
|
|
183
|
-
proc = scm.call(SCM_SOURCE, "issue", args)
|
|
184
|
-
if proc.returncode != 0:
|
|
185
|
-
raise ScmLabelError(
|
|
186
|
-
f"issue edit #{issue_number} ({repo}) failed: "
|
|
187
|
-
f"{(proc.stderr or '').strip()}"
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# ---------------------------------------------------------------------------
|
|
192
|
-
# Outcome types
|
|
193
|
-
# ---------------------------------------------------------------------------
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
@dataclass
|
|
197
|
-
class LabelChange:
|
|
198
|
-
"""A single issue's computed (and, unless dry-run, applied) label diff."""
|
|
199
|
-
|
|
200
|
-
story_id: str
|
|
201
|
-
repo: str
|
|
202
|
-
issue_number: int
|
|
203
|
-
current: list[str]
|
|
204
|
-
desired: list[str]
|
|
205
|
-
add: list[str]
|
|
206
|
-
remove: list[str]
|
|
207
|
-
|
|
208
|
-
def to_json(self) -> dict[str, object]:
|
|
209
|
-
return {
|
|
210
|
-
"story_id": self.story_id,
|
|
211
|
-
"repo": self.repo,
|
|
212
|
-
"issue_number": self.issue_number,
|
|
213
|
-
"current": list(self.current),
|
|
214
|
-
"desired": list(self.desired),
|
|
215
|
-
"add": list(self.add),
|
|
216
|
-
"remove": list(self.remove),
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
@dataclass
|
|
221
|
-
class ReconcileLabelsOutcome:
|
|
222
|
-
"""Aggregate result of a single label-reconcile run."""
|
|
223
|
-
|
|
224
|
-
changed: list[LabelChange] = field(default_factory=list)
|
|
225
|
-
unchanged: list[LabelChange] = field(default_factory=list)
|
|
226
|
-
skipped_no_ref: list[str] = field(default_factory=list)
|
|
227
|
-
errors: list[tuple[str, str]] = field(default_factory=list)
|
|
228
|
-
dry_run: bool = False
|
|
229
|
-
|
|
230
|
-
def to_json(self) -> dict[str, object]:
|
|
231
|
-
return {
|
|
232
|
-
"changed": [c.to_json() for c in self.changed],
|
|
233
|
-
"unchanged": [c.to_json() for c in self.unchanged],
|
|
234
|
-
"skipped_no_ref": list(self.skipped_no_ref),
|
|
235
|
-
"errors": [{"story_id": sid, "message": msg} for sid, msg in self.errors],
|
|
236
|
-
"dry_run": self.dry_run,
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
# ---------------------------------------------------------------------------
|
|
241
|
-
# Core reconcile logic
|
|
242
|
-
# ---------------------------------------------------------------------------
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _unresolved_deps(
|
|
246
|
-
swarm: dict,
|
|
247
|
-
known_ids: dict[str, tuple[Path, str]],
|
|
248
|
-
) -> bool:
|
|
249
|
-
"""True when any ``depends_on`` entry has NOT resolved to a terminal folder.
|
|
250
|
-
|
|
251
|
-
Reuses :func:`vbrief_reconcile_graph._dep_resolved` so "resolved"
|
|
252
|
-
means exactly what the #1287 graph walker means: the named brief
|
|
253
|
-
lives in ``completed/`` or ``cancelled/``. An unknown dependency id
|
|
254
|
-
counts as unresolved (the dependent is still blocked on it).
|
|
255
|
-
"""
|
|
256
|
-
return any(
|
|
257
|
-
not _dep_resolved(dep, known_ids)
|
|
258
|
-
for dep in _as_str_list(swarm.get("depends_on"))
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def reconcile_labels(
|
|
263
|
-
project_root: Path,
|
|
264
|
-
*,
|
|
265
|
-
repo: str | None = None,
|
|
266
|
-
dry_run: bool = False,
|
|
267
|
-
client: LabelClient | None = None,
|
|
268
|
-
) -> tuple[int, ReconcileLabelsOutcome]:
|
|
269
|
-
"""Reconcile managed SCM labels against canonical vBRIEF state.
|
|
270
|
-
|
|
271
|
-
Walks :data:`SCAN_FOLDERS`, resolves each brief's linked issue from
|
|
272
|
-
its ``x-vbrief/github-issue`` reference (falling back to *repo* when
|
|
273
|
-
the reference URI lacks an owner/name segment), computes the
|
|
274
|
-
add/remove diff against :data:`MANAGED_LABELS`, and applies it via
|
|
275
|
-
*client* (unless *dry_run*). Returns ``(exit_code, outcome)``.
|
|
276
|
-
"""
|
|
277
|
-
vbrief_dir = project_root / "vbrief"
|
|
278
|
-
if not vbrief_dir.is_dir():
|
|
279
|
-
return 2, ReconcileLabelsOutcome(dry_run=dry_run)
|
|
280
|
-
|
|
281
|
-
if client is None:
|
|
282
|
-
client = ScmLabelClient()
|
|
283
|
-
|
|
284
|
-
known_ids = _all_scope_ids(project_root)
|
|
285
|
-
outcome = ReconcileLabelsOutcome(dry_run=dry_run)
|
|
286
|
-
seen_issues: set[tuple[str, int]] = set()
|
|
287
|
-
|
|
288
|
-
for folder in SCAN_FOLDERS:
|
|
289
|
-
folder_path = vbrief_dir / folder
|
|
290
|
-
if not folder_path.is_dir():
|
|
291
|
-
continue
|
|
292
|
-
for path in sorted(folder_path.glob("*.vbrief.json")):
|
|
293
|
-
try:
|
|
294
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
295
|
-
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
296
|
-
continue
|
|
297
|
-
if not isinstance(data, dict):
|
|
298
|
-
continue
|
|
299
|
-
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
|
|
300
|
-
story_id = str(plan.get("id") or path.name[: -len(".vbrief.json")])
|
|
301
|
-
|
|
302
|
-
ref_repo, number = _extract_issue_ref(data)
|
|
303
|
-
effective_repo = ref_repo or repo
|
|
304
|
-
if number is None or effective_repo is None:
|
|
305
|
-
outcome.skipped_no_ref.append(story_id)
|
|
306
|
-
continue
|
|
307
|
-
key = (effective_repo, number)
|
|
308
|
-
if key in seen_issues:
|
|
309
|
-
continue
|
|
310
|
-
seen_issues.add(key)
|
|
311
|
-
|
|
312
|
-
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
|
|
313
|
-
swarm = metadata.get("swarm") if isinstance(metadata.get("swarm"), dict) else {}
|
|
314
|
-
desired = compute_desired_labels(
|
|
315
|
-
plan, unresolved_deps=_unresolved_deps(swarm, known_ids)
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
try:
|
|
319
|
-
current = client.fetch_labels(effective_repo, number)
|
|
320
|
-
except ScmLabelError as exc:
|
|
321
|
-
outcome.errors.append((story_id, str(exc)))
|
|
322
|
-
continue
|
|
323
|
-
|
|
324
|
-
current_managed = {name for name in current if name in MANAGED_LABELS}
|
|
325
|
-
add = sorted(desired - current_managed)
|
|
326
|
-
remove = sorted(current_managed - desired)
|
|
327
|
-
change = LabelChange(
|
|
328
|
-
story_id=story_id,
|
|
329
|
-
repo=effective_repo,
|
|
330
|
-
issue_number=number,
|
|
331
|
-
current=sorted(current),
|
|
332
|
-
desired=sorted(desired),
|
|
333
|
-
add=add,
|
|
334
|
-
remove=remove,
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
if not add and not remove:
|
|
338
|
-
outcome.unchanged.append(change)
|
|
339
|
-
continue
|
|
340
|
-
if dry_run:
|
|
341
|
-
outcome.changed.append(change)
|
|
342
|
-
continue
|
|
343
|
-
try:
|
|
344
|
-
client.apply(effective_repo, number, add, remove)
|
|
345
|
-
except ScmLabelError as exc:
|
|
346
|
-
outcome.errors.append((story_id, str(exc)))
|
|
347
|
-
continue
|
|
348
|
-
outcome.changed.append(change)
|
|
349
|
-
|
|
350
|
-
exit_code = 1 if outcome.errors else 0
|
|
351
|
-
return exit_code, outcome
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
# ---------------------------------------------------------------------------
|
|
355
|
-
# Rendering + CLI
|
|
356
|
-
# ---------------------------------------------------------------------------
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def _render_report(outcome: ReconcileLabelsOutcome) -> str:
|
|
360
|
-
lines: list[str] = ["vBRIEF reconcile labels", ""]
|
|
361
|
-
suffix = " (dry-run)" if outcome.dry_run else ""
|
|
362
|
-
|
|
363
|
-
lines.append(f"Changed{suffix}:")
|
|
364
|
-
if outcome.changed:
|
|
365
|
-
for change in outcome.changed:
|
|
366
|
-
parts: list[str] = []
|
|
367
|
-
if change.add:
|
|
368
|
-
parts.append(f"+{', +'.join(change.add)}")
|
|
369
|
-
if change.remove:
|
|
370
|
-
parts.append(f"-{', -'.join(change.remove)}")
|
|
371
|
-
lines.append(
|
|
372
|
-
f"- #{change.issue_number} ({change.repo}) "
|
|
373
|
-
f"[{change.story_id}]: {'; '.join(parts)}"
|
|
374
|
-
)
|
|
375
|
-
else:
|
|
376
|
-
lines.append("- none")
|
|
377
|
-
lines.append("")
|
|
378
|
-
|
|
379
|
-
lines.append("Unchanged:")
|
|
380
|
-
if outcome.unchanged:
|
|
381
|
-
lines.extend(
|
|
382
|
-
f"- #{c.issue_number} ({c.repo}) [{c.story_id}]" for c in outcome.unchanged
|
|
383
|
-
)
|
|
384
|
-
else:
|
|
385
|
-
lines.append("- none")
|
|
386
|
-
|
|
387
|
-
if outcome.skipped_no_ref:
|
|
388
|
-
lines.append("")
|
|
389
|
-
lines.append("Skipped (no github-issue reference / repo):")
|
|
390
|
-
lines.extend(f"- {story_id}" for story_id in outcome.skipped_no_ref)
|
|
391
|
-
|
|
392
|
-
if outcome.errors:
|
|
393
|
-
lines.append("")
|
|
394
|
-
lines.append("Errors:")
|
|
395
|
-
lines.extend(f"- {story_id}: {message}" for story_id, message in outcome.errors)
|
|
396
|
-
|
|
397
|
-
return "\n".join(lines)
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
401
|
-
parser = argparse.ArgumentParser(
|
|
402
|
-
description=(
|
|
403
|
-
"Reconcile SCM labels to mirror canonical vBRIEF state: "
|
|
404
|
-
"status:blocked (blocked / unresolved deps), epic + status:tracker "
|
|
405
|
-
"(kind=epic), rfc (kind=research). Routes through scripts/scm.py "
|
|
406
|
-
"(#1145). Idempotent."
|
|
407
|
-
)
|
|
408
|
-
)
|
|
409
|
-
parser.add_argument(
|
|
410
|
-
"--project-root",
|
|
411
|
-
default=".",
|
|
412
|
-
help="Project root containing vbrief/ (default: current directory).",
|
|
413
|
-
)
|
|
414
|
-
parser.add_argument(
|
|
415
|
-
"--repo",
|
|
416
|
-
default=None,
|
|
417
|
-
help=(
|
|
418
|
-
"Fallback repo slug 'owner/name' used ONLY when a vBRIEF's "
|
|
419
|
-
"github-issue reference URI lacks an owner/repo segment."
|
|
420
|
-
),
|
|
421
|
-
)
|
|
422
|
-
parser.add_argument(
|
|
423
|
-
"--dry-run",
|
|
424
|
-
action="store_true",
|
|
425
|
-
help="Report which labels WOULD change without mutating any issue.",
|
|
426
|
-
)
|
|
427
|
-
parser.add_argument(
|
|
428
|
-
"--json",
|
|
429
|
-
action="store_true",
|
|
430
|
-
help="Emit a machine-readable JSON summary instead of the text report.",
|
|
431
|
-
)
|
|
432
|
-
return parser.parse_args(argv)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
def main(argv: list[str] | None = None) -> int:
|
|
436
|
-
args = _parse_args(sys.argv[1:] if argv is None else argv)
|
|
437
|
-
project_root = Path(args.project_root).resolve()
|
|
438
|
-
exit_code, outcome = reconcile_labels(
|
|
439
|
-
project_root,
|
|
440
|
-
repo=args.repo,
|
|
441
|
-
dry_run=args.dry_run,
|
|
442
|
-
)
|
|
443
|
-
if exit_code == 2:
|
|
444
|
-
if args.json:
|
|
445
|
-
print(json.dumps({"error": "no vbrief/ directory found"}))
|
|
446
|
-
else:
|
|
447
|
-
print(
|
|
448
|
-
f"Error: no vbrief/ directory found under {project_root}",
|
|
449
|
-
file=sys.stderr,
|
|
450
|
-
)
|
|
451
|
-
return 2
|
|
452
|
-
if args.json:
|
|
453
|
-
print(json.dumps(outcome.to_json(), indent=2))
|
|
454
|
-
else:
|
|
455
|
-
print(_render_report(outcome))
|
|
456
|
-
return exit_code
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if __name__ == "__main__":
|
|
460
|
-
raise SystemExit(main())
|