@deftai/directive-content 0.55.1 → 0.56.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-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +2 -2
|
@@ -0,0 +1,1442 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
reconcile_issues.py -- Reconcile GitHub issues against vBRIEF references.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
uv run python scripts/reconcile_issues.py [options]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--vbrief-dir DIR Path to vbrief/ directory
|
|
10
|
+
--repo OWNER/REPO GitHub repo
|
|
11
|
+
--format json|markdown Output format
|
|
12
|
+
--apply-lifecycle-fixes Move non-terminal closed-issue vBRIEFs
|
|
13
|
+
to completed/ (idempotent; #734)
|
|
14
|
+
--report-unlinked Emit the legacy three-section report
|
|
15
|
+
including issues with no vBRIEF (#754)
|
|
16
|
+
--max-open-issues N Safety cap for --report-unlinked path
|
|
17
|
+
(default 1000) (#754)
|
|
18
|
+
|
|
19
|
+
Reads all vBRIEF files in the lifecycle folders (proposed/, pending/, active/,
|
|
20
|
+
completed/, cancelled/) and extracts github-issue references from the
|
|
21
|
+
``references`` arrays.
|
|
22
|
+
|
|
23
|
+
Default path (#754): produces a two-section report via inverted lookup --
|
|
24
|
+
the scanner extracts the set of issue numbers referenced by vBRIEFs and
|
|
25
|
+
queries just those issues' states via batched ``gh api graphql`` (aliased
|
|
26
|
+
node queries). Cost scales by O(vBRIEF-referenced-issue-count), bounded
|
|
27
|
+
by the repo's vBRIEF count rather than total open-issue count. Sections:
|
|
28
|
+
|
|
29
|
+
(a) linked -- referenced issues with state ``OPEN``
|
|
30
|
+
(c) no_open_issue -- referenced issues with state ``CLOSED`` /
|
|
31
|
+
``NOT_FOUND`` (the apply-mode candidates)
|
|
32
|
+
|
|
33
|
+
The legacy section (b) ``unlinked`` (open issues with NO matching vBRIEF)
|
|
34
|
+
is NOT emitted in the default path because it requires fetching every
|
|
35
|
+
open issue in the repo -- which scales by O(repo-open-issue-count) and
|
|
36
|
+
caused #754's false-positive flood on a 225-open-issue repo (the prior
|
|
37
|
+
200-issue cap silently treated the tail as closed). The legacy three-
|
|
38
|
+
section report is available via ``--report-unlinked`` with a
|
|
39
|
+
``--max-open-issues`` safety cap.
|
|
40
|
+
|
|
41
|
+
When ``--apply-lifecycle-fixes`` (#734) is passed, Section (c) entries that
|
|
42
|
+
are not already in a terminal lifecycle folder (``completed/`` or
|
|
43
|
+
``cancelled/``) are auto-resolved: the vBRIEF JSON gains
|
|
44
|
+
``plan.status = "completed"``, ``vBRIEFInfo.updated`` is stamped with the
|
|
45
|
+
current UTC ISO timestamp, and the file is ``git mv``\'d (or filesystem-
|
|
46
|
+
moved) into ``completed/``. The flag is idempotent: a second run is a
|
|
47
|
+
no-op once every closed-issue vBRIEF lives in a terminal lifecycle folder.
|
|
48
|
+
Reverse mismatches (terminal vBRIEF whose issue was reopened) are
|
|
49
|
+
report-only -- never auto-reverse-moved.
|
|
50
|
+
|
|
51
|
+
Exit codes:
|
|
52
|
+
0 -- report generated successfully (or apply-mode clean / all moves OK)
|
|
53
|
+
1 -- error (missing dependencies, API failure, partial apply failure,
|
|
54
|
+
--report-unlinked over the --max-open-issues cap)
|
|
55
|
+
2 -- usage / configuration error
|
|
56
|
+
|
|
57
|
+
Story #322, RFC #309. Apply-mode: #734. Inverted-lookup scaling: #754.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
import datetime as _dt
|
|
61
|
+
import json
|
|
62
|
+
import re
|
|
63
|
+
import shutil
|
|
64
|
+
import subprocess
|
|
65
|
+
import sys
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
|
|
68
|
+
# Make sibling ``_stdio_utf8`` / ``_project_context`` importable when run
|
|
69
|
+
# as ``__main__`` and when imported by tests that preload sys.path.
|
|
70
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
71
|
+
|
|
72
|
+
from _project_context import resolve_project_repo, resolve_project_root # noqa: E402
|
|
73
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
74
|
+
|
|
75
|
+
reconfigure_stdio()
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Constants
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
LIFECYCLE_FOLDERS = ("proposed", "pending", "active", "completed", "cancelled")
|
|
82
|
+
TERMINAL_LIFECYCLE_FOLDERS: frozenset[str] = frozenset(
|
|
83
|
+
{"completed", "cancelled"}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
ISSUE_URL_PATTERN = re.compile(
|
|
87
|
+
r"https://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/issues/(?P<number>\d+)"
|
|
88
|
+
)
|
|
89
|
+
ISSUE_ID_PATTERN = re.compile(r"^#(?P<number>\d+)$")
|
|
90
|
+
|
|
91
|
+
# Reference-type strings that identify a GitHub issue origin. The migrator
|
|
92
|
+
# emits the canonical v0.6 ``x-vbrief/github-issue`` type (#613); legacy
|
|
93
|
+
# vBRIEFs produced by earlier migrator runs (or hand-authored pre-v0.20
|
|
94
|
+
# fixtures) use the bare ``github-issue`` string. Both shapes are accepted
|
|
95
|
+
# here so the reconciler stays idempotent across the transition.
|
|
96
|
+
GITHUB_ISSUE_REF_TYPES: frozenset[str] = frozenset(
|
|
97
|
+
{"github-issue", "x-vbrief/github-issue"}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# #1290: GitHub ``stateReason`` values that route a CLOSED issue's vBRIEF
|
|
101
|
+
# to ``cancelled/`` rather than ``completed/``. ``COMPLETED`` (and a null
|
|
102
|
+
# reason / ``NOT_FOUND``) route to ``completed/`` -- the pre-#1290 default.
|
|
103
|
+
CANCELLED_STATE_REASONS: frozenset[str] = frozenset({"NOT_PLANNED", "DUPLICATE"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class IssueState(str):
|
|
107
|
+
"""A ``str`` subclass carrying a GitHub issue's state plus stateReason.
|
|
108
|
+
|
|
109
|
+
Phase A of #1290 extends ``fetch_issue_states`` to also fetch each
|
|
110
|
+
issue's ``stateReason`` so apply-mode can route CLOSED+NOT_PLANNED /
|
|
111
|
+
CLOSED+DUPLICATE to ``cancelled/`` while CLOSED+COMPLETED stays in
|
|
112
|
+
``completed/``. To avoid breaking the many existing callers (and
|
|
113
|
+
tests) that compare the return value directly to the bare strings
|
|
114
|
+
``"OPEN"`` / ``"CLOSED"`` / ``"NOT_FOUND"`` -- including
|
|
115
|
+
``scripts/release.py::check_vbrief_lifecycle_sync`` -- the value is a
|
|
116
|
+
``str`` subclass: it still ``==`` the bare state string, so legacy
|
|
117
|
+
code keeps working unchanged, while new code reads ``.state_reason``.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
state_reason: str | None
|
|
121
|
+
|
|
122
|
+
def __new__(cls, state: str, state_reason: str | None = None) -> "IssueState":
|
|
123
|
+
obj = super().__new__(cls, state)
|
|
124
|
+
obj.state_reason = state_reason
|
|
125
|
+
return obj
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def state_reason_of(value: object) -> str | None:
|
|
129
|
+
"""Return the ``stateReason`` carried by a state-map value, or None.
|
|
130
|
+
|
|
131
|
+
Thin accessor so callers that hold a state-map value (which may be a
|
|
132
|
+
plain ``str`` from a legacy/monkeypatched fetch or an ``IssueState``
|
|
133
|
+
from the real fetch) can read the reason without an ``isinstance``
|
|
134
|
+
dance. Returns ``None`` for plain strings / missing values (#1290).
|
|
135
|
+
"""
|
|
136
|
+
return getattr(value, "state_reason", None)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_terminal_lifecycle_path(rel_path: str) -> bool:
|
|
140
|
+
"""Return True when a vBRIEF relative path is already terminal."""
|
|
141
|
+
folder, sep, _filename = rel_path.partition("/")
|
|
142
|
+
return sep == "/" and folder in TERMINAL_LIFECYCLE_FOLDERS
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# vBRIEF scanning
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extract_references_from_vbrief(data: dict) -> list[dict]:
|
|
151
|
+
"""Extract all references from a vBRIEF data structure.
|
|
152
|
+
|
|
153
|
+
Walks plan.references and each item's references recursively.
|
|
154
|
+
"""
|
|
155
|
+
refs: list[dict] = []
|
|
156
|
+
plan = data.get("plan", {})
|
|
157
|
+
|
|
158
|
+
# Top-level plan references
|
|
159
|
+
for ref in plan.get("references", []):
|
|
160
|
+
if isinstance(ref, dict):
|
|
161
|
+
refs.append(ref)
|
|
162
|
+
|
|
163
|
+
# Item-level references (and nested subItems). Every container access
|
|
164
|
+
# uses ``... or []`` rather than ``.get(key, [])``: a key present with an
|
|
165
|
+
# explicit JSON ``null`` value returns ``None`` from ``.get(key, [])``
|
|
166
|
+
# (the default only fires for ABSENT keys), and ``for x in None`` raises
|
|
167
|
+
# ``TypeError`` (#924).
|
|
168
|
+
def _walk_items(items: list | None) -> None:
|
|
169
|
+
for item in items or []:
|
|
170
|
+
if not isinstance(item, dict):
|
|
171
|
+
continue
|
|
172
|
+
for ref in item.get("references") or []:
|
|
173
|
+
if isinstance(ref, dict):
|
|
174
|
+
refs.append(ref)
|
|
175
|
+
_walk_items(item.get("subItems") or [])
|
|
176
|
+
_walk_items(item.get("items") or [])
|
|
177
|
+
|
|
178
|
+
_walk_items(plan.get("items") or [])
|
|
179
|
+
return refs
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_issue_number(ref: dict) -> int | None:
|
|
183
|
+
"""Extract a GitHub issue number from a vBRIEF reference dict.
|
|
184
|
+
|
|
185
|
+
Accepts both the canonical v0.6 shape ``{uri, type, title}`` (#613) and
|
|
186
|
+
the legacy pre-v0.20 shapes ``{type, url}`` / ``{type, id}`` so mixed-
|
|
187
|
+
shape trees (projects partway through the migrator flip) reconcile
|
|
188
|
+
cleanly. The URL-bearing keys (``uri`` and ``url``) are searched first
|
|
189
|
+
because they disambiguate the owner/repo; ``id`` is the last-resort
|
|
190
|
+
fallback used by the legacy migrator output.
|
|
191
|
+
"""
|
|
192
|
+
for key in ("uri", "url"):
|
|
193
|
+
value = ref.get(key, "")
|
|
194
|
+
if isinstance(value, str) and value:
|
|
195
|
+
m = ISSUE_URL_PATTERN.search(value)
|
|
196
|
+
if m:
|
|
197
|
+
return int(m.group("number"))
|
|
198
|
+
|
|
199
|
+
ref_id = ref.get("id", "")
|
|
200
|
+
if isinstance(ref_id, str):
|
|
201
|
+
m = ISSUE_ID_PATTERN.match(ref_id)
|
|
202
|
+
if m:
|
|
203
|
+
return int(m.group("number"))
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def scan_vbrief_dir(vbrief_dir: Path) -> dict[int, list[str]]:
|
|
208
|
+
"""Scan all lifecycle folders for vBRIEF files and extract issue references.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Mapping of issue_number -> list of vBRIEF file paths (relative to vbrief_dir).
|
|
212
|
+
"""
|
|
213
|
+
issue_to_vbriefs: dict[int, list[str]] = {}
|
|
214
|
+
|
|
215
|
+
for folder in LIFECYCLE_FOLDERS:
|
|
216
|
+
folder_path = vbrief_dir / folder
|
|
217
|
+
if not folder_path.is_dir():
|
|
218
|
+
continue
|
|
219
|
+
for vbrief_file in sorted(folder_path.glob("*.vbrief.json")):
|
|
220
|
+
try:
|
|
221
|
+
data = json.loads(vbrief_file.read_text(encoding="utf-8"))
|
|
222
|
+
except (json.JSONDecodeError, OSError):
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
refs = extract_references_from_vbrief(data)
|
|
226
|
+
rel_path = f"{folder}/{vbrief_file.name}"
|
|
227
|
+
for ref in refs:
|
|
228
|
+
# #613: accept both the canonical v0.6 type
|
|
229
|
+
# (``x-vbrief/github-issue``) and the legacy bare
|
|
230
|
+
# ``github-issue`` so scans over partially-migrated
|
|
231
|
+
# trees find every GitHub-issue origin.
|
|
232
|
+
if ref.get("type") not in GITHUB_ISSUE_REF_TYPES:
|
|
233
|
+
continue
|
|
234
|
+
num = parse_issue_number(ref)
|
|
235
|
+
if num is not None:
|
|
236
|
+
issue_to_vbriefs.setdefault(num, []).append(rel_path)
|
|
237
|
+
|
|
238
|
+
return issue_to_vbriefs
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# GitHub issue fetching
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
ISSUE_FETCH_LIMIT = 1000
|
|
247
|
+
|
|
248
|
+
# #754: GraphQL aliased-node batch size for ``fetch_issue_states``. GitHub's
|
|
249
|
+
# GraphQL ceiling is ~500 nodes per query; 200 keeps each query well under
|
|
250
|
+
# the limit and bounds query body size for repos with very large vBRIEF
|
|
251
|
+
# counts.
|
|
252
|
+
GRAPHQL_BATCH_SIZE = 200
|
|
253
|
+
|
|
254
|
+
# #754: paginated all-open-issues fetch limit for the ``--report-unlinked``
|
|
255
|
+
# opt-in path. ``gh issue list --limit 0`` fetches every open issue via
|
|
256
|
+
# native pagination (no per_page cap). Default operator-facing safety cap
|
|
257
|
+
# is 1000 -- raised via ``--max-open-issues N`` when the operator has
|
|
258
|
+
# acknowledged the cost.
|
|
259
|
+
DEFAULT_MAX_OPEN_ISSUES = 1000
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def fetch_open_issues(repo: str, cwd: Path | None = None) -> list[dict] | None:
|
|
263
|
+
"""Fetch open issues from GitHub using gh CLI.
|
|
264
|
+
|
|
265
|
+
Retained for the opt-in ``--report-unlinked`` path; the release-pipeline
|
|
266
|
+
gate uses ``fetch_issue_states`` for inverted-lookup scaling (#754).
|
|
267
|
+
|
|
268
|
+
``cwd`` is passed to ``subprocess.run`` so that ``gh`` resolves its
|
|
269
|
+
auth / config from the consumer project's directory rather than
|
|
270
|
+
whichever directory the included Taskfile happens to be in (#538).
|
|
271
|
+
Explicit ``--repo`` already targets the correct repository; ``cwd``
|
|
272
|
+
is a belt-and-suspenders guard for any future path-sensitive checks.
|
|
273
|
+
|
|
274
|
+
Returns a list of dicts with keys: number, title, labels, url.
|
|
275
|
+
Returns None on error (gh not found, timeout, API failure, parse error).
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
[
|
|
280
|
+
"gh", "issue", "list",
|
|
281
|
+
"--repo", repo,
|
|
282
|
+
"--state", "open",
|
|
283
|
+
"--limit", str(ISSUE_FETCH_LIMIT),
|
|
284
|
+
"--json", "number,title,labels,url",
|
|
285
|
+
],
|
|
286
|
+
capture_output=True,
|
|
287
|
+
text=True,
|
|
288
|
+
timeout=60,
|
|
289
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
290
|
+
)
|
|
291
|
+
except FileNotFoundError:
|
|
292
|
+
print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
|
|
293
|
+
return None
|
|
294
|
+
except subprocess.TimeoutExpired:
|
|
295
|
+
print("Error: gh CLI timed out.", file=sys.stderr)
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
if result.returncode != 0:
|
|
299
|
+
print(f"Error: gh CLI failed: {result.stderr.strip()}", file=sys.stderr)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
issues: list[dict] = json.loads(result.stdout)
|
|
304
|
+
except json.JSONDecodeError:
|
|
305
|
+
print("Error: failed to parse gh CLI output.", file=sys.stderr)
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
if len(issues) >= ISSUE_FETCH_LIMIT:
|
|
309
|
+
print(
|
|
310
|
+
f"Warning: fetched {len(issues)} issues (limit {ISSUE_FETCH_LIMIT}). "
|
|
311
|
+
"Report may be incomplete.",
|
|
312
|
+
file=sys.stderr,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return issues
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def fetch_all_open_issues(
|
|
319
|
+
repo: str, cwd: Path | None = None
|
|
320
|
+
) -> list[dict] | None:
|
|
321
|
+
"""Fetch ALL open issues from GitHub using gh CLI native pagination (#754).
|
|
322
|
+
|
|
323
|
+
Used by the ``--report-unlinked`` opt-in path. Invokes
|
|
324
|
+
``gh issue list --limit 0`` which paginates internally and returns
|
|
325
|
+
every open issue regardless of count. The caller is responsible for
|
|
326
|
+
enforcing ``--max-open-issues`` after this returns.
|
|
327
|
+
|
|
328
|
+
Returns a list of dicts with keys: number, title, labels, url.
|
|
329
|
+
Returns None on error (gh not found, timeout, API failure, parse error).
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
result = subprocess.run(
|
|
333
|
+
[
|
|
334
|
+
"gh", "issue", "list",
|
|
335
|
+
"--repo", repo,
|
|
336
|
+
"--state", "open",
|
|
337
|
+
# ``--limit 0`` opts into gh's native unlimited pagination.
|
|
338
|
+
"--limit", "0",
|
|
339
|
+
"--json", "number,title,labels,url",
|
|
340
|
+
],
|
|
341
|
+
capture_output=True,
|
|
342
|
+
text=True,
|
|
343
|
+
# 5 min ceiling -- a properly-paginated fetch on a 10k-open
|
|
344
|
+
# repo completes inside this budget; anything beyond is a
|
|
345
|
+
# real auth / network failure to surface cleanly.
|
|
346
|
+
timeout=300,
|
|
347
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
348
|
+
)
|
|
349
|
+
except FileNotFoundError:
|
|
350
|
+
print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
|
|
351
|
+
return None
|
|
352
|
+
except subprocess.TimeoutExpired:
|
|
353
|
+
print("Error: gh CLI timed out.", file=sys.stderr)
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
if result.returncode != 0:
|
|
357
|
+
print(f"Error: gh CLI failed: {result.stderr.strip()}", file=sys.stderr)
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
try:
|
|
361
|
+
issues: list[dict] = json.loads(result.stdout)
|
|
362
|
+
except json.JSONDecodeError:
|
|
363
|
+
print("Error: failed to parse gh CLI output.", file=sys.stderr)
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
return issues
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _split_repo_slug(repo: str) -> tuple[str, str] | None:
|
|
370
|
+
"""Split ``OWNER/REPO`` into ``(owner, repo)``; None on malformed input."""
|
|
371
|
+
parts = repo.split("/", 1)
|
|
372
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
373
|
+
return None
|
|
374
|
+
return parts[0], parts[1]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def fetch_issue_states(
|
|
378
|
+
repo: str,
|
|
379
|
+
issue_numbers: set[int],
|
|
380
|
+
cwd: Path | None = None,
|
|
381
|
+
*,
|
|
382
|
+
batch_size: int = GRAPHQL_BATCH_SIZE,
|
|
383
|
+
) -> dict[int, IssueState] | None:
|
|
384
|
+
"""Fetch GitHub issue states via batched ``gh api graphql`` (#754).
|
|
385
|
+
|
|
386
|
+
Inverts the lookup direction relative to ``fetch_open_issues``:
|
|
387
|
+
instead of fetching every open issue in the repo and filtering for
|
|
388
|
+
the vBRIEF-referenced subset, this helper takes the subset directly
|
|
389
|
+
(``issue_numbers``) and queries the state of just those issues. The
|
|
390
|
+
cost therefore scales by ``O(len(issue_numbers))`` -- the
|
|
391
|
+
vBRIEF-referenced-issue-count -- rather than
|
|
392
|
+
``O(repo-open-issue-count)``.
|
|
393
|
+
|
|
394
|
+
Implementation: builds a GraphQL query with aliased nodes
|
|
395
|
+
(``i100: issue(number: 100) { state }``), batched at ``batch_size``
|
|
396
|
+
nodes per query (default 200; safe under GitHub's ~500 ceiling). One
|
|
397
|
+
``gh api graphql`` invocation per batch. Issues that don't exist in
|
|
398
|
+
the repo are returned as ``"NOT_FOUND"`` (the corresponding aliased
|
|
399
|
+
node is null in the GraphQL response).
|
|
400
|
+
|
|
401
|
+
``cwd`` is forwarded to ``subprocess.run`` so ``gh`` resolves its
|
|
402
|
+
auth / config from the consumer project's directory (#538
|
|
403
|
+
belt-and-suspenders).
|
|
404
|
+
|
|
405
|
+
Returns a dict mapping issue_number -> ``IssueState`` (a ``str``
|
|
406
|
+
subclass equal to ``"OPEN"`` / ``"CLOSED"`` / ``"NOT_FOUND"`` that
|
|
407
|
+
additionally carries the GitHub ``stateReason`` via
|
|
408
|
+
``.state_reason``) when every batch resolved cleanly, ``None`` on
|
|
409
|
+
subprocess error, parse error, or non-zero exit (mirrors
|
|
410
|
+
``fetch_open_issues``). An empty ``issue_numbers`` set returns an
|
|
411
|
+
empty dict (no subprocess call). #1290 added the ``stateReason``
|
|
412
|
+
selection so apply-mode can route NOT_PLANNED / DUPLICATE closures
|
|
413
|
+
to ``cancelled/``; the ``str`` subclass keeps every existing caller
|
|
414
|
+
(and the bare-string equality tests) working unchanged.
|
|
415
|
+
|
|
416
|
+
Refs #754 (inverted-lookup gate fix), #1290 (stateReason); see also
|
|
417
|
+
``reconcile()`` and ``scripts/release.py::check_vbrief_lifecycle_sync``.
|
|
418
|
+
"""
|
|
419
|
+
if not issue_numbers:
|
|
420
|
+
return {}
|
|
421
|
+
parsed = _split_repo_slug(repo)
|
|
422
|
+
if parsed is None:
|
|
423
|
+
print(
|
|
424
|
+
f"Error: invalid repo slug {repo!r}; expected OWNER/REPO.",
|
|
425
|
+
file=sys.stderr,
|
|
426
|
+
)
|
|
427
|
+
return None
|
|
428
|
+
owner, name = parsed
|
|
429
|
+
|
|
430
|
+
sorted_numbers = sorted(issue_numbers)
|
|
431
|
+
states: dict[int, IssueState] = {}
|
|
432
|
+
|
|
433
|
+
for start in range(0, len(sorted_numbers), batch_size):
|
|
434
|
+
batch = sorted_numbers[start : start + batch_size]
|
|
435
|
+
# Aliased-node block: each issue gets a unique alias (``i<N>``)
|
|
436
|
+
# so the GraphQL response carries every state in a single query.
|
|
437
|
+
# #1290: also select ``stateReason`` so apply-mode can route
|
|
438
|
+
# NOT_PLANNED / DUPLICATE closures to ``cancelled/``.
|
|
439
|
+
aliases = "\n ".join(
|
|
440
|
+
f"i{n}: issue(number: {n}) {{ state stateReason }}" for n in batch
|
|
441
|
+
)
|
|
442
|
+
query = (
|
|
443
|
+
"query {\n"
|
|
444
|
+
f' repository(owner: "{owner}", name: "{name}") {{\n'
|
|
445
|
+
f" {aliases}\n"
|
|
446
|
+
" }\n"
|
|
447
|
+
"}\n"
|
|
448
|
+
)
|
|
449
|
+
try:
|
|
450
|
+
result = subprocess.run(
|
|
451
|
+
["gh", "api", "graphql", "-f", f"query={query}"],
|
|
452
|
+
capture_output=True,
|
|
453
|
+
text=True,
|
|
454
|
+
timeout=60,
|
|
455
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
456
|
+
)
|
|
457
|
+
except FileNotFoundError:
|
|
458
|
+
print(
|
|
459
|
+
"Error: gh CLI not found. Install GitHub CLI.",
|
|
460
|
+
file=sys.stderr,
|
|
461
|
+
)
|
|
462
|
+
return None
|
|
463
|
+
except subprocess.TimeoutExpired:
|
|
464
|
+
print("Error: gh CLI timed out.", file=sys.stderr)
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
# Tolerate partial GraphQL errors: when an issue number actually
|
|
468
|
+
# references a PR (or a deleted/transferred record) GitHub emits a
|
|
469
|
+
# top-level ``errors[*]`` entry AND gh exits non-zero, but the
|
|
470
|
+
# response ``data`` field is still populated (just with ``null``
|
|
471
|
+
# for the offending alias). Treat that as a soft failure so the
|
|
472
|
+
# caller can classify the missing aliases as NOT_FOUND. A truly
|
|
473
|
+
# fatal error (auth, network, malformed query) leaves ``stdout``
|
|
474
|
+
# empty / non-JSON and is still surfaced as ``None``.
|
|
475
|
+
try:
|
|
476
|
+
payload = json.loads(result.stdout) if result.stdout else None
|
|
477
|
+
except json.JSONDecodeError:
|
|
478
|
+
payload = None
|
|
479
|
+
|
|
480
|
+
if result.returncode != 0:
|
|
481
|
+
if payload is None or not isinstance(payload.get("data"), dict):
|
|
482
|
+
print(
|
|
483
|
+
f"Error: gh CLI failed: {result.stderr.strip()}",
|
|
484
|
+
file=sys.stderr,
|
|
485
|
+
)
|
|
486
|
+
return None
|
|
487
|
+
# Soft-failure path: surface the GraphQL errors as a single
|
|
488
|
+
# warning line so operators see the partial-resolve trace,
|
|
489
|
+
# then continue with whatever ``data`` came back.
|
|
490
|
+
print(
|
|
491
|
+
"Warning: gh GraphQL returned partial errors (likely PR "
|
|
492
|
+
"numbers referenced as issues): "
|
|
493
|
+
f"{result.stderr.strip().splitlines()[0] if result.stderr else ''}",
|
|
494
|
+
file=sys.stderr,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if payload is None:
|
|
498
|
+
print(
|
|
499
|
+
"Error: failed to parse gh CLI graphql output.",
|
|
500
|
+
file=sys.stderr,
|
|
501
|
+
)
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
repo_data = (payload.get("data") or {}).get("repository")
|
|
505
|
+
if not isinstance(repo_data, dict):
|
|
506
|
+
print(
|
|
507
|
+
"Error: gh CLI graphql response missing repository payload.",
|
|
508
|
+
file=sys.stderr,
|
|
509
|
+
)
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
for n in batch:
|
|
513
|
+
node = repo_data.get(f"i{n}")
|
|
514
|
+
if isinstance(node, dict) and isinstance(node.get("state"), str):
|
|
515
|
+
reason = node.get("stateReason")
|
|
516
|
+
states[n] = IssueState(
|
|
517
|
+
node["state"],
|
|
518
|
+
reason if isinstance(reason, str) else None,
|
|
519
|
+
)
|
|
520
|
+
else:
|
|
521
|
+
# GraphQL returns null for non-existent issues; map to a
|
|
522
|
+
# sentinel the caller can detect.
|
|
523
|
+
states[n] = IssueState("NOT_FOUND", None)
|
|
524
|
+
|
|
525
|
+
return states
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---------------------------------------------------------------------------
|
|
529
|
+
# Reconciliation
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def reconcile(
|
|
534
|
+
issue_to_vbriefs: dict[int, list[str]],
|
|
535
|
+
issue_state_map: dict[int, str],
|
|
536
|
+
) -> dict:
|
|
537
|
+
"""Inverted-lookup reconciliation report (default path; #754).
|
|
538
|
+
|
|
539
|
+
Classifies vBRIEF-referenced issues using the state map produced by
|
|
540
|
+
``fetch_issue_states``. Cost scales by
|
|
541
|
+
``O(len(issue_to_vbriefs))`` -- bounded by the repo's vBRIEF count
|
|
542
|
+
rather than total open-issue count.
|
|
543
|
+
|
|
544
|
+
Returns a dict with two sections:
|
|
545
|
+
linked -- referenced issues whose state is ``OPEN``
|
|
546
|
+
no_open_issue -- referenced issues whose state is ``CLOSED`` /
|
|
547
|
+
``NOT_FOUND`` / unknown (treated as the
|
|
548
|
+
apply-mode candidates)
|
|
549
|
+
|
|
550
|
+
The legacy ``unlinked`` bucket (open issues with NO matching vBRIEF)
|
|
551
|
+
is intentionally absent: it requires fetching every open issue in
|
|
552
|
+
the repo, which is the failure mode #754 retired. The legacy
|
|
553
|
+
three-section report is available via ``reconcile_with_unlinked``
|
|
554
|
+
(surfaced through the ``--report-unlinked`` CLI flag).
|
|
555
|
+
"""
|
|
556
|
+
linked: list[dict] = []
|
|
557
|
+
no_open_issue: list[dict] = []
|
|
558
|
+
|
|
559
|
+
for num in sorted(issue_to_vbriefs):
|
|
560
|
+
state = issue_state_map.get(num, "NOT_FOUND")
|
|
561
|
+
vbrief_files = issue_to_vbriefs[num]
|
|
562
|
+
if state == "OPEN":
|
|
563
|
+
linked.append({
|
|
564
|
+
"issue_number": num,
|
|
565
|
+
"vbrief_files": vbrief_files,
|
|
566
|
+
})
|
|
567
|
+
else:
|
|
568
|
+
note = (
|
|
569
|
+
"Issue is closed"
|
|
570
|
+
if state == "CLOSED"
|
|
571
|
+
else "Issue is closed or does not exist"
|
|
572
|
+
)
|
|
573
|
+
# #1290: surface state + stateReason so apply-mode can route
|
|
574
|
+
# CLOSED+NOT_PLANNED / CLOSED+DUPLICATE to cancelled/.
|
|
575
|
+
no_open_issue.append({
|
|
576
|
+
"issue_number": num,
|
|
577
|
+
"vbrief_files": vbrief_files,
|
|
578
|
+
"note": note,
|
|
579
|
+
"state": str(state),
|
|
580
|
+
"state_reason": state_reason_of(state),
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
"linked": linked,
|
|
585
|
+
"no_open_issue": no_open_issue,
|
|
586
|
+
"summary": {
|
|
587
|
+
"linked_count": len(linked),
|
|
588
|
+
"vbriefs_no_open_issue_count": len(no_open_issue),
|
|
589
|
+
},
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def reconcile_with_unlinked(
|
|
594
|
+
issue_to_vbriefs: dict[int, list[str]],
|
|
595
|
+
open_issues: list[dict],
|
|
596
|
+
) -> dict:
|
|
597
|
+
"""Legacy three-section reconciliation including the ``unlinked`` bucket.
|
|
598
|
+
|
|
599
|
+
Surfaced via the ``--report-unlinked`` opt-in CLI flag (#754); the
|
|
600
|
+
release-pipeline gate uses the inverted-lookup ``reconcile`` instead.
|
|
601
|
+
|
|
602
|
+
Returns a dict with three sections:
|
|
603
|
+
linked -- open issues with matching vBRIEF provenance
|
|
604
|
+
unlinked -- open issues with NO matching vBRIEF
|
|
605
|
+
no_open_issue -- vBRIEF references with no matching open issue
|
|
606
|
+
"""
|
|
607
|
+
open_issue_numbers = {i["number"] for i in open_issues}
|
|
608
|
+
|
|
609
|
+
linked = []
|
|
610
|
+
unlinked = []
|
|
611
|
+
no_open_issue = []
|
|
612
|
+
|
|
613
|
+
# Classify open issues
|
|
614
|
+
for issue in sorted(open_issues, key=lambda i: i["number"]):
|
|
615
|
+
num = issue["number"]
|
|
616
|
+
if num in issue_to_vbriefs:
|
|
617
|
+
linked.append({
|
|
618
|
+
"issue_number": num,
|
|
619
|
+
"title": issue.get("title", ""),
|
|
620
|
+
"url": issue.get("url", ""),
|
|
621
|
+
"vbrief_files": issue_to_vbriefs[num],
|
|
622
|
+
})
|
|
623
|
+
else:
|
|
624
|
+
unlinked.append({
|
|
625
|
+
"issue_number": num,
|
|
626
|
+
"title": issue.get("title", ""),
|
|
627
|
+
"url": issue.get("url", ""),
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
# vBRIEF references with no open issue
|
|
631
|
+
for num, vbrief_files in sorted(issue_to_vbriefs.items()):
|
|
632
|
+
if num not in open_issue_numbers:
|
|
633
|
+
no_open_issue.append({
|
|
634
|
+
"issue_number": num,
|
|
635
|
+
"vbrief_files": vbrief_files,
|
|
636
|
+
"note": "Issue is closed or does not exist",
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
"linked": linked,
|
|
641
|
+
"unlinked": unlinked,
|
|
642
|
+
"no_open_issue": no_open_issue,
|
|
643
|
+
"summary": {
|
|
644
|
+
"total_open_issues": len(open_issues),
|
|
645
|
+
"linked_count": len(linked),
|
|
646
|
+
"unlinked_count": len(unlinked),
|
|
647
|
+
"vbriefs_no_open_issue_count": len(no_open_issue),
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# ---------------------------------------------------------------------------
|
|
653
|
+
# Lifecycle anchor resolution (#1290 Phase B -- Axis B primary-reference filter)
|
|
654
|
+
# ---------------------------------------------------------------------------
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _parse_issue_ref_string(raw: object) -> int | None:
|
|
658
|
+
"""Parse a bare ``#N`` id or a full issue URL into an issue number.
|
|
659
|
+
|
|
660
|
+
Shared by ``parse_plan_ref``, ``parse_parent_issue`` and
|
|
661
|
+
``parse_decomposition_origin`` (#1290 / #1319). Returns ``None`` for
|
|
662
|
+
non-strings, empty strings, or strings that match neither shape.
|
|
663
|
+
"""
|
|
664
|
+
if not isinstance(raw, str):
|
|
665
|
+
return None
|
|
666
|
+
candidate = raw.strip()
|
|
667
|
+
m = ISSUE_ID_PATTERN.match(candidate)
|
|
668
|
+
if m:
|
|
669
|
+
return int(m.group("number"))
|
|
670
|
+
m = ISSUE_URL_PATTERN.search(candidate)
|
|
671
|
+
if m:
|
|
672
|
+
return int(m.group("number"))
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _x_tracking(data: dict) -> dict:
|
|
677
|
+
"""Return the ``metadata.x-tracking`` dict for a vBRIEF, or ``{}`` (#1319).
|
|
678
|
+
|
|
679
|
+
Decomposition children carry their tracking provenance under
|
|
680
|
+
``plan.metadata.x-tracking`` (the observed shape); a top-level
|
|
681
|
+
``metadata.x-tracking`` is also tolerated for robustness. Always
|
|
682
|
+
returns a dict so callers can ``.get(...)`` without guards.
|
|
683
|
+
"""
|
|
684
|
+
for container in (data.get("plan"), data):
|
|
685
|
+
if not isinstance(container, dict):
|
|
686
|
+
continue
|
|
687
|
+
meta = container.get("metadata")
|
|
688
|
+
if not isinstance(meta, dict):
|
|
689
|
+
continue
|
|
690
|
+
xt = meta.get("x-tracking")
|
|
691
|
+
if isinstance(xt, dict):
|
|
692
|
+
return xt
|
|
693
|
+
return {}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def parse_plan_ref(data: dict) -> int | None:
|
|
697
|
+
"""Extract the canonical issue number from ``plan.planRef`` (#1290).
|
|
698
|
+
|
|
699
|
+
``planRef`` is the vBRIEF's own primary issue (e.g. ``"#1290"``). It
|
|
700
|
+
is the canonical lifecycle anchor: a vBRIEF that merely *references*
|
|
701
|
+
an unrelated closed umbrella in ``plan.references[]`` must NOT be
|
|
702
|
+
dragged into that umbrella's terminal state. Accepts both the bare
|
|
703
|
+
``#N`` shape and a full issue URL. Returns ``None`` when the field is
|
|
704
|
+
absent or unparseable, so callers can fall back to ``references[]``.
|
|
705
|
+
"""
|
|
706
|
+
plan = data.get("plan", {})
|
|
707
|
+
if not isinstance(plan, dict):
|
|
708
|
+
return None
|
|
709
|
+
return _parse_issue_ref_string(plan.get("planRef"))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def parse_parent_issue(data: dict) -> int | None:
|
|
713
|
+
"""Extract the vBRIEF's own issue from ``x-tracking.parent_issue`` (#1319).
|
|
714
|
+
|
|
715
|
+
Decomposition children (carved from an umbrella via the decompose
|
|
716
|
+
skill) record their OWN primary issue under
|
|
717
|
+
``metadata.x-tracking.parent_issue`` even when ``plan.planRef`` is
|
|
718
|
+
absent. This is the canonical lifecycle anchor for those children:
|
|
719
|
+
it is the issue whose closure means the child's work is done, NOT
|
|
720
|
+
the umbrella it was carved from. Returns ``None`` when absent or
|
|
721
|
+
unparseable.
|
|
722
|
+
"""
|
|
723
|
+
return _parse_issue_ref_string(_x_tracking(data).get("parent_issue"))
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def parse_decomposition_origin(data: dict) -> int | None:
|
|
727
|
+
"""Extract the umbrella issue from ``x-tracking.decomposition_origin`` (#1319).
|
|
728
|
+
|
|
729
|
+
``decomposition_origin`` is the (often closed) umbrella issue a child
|
|
730
|
+
vBRIEF was carved out of. Its closure is NOT a completion signal for
|
|
731
|
+
the child, so the references fallback in ``resolve_lifecycle_anchor``
|
|
732
|
+
excludes it. Returns ``None`` when absent or unparseable.
|
|
733
|
+
"""
|
|
734
|
+
return _parse_issue_ref_string(_x_tracking(data).get("decomposition_origin"))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def resolve_lifecycle_anchor(data: dict) -> tuple[int | None, str]:
|
|
738
|
+
"""Resolve a vBRIEF's canonical lifecycle anchor (#1290 Phase B / #1319).
|
|
739
|
+
|
|
740
|
+
Returns ``(issue_number, axis)`` where ``axis`` is one of:
|
|
741
|
+
- ``"planRef"`` -- ``plan.planRef`` resolved to an issue number.
|
|
742
|
+
- ``"parent_issue"`` -- planRef absent; resolved the child's own
|
|
743
|
+
issue from ``x-tracking.parent_issue``.
|
|
744
|
+
- ``"references"`` -- both absent; fell back to the first
|
|
745
|
+
github-issue entry in ``references[]`` that
|
|
746
|
+
is NOT the decomposition_origin umbrella.
|
|
747
|
+
- ``"none"`` -- nothing yielded a github-issue number.
|
|
748
|
+
|
|
749
|
+
The Axis B fix (#1290): ``plan.planRef`` is consulted FIRST so an
|
|
750
|
+
umbrella close does not false-positive across a cohort whose own
|
|
751
|
+
planRef issues are still open.
|
|
752
|
+
|
|
753
|
+
The #1319 hardening: decomposition children carved from an umbrella
|
|
754
|
+
frequently lack ``plan.planRef`` but DO record their own primary
|
|
755
|
+
issue under ``x-tracking.parent_issue``. That is consulted next, so a
|
|
756
|
+
closed umbrella never drags a child whose own issue is still open.
|
|
757
|
+
The ``references[]`` fallback additionally EXCLUDES the
|
|
758
|
+
``x-tracking.decomposition_origin`` umbrella, so the closure of the
|
|
759
|
+
parent the child was carved from can never -- on its own -- be read
|
|
760
|
+
as the child's completion signal (the #742 / #1283 / #1284 / #1285 /
|
|
761
|
+
#1291 recurrence).
|
|
762
|
+
"""
|
|
763
|
+
num = parse_plan_ref(data)
|
|
764
|
+
if num is not None:
|
|
765
|
+
return num, "planRef"
|
|
766
|
+
num = parse_parent_issue(data)
|
|
767
|
+
if num is not None:
|
|
768
|
+
return num, "parent_issue"
|
|
769
|
+
decomposition_origin = parse_decomposition_origin(data)
|
|
770
|
+
for ref in extract_references_from_vbrief(data):
|
|
771
|
+
if ref.get("type") not in GITHUB_ISSUE_REF_TYPES:
|
|
772
|
+
continue
|
|
773
|
+
num = parse_issue_number(ref)
|
|
774
|
+
if num is None:
|
|
775
|
+
continue
|
|
776
|
+
if decomposition_origin is not None and num == decomposition_origin:
|
|
777
|
+
# The umbrella the child was carved from is not a completion
|
|
778
|
+
# signal for the child (#1319). Skip it as an anchor candidate.
|
|
779
|
+
continue
|
|
780
|
+
return num, "references"
|
|
781
|
+
return None, "none"
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def scan_lifecycle_anchors(vbrief_dir: Path) -> list[dict]:
|
|
785
|
+
"""Resolve the canonical lifecycle anchor for every vBRIEF (#1290).
|
|
786
|
+
|
|
787
|
+
Unlike ``scan_vbrief_dir`` (which maps each issue number to ALL the
|
|
788
|
+
vBRIEFs that reference it, for the human report), this is vBRIEF-
|
|
789
|
+
centric: each vBRIEF resolves to exactly one canonical anchor via
|
|
790
|
+
``resolve_lifecycle_anchor``. Returns a list of dicts with keys
|
|
791
|
+
``rel_path``, ``issue_number`` (``int`` or ``None``), and ``axis``.
|
|
792
|
+
"""
|
|
793
|
+
anchors: list[dict] = []
|
|
794
|
+
for folder in LIFECYCLE_FOLDERS:
|
|
795
|
+
folder_path = vbrief_dir / folder
|
|
796
|
+
if not folder_path.is_dir():
|
|
797
|
+
continue
|
|
798
|
+
for vbrief_file in sorted(folder_path.glob("*.vbrief.json")):
|
|
799
|
+
try:
|
|
800
|
+
data = json.loads(vbrief_file.read_text(encoding="utf-8"))
|
|
801
|
+
except (json.JSONDecodeError, OSError):
|
|
802
|
+
continue
|
|
803
|
+
num, axis = resolve_lifecycle_anchor(data)
|
|
804
|
+
anchors.append({
|
|
805
|
+
"rel_path": f"{folder}/{vbrief_file.name}",
|
|
806
|
+
"issue_number": num,
|
|
807
|
+
"axis": axis,
|
|
808
|
+
})
|
|
809
|
+
return anchors
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def build_lifecycle_report(
|
|
813
|
+
anchors: list[dict],
|
|
814
|
+
issue_state_map: dict[int, str],
|
|
815
|
+
*,
|
|
816
|
+
log: bool = True,
|
|
817
|
+
) -> dict:
|
|
818
|
+
"""Build the apply-mode report from canonical anchors (#1290 Phase B).
|
|
819
|
+
|
|
820
|
+
Each vBRIEF is classified by its OWN canonical anchor's state rather
|
|
821
|
+
than by every issue it references. Emits a structured per-vBRIEF log
|
|
822
|
+
line naming the resolved axis so a recovery audit can confirm the
|
|
823
|
+
reconciler routed off the correct anchor. Returns the same two-section
|
|
824
|
+
shape as ``reconcile`` (``linked`` / ``no_open_issue`` / ``summary``),
|
|
825
|
+
with each ``no_open_issue`` entry carrying ``state`` + ``state_reason``
|
|
826
|
+
for apply-mode routing.
|
|
827
|
+
"""
|
|
828
|
+
linked: list[dict] = []
|
|
829
|
+
no_open_issue: list[dict] = []
|
|
830
|
+
|
|
831
|
+
for anchor in anchors:
|
|
832
|
+
rel = anchor["rel_path"]
|
|
833
|
+
num = anchor["issue_number"]
|
|
834
|
+
axis = anchor["axis"]
|
|
835
|
+
if num is None:
|
|
836
|
+
if log:
|
|
837
|
+
print(
|
|
838
|
+
f"[lifecycle-resolve] vbrief={rel} axis=none "
|
|
839
|
+
"anchor=none state=n/a stateReason=n/a",
|
|
840
|
+
file=sys.stderr,
|
|
841
|
+
)
|
|
842
|
+
continue
|
|
843
|
+
value = issue_state_map.get(num)
|
|
844
|
+
state = str(value) if value is not None else "NOT_FOUND"
|
|
845
|
+
reason = state_reason_of(value)
|
|
846
|
+
if log:
|
|
847
|
+
print(
|
|
848
|
+
f"[lifecycle-resolve] vbrief={rel} axis={axis} "
|
|
849
|
+
f"anchor=#{num} state={state} stateReason={reason}",
|
|
850
|
+
file=sys.stderr,
|
|
851
|
+
)
|
|
852
|
+
if state == "OPEN":
|
|
853
|
+
linked.append({"issue_number": num, "vbrief_files": [rel]})
|
|
854
|
+
else:
|
|
855
|
+
note = (
|
|
856
|
+
"Issue is closed"
|
|
857
|
+
if state == "CLOSED"
|
|
858
|
+
else "Issue is closed or does not exist"
|
|
859
|
+
)
|
|
860
|
+
no_open_issue.append({
|
|
861
|
+
"issue_number": num,
|
|
862
|
+
"vbrief_files": [rel],
|
|
863
|
+
"note": note,
|
|
864
|
+
"state": state,
|
|
865
|
+
"state_reason": reason,
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
return {
|
|
869
|
+
"linked": linked,
|
|
870
|
+
"no_open_issue": no_open_issue,
|
|
871
|
+
"summary": {
|
|
872
|
+
"linked_count": len(linked),
|
|
873
|
+
"vbriefs_no_open_issue_count": len(no_open_issue),
|
|
874
|
+
},
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
# ---------------------------------------------------------------------------
|
|
879
|
+
# Output formatting
|
|
880
|
+
# ---------------------------------------------------------------------------
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def format_json(report: dict) -> str:
|
|
884
|
+
"""Format report as JSON."""
|
|
885
|
+
return json.dumps(report, indent=2, ensure_ascii=False)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def format_markdown(report: dict) -> str:
|
|
889
|
+
"""Format report as Markdown.
|
|
890
|
+
|
|
891
|
+
Handles both the inverted-lookup shape (default path; #754 -- two
|
|
892
|
+
sections, no ``unlinked`` bucket) and the legacy three-section shape
|
|
893
|
+
surfaced via ``--report-unlinked``. Section (b) is omitted when the
|
|
894
|
+
report lacks an ``unlinked`` key.
|
|
895
|
+
"""
|
|
896
|
+
lines: list[str] = []
|
|
897
|
+
summary = report["summary"]
|
|
898
|
+
has_unlinked = "unlinked" in report
|
|
899
|
+
|
|
900
|
+
lines.append("# Issue Reconciliation Report")
|
|
901
|
+
lines.append("")
|
|
902
|
+
if has_unlinked:
|
|
903
|
+
lines.append(f"- **Open issues**: {summary['total_open_issues']}")
|
|
904
|
+
lines.append(f"- **Linked** (vBRIEF provenance): {summary['linked_count']}")
|
|
905
|
+
if has_unlinked:
|
|
906
|
+
lines.append(
|
|
907
|
+
f"- **Unlinked** (no vBRIEF): {summary['unlinked_count']}"
|
|
908
|
+
)
|
|
909
|
+
lines.append(
|
|
910
|
+
f"- **vBRIEFs without open issue**: {summary['vbriefs_no_open_issue_count']}"
|
|
911
|
+
)
|
|
912
|
+
lines.append("")
|
|
913
|
+
|
|
914
|
+
# Section A: Linked
|
|
915
|
+
lines.append("## (a) Open issues with matching vBRIEF provenance")
|
|
916
|
+
lines.append("")
|
|
917
|
+
if report["linked"]:
|
|
918
|
+
for entry in report["linked"]:
|
|
919
|
+
files = ", ".join(f"`{f}`" for f in entry["vbrief_files"])
|
|
920
|
+
# Legacy shape carries title/url; inverted shape omits both.
|
|
921
|
+
title = entry.get("title", "")
|
|
922
|
+
suffix = f" {title}" if title else ""
|
|
923
|
+
lines.append(f"- #{entry['issue_number']}{suffix} -- {files}")
|
|
924
|
+
else:
|
|
925
|
+
lines.append("None.")
|
|
926
|
+
lines.append("")
|
|
927
|
+
|
|
928
|
+
# Section B: Unlinked (legacy three-section report only).
|
|
929
|
+
if has_unlinked:
|
|
930
|
+
lines.append("## (b) Open issues with NO matching vBRIEF (unlinked)")
|
|
931
|
+
lines.append("")
|
|
932
|
+
if report["unlinked"]:
|
|
933
|
+
for entry in report["unlinked"]:
|
|
934
|
+
lines.append(f"- #{entry['issue_number']} {entry['title']}")
|
|
935
|
+
else:
|
|
936
|
+
lines.append("None.")
|
|
937
|
+
lines.append("")
|
|
938
|
+
|
|
939
|
+
# Section C: No open issue
|
|
940
|
+
lines.append("## (c) vBRIEFs with NO matching open issue (potentially resolved)")
|
|
941
|
+
lines.append("")
|
|
942
|
+
if report["no_open_issue"]:
|
|
943
|
+
for entry in report["no_open_issue"]:
|
|
944
|
+
files = ", ".join(f"`{f}`" for f in entry["vbrief_files"])
|
|
945
|
+
lines.append(
|
|
946
|
+
f"- #{entry['issue_number']} -- {files} ({entry['note']})"
|
|
947
|
+
)
|
|
948
|
+
else:
|
|
949
|
+
lines.append("None.")
|
|
950
|
+
lines.append("")
|
|
951
|
+
|
|
952
|
+
return "\n".join(lines)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
# ---------------------------------------------------------------------------
|
|
956
|
+
# Apply-mode helpers (#734 -- --apply-lifecycle-fixes)
|
|
957
|
+
# ---------------------------------------------------------------------------
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _utc_now_iso() -> str:
|
|
961
|
+
"""Return the current UTC time as an ISO-8601 string with ``Z`` suffix.
|
|
962
|
+
|
|
963
|
+
The shape matches the existing migrator / refinement-skill stamp format
|
|
964
|
+
(``2026-04-29T22:48:22Z``). Seconds-precision is sufficient -- the
|
|
965
|
+
field is human-auditable, not a high-resolution timestamp.
|
|
966
|
+
"""
|
|
967
|
+
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _propagate_item_status(items: list, item_status: str, stamp: str) -> int:
|
|
971
|
+
"""Flip every item's ``status`` to ``item_status`` and stamp ``completed``.
|
|
972
|
+
|
|
973
|
+
Walks ``plan.items[*]`` recursively -- including the nested ``subItems``
|
|
974
|
+
and ``items`` arrays that ``extract_references_from_vbrief`` traverses --
|
|
975
|
+
so a vBRIEF with sub-item trees lands fully consistent rather than only
|
|
976
|
+
flipping the top level. Each touched item gets ``status = item_status``
|
|
977
|
+
(``"completed"`` or ``"cancelled"``) and an item-level ISO-8601 UTC
|
|
978
|
+
``completed`` timestamp mirroring PR #921's hand-applied
|
|
979
|
+
``plan.items[*].completed`` pattern. Returns the number of items touched
|
|
980
|
+
(#924).
|
|
981
|
+
"""
|
|
982
|
+
touched = 0
|
|
983
|
+
for item in items:
|
|
984
|
+
if not isinstance(item, dict):
|
|
985
|
+
continue
|
|
986
|
+
item["status"] = item_status
|
|
987
|
+
item["completed"] = stamp
|
|
988
|
+
touched += 1
|
|
989
|
+
# ``.get(key) or []`` (not ``.get(key, [])``): a present key with an
|
|
990
|
+
# explicit JSON ``null`` value returns ``None`` from ``.get(key, [])``
|
|
991
|
+
# because the default only applies to ABSENT keys. Passing ``None``
|
|
992
|
+
# into the recursion would raise ``TypeError: 'NoneType' object is
|
|
993
|
+
# not iterable`` and abort the whole lifecycle-fix batch mid-loop
|
|
994
|
+
# (#924 defensive hardening).
|
|
995
|
+
touched += _propagate_item_status(
|
|
996
|
+
item.get("subItems") or [], item_status, stamp
|
|
997
|
+
)
|
|
998
|
+
touched += _propagate_item_status(
|
|
999
|
+
item.get("items") or [], item_status, stamp
|
|
1000
|
+
)
|
|
1001
|
+
return touched
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _destination_folder(state_reason: str | None) -> str:
|
|
1005
|
+
"""Map a CLOSED issue's ``stateReason`` to a terminal folder (#1290).
|
|
1006
|
+
|
|
1007
|
+
``NOT_PLANNED`` and ``DUPLICATE`` route to ``cancelled/``; everything
|
|
1008
|
+
else (``COMPLETED``, a null reason, or the ``NOT_FOUND`` sentinel)
|
|
1009
|
+
routes to ``completed/`` -- the pre-#1290 default behaviour.
|
|
1010
|
+
"""
|
|
1011
|
+
if state_reason in CANCELLED_STATE_REASONS:
|
|
1012
|
+
return "cancelled"
|
|
1013
|
+
return "completed"
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _git_mv(src: Path, dst: Path, *, cwd: Path | None = None) -> bool:
|
|
1017
|
+
"""Move ``src`` -> ``dst`` using ``git mv`` when possible.
|
|
1018
|
+
|
|
1019
|
+
Falls back to ``shutil.move`` when ``git`` is not on PATH or the
|
|
1020
|
+
project is not a git repo (e.g. a synthetic test fixture). Returns
|
|
1021
|
+
True on success. Raises no exception -- the caller maps a False
|
|
1022
|
+
return to a per-file failure for the apply-mode summary.
|
|
1023
|
+
"""
|
|
1024
|
+
if shutil.which("git") is None:
|
|
1025
|
+
try:
|
|
1026
|
+
shutil.move(str(src), str(dst))
|
|
1027
|
+
return True
|
|
1028
|
+
except OSError:
|
|
1029
|
+
return False
|
|
1030
|
+
try:
|
|
1031
|
+
result = subprocess.run(
|
|
1032
|
+
["git", "mv", str(src), str(dst)],
|
|
1033
|
+
capture_output=True,
|
|
1034
|
+
text=True,
|
|
1035
|
+
timeout=30,
|
|
1036
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
1037
|
+
)
|
|
1038
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
1039
|
+
try:
|
|
1040
|
+
shutil.move(str(src), str(dst))
|
|
1041
|
+
return True
|
|
1042
|
+
except OSError:
|
|
1043
|
+
return False
|
|
1044
|
+
if result.returncode != 0:
|
|
1045
|
+
# Fall back to filesystem move (synthetic fixtures / non-git
|
|
1046
|
+
# trees). This keeps the apply-mode robust against partial
|
|
1047
|
+
# repo layouts while still preferring git semantics when
|
|
1048
|
+
# available.
|
|
1049
|
+
try:
|
|
1050
|
+
shutil.move(str(src), str(dst))
|
|
1051
|
+
return True
|
|
1052
|
+
except OSError:
|
|
1053
|
+
return False
|
|
1054
|
+
return True
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def apply_lifecycle_fixes(
|
|
1058
|
+
vbrief_dir: Path,
|
|
1059
|
+
report: dict,
|
|
1060
|
+
*,
|
|
1061
|
+
project_root: Path | None = None,
|
|
1062
|
+
) -> tuple[int, int, list[str]]:
|
|
1063
|
+
"""Move non-terminal Section (c) entries to a terminal folder.
|
|
1064
|
+
|
|
1065
|
+
Iterates ``report['no_open_issue']`` and for each vBRIEF file path
|
|
1066
|
+
that is NOT already in a terminal lifecycle folder:
|
|
1067
|
+
|
|
1068
|
+
1. Read the JSON.
|
|
1069
|
+
2. Route by the entry's ``state_reason`` (#1290): CLOSED+NOT_PLANNED
|
|
1070
|
+
and CLOSED+DUPLICATE go to ``cancelled/`` (``plan.status =
|
|
1071
|
+
"cancelled"``); everything else (COMPLETED / null reason /
|
|
1072
|
+
NOT_FOUND) goes to ``completed/`` (``plan.status = "completed"``).
|
|
1073
|
+
Entries without a ``state_reason`` key (legacy callers / hand-built
|
|
1074
|
+
reports) default to ``completed/`` -- the pre-#1290 behaviour.
|
|
1075
|
+
3. Stamp ``vBRIEFInfo.updated`` with the current UTC ISO timestamp.
|
|
1076
|
+
4. Write the file back (UTF-8, no BOM, trailing newline).
|
|
1077
|
+
5. ``git mv`` (or filesystem-move) the file into the routed folder.
|
|
1078
|
+
|
|
1079
|
+
The function is intentionally idempotent: a second call with a
|
|
1080
|
+
fresh report (where every entry already lives in ``completed/`` or
|
|
1081
|
+
``cancelled/``) is a no-op. Reverse mismatches (vBRIEFs already in a
|
|
1082
|
+
terminal folder whose issue was reopened) are skipped silently here -- they are
|
|
1083
|
+
surfaced in the report's Section (a) / (c) split, but auto-reverse
|
|
1084
|
+
is intentionally NOT performed (operator decision per #734).
|
|
1085
|
+
|
|
1086
|
+
Returns ``(moved, skipped, failures)`` where ``failures`` is a list
|
|
1087
|
+
of human-readable failure descriptions (empty on the happy path).
|
|
1088
|
+
|
|
1089
|
+
#756: Section (c) entries are deduplicated by relative path BEFORE
|
|
1090
|
+
the move loop runs. A single vBRIEF that references multiple closed
|
|
1091
|
+
issues appears once per issue in the report; without dedup the
|
|
1092
|
+
second-and-later iterations attempt to re-move the same file --
|
|
1093
|
+
the first move succeeds, the rest fail with the spurious
|
|
1094
|
+
``vBRIEF file missing`` diagnostic and the function exits with
|
|
1095
|
+
``failures != []`` even though the lifecycle move itself was
|
|
1096
|
+
correct. The pre-computed unique set preserves the surfacing order
|
|
1097
|
+
of the report (each path is processed in first-seen order) so the
|
|
1098
|
+
``[N/M] vBRIEFs reconciled`` summary keeps stable output across
|
|
1099
|
+
runs.
|
|
1100
|
+
"""
|
|
1101
|
+
moved = 0
|
|
1102
|
+
skipped = 0
|
|
1103
|
+
failures: list[str] = []
|
|
1104
|
+
cwd = project_root if project_root is not None else vbrief_dir.parent
|
|
1105
|
+
|
|
1106
|
+
# #756: pre-compute the unique candidate set in first-seen order so
|
|
1107
|
+
# a vBRIEF that references multiple closed issues lands in its
|
|
1108
|
+
# terminal folder exactly once. ``dict`` preserves insertion order
|
|
1109
|
+
# while collapsing duplicates; the value records the first-seen
|
|
1110
|
+
# ``state_reason`` so #1290 routing is stable across duplicate
|
|
1111
|
+
# entries for the same path.
|
|
1112
|
+
rel_reasons: dict[str, str | None] = {}
|
|
1113
|
+
for entry in report.get("no_open_issue", []):
|
|
1114
|
+
reason = entry.get("state_reason")
|
|
1115
|
+
for rel_path in entry.get("vbrief_files", []):
|
|
1116
|
+
if rel_path not in rel_reasons:
|
|
1117
|
+
rel_reasons[rel_path] = reason
|
|
1118
|
+
|
|
1119
|
+
for rel_path, state_reason in rel_reasons.items():
|
|
1120
|
+
try:
|
|
1121
|
+
folder, filename = rel_path.split("/", 1)
|
|
1122
|
+
except ValueError:
|
|
1123
|
+
failures.append(
|
|
1124
|
+
f"unexpected vBRIEF path shape (no folder): {rel_path!r}"
|
|
1125
|
+
)
|
|
1126
|
+
continue
|
|
1127
|
+
if is_terminal_lifecycle_path(rel_path):
|
|
1128
|
+
# Already terminal; no-op.
|
|
1129
|
+
skipped += 1
|
|
1130
|
+
continue
|
|
1131
|
+
|
|
1132
|
+
# #1290: route by stateReason. NOT_PLANNED / DUPLICATE ->
|
|
1133
|
+
# cancelled/; COMPLETED / null / NOT_FOUND -> completed/.
|
|
1134
|
+
dest_folder = _destination_folder(state_reason)
|
|
1135
|
+
src = vbrief_dir / folder / filename
|
|
1136
|
+
dst = vbrief_dir / dest_folder / filename
|
|
1137
|
+
if not src.is_file():
|
|
1138
|
+
failures.append(f"vBRIEF file missing: {rel_path}")
|
|
1139
|
+
continue
|
|
1140
|
+
|
|
1141
|
+
try:
|
|
1142
|
+
data = json.loads(src.read_text(encoding="utf-8"))
|
|
1143
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
1144
|
+
failures.append(f"failed to parse {rel_path}: {exc}")
|
|
1145
|
+
continue
|
|
1146
|
+
|
|
1147
|
+
# Greptile P1: check for a destination conflict BEFORE
|
|
1148
|
+
# mutating the source file on disk. Previously the
|
|
1149
|
+
# write-back happened before ``dst.exists()`` so a
|
|
1150
|
+
# collision left the source vBRIEF in an inconsistent
|
|
1151
|
+
# half-completed state (status stamped on disk but the file
|
|
1152
|
+
# still in its original lifecycle folder). Now the conflict
|
|
1153
|
+
# guard fires before any write, so the source file stays
|
|
1154
|
+
# byte-identical when the move cannot proceed.
|
|
1155
|
+
(vbrief_dir / dest_folder).mkdir(parents=True, exist_ok=True)
|
|
1156
|
+
if dst.exists():
|
|
1157
|
+
failures.append(
|
|
1158
|
+
f"target already exists in {dest_folder}/: {filename}"
|
|
1159
|
+
)
|
|
1160
|
+
continue
|
|
1161
|
+
|
|
1162
|
+
# Stamp status + updated. cancelled/ vBRIEFs get
|
|
1163
|
+
# plan.status="cancelled"; completed/ get "completed".
|
|
1164
|
+
plan = data.setdefault("plan", {})
|
|
1165
|
+
terminal_status = (
|
|
1166
|
+
"cancelled" if dest_folder == "cancelled" else "completed"
|
|
1167
|
+
)
|
|
1168
|
+
plan["status"] = terminal_status
|
|
1169
|
+
stamp = _utc_now_iso()
|
|
1170
|
+
info = data.setdefault("vBRIEFInfo", {})
|
|
1171
|
+
info["updated"] = stamp
|
|
1172
|
+
# Mirror the migrator pattern: also stamp ``plan.updated`` so
|
|
1173
|
+
# downstream tooling that prefers the plan-level field stays
|
|
1174
|
+
# current. Pre-existing files without the key gain it.
|
|
1175
|
+
plan["updated"] = stamp
|
|
1176
|
+
# #924: propagate the terminal status down to every
|
|
1177
|
+
# plan.items[*] (recursively, incl. subItems/items) and stamp an
|
|
1178
|
+
# item-level ISO-8601 UTC ``completed`` timestamp. Without this
|
|
1179
|
+
# the on-disk record is internally inconsistent (plan.status
|
|
1180
|
+
# flipped, items still "proposed"/"pending") and the next
|
|
1181
|
+
# reconcile/refinement pass re-flags the file as drifted.
|
|
1182
|
+
# ``.get("items") or []`` guards against an explicit ``"items": null``
|
|
1183
|
+
# in the on-disk JSON (the ``.get(key, [])`` default only applies to
|
|
1184
|
+
# ABSENT keys, so a present null would otherwise reach the recursion
|
|
1185
|
+
# as ``None`` and abort the batch).
|
|
1186
|
+
_propagate_item_status(plan.get("items") or [], terminal_status, stamp)
|
|
1187
|
+
|
|
1188
|
+
# Write back (UTF-8, no BOM, trailing newline; matches the
|
|
1189
|
+
# canonical writer style elsewhere in the script).
|
|
1190
|
+
try:
|
|
1191
|
+
src.write_text(
|
|
1192
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
1193
|
+
encoding="utf-8",
|
|
1194
|
+
)
|
|
1195
|
+
except OSError as exc:
|
|
1196
|
+
failures.append(f"failed to write {rel_path}: {exc}")
|
|
1197
|
+
continue
|
|
1198
|
+
|
|
1199
|
+
if not _git_mv(src, dst, cwd=cwd):
|
|
1200
|
+
failures.append(f"failed to move {rel_path} -> {dest_folder}/")
|
|
1201
|
+
continue
|
|
1202
|
+
moved += 1
|
|
1203
|
+
|
|
1204
|
+
return moved, skipped, failures
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
# ---------------------------------------------------------------------------
|
|
1208
|
+
# CLI entry point
|
|
1209
|
+
# ---------------------------------------------------------------------------
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def main() -> int:
|
|
1213
|
+
import argparse
|
|
1214
|
+
|
|
1215
|
+
parser = argparse.ArgumentParser(
|
|
1216
|
+
description="Reconcile GitHub issues against vBRIEF references."
|
|
1217
|
+
)
|
|
1218
|
+
parser.add_argument(
|
|
1219
|
+
"--vbrief-dir",
|
|
1220
|
+
default="./vbrief",
|
|
1221
|
+
help="Path to vbrief/ directory (default: ./vbrief)",
|
|
1222
|
+
)
|
|
1223
|
+
parser.add_argument(
|
|
1224
|
+
"--repo",
|
|
1225
|
+
default=None,
|
|
1226
|
+
help=(
|
|
1227
|
+
"GitHub repo in OWNER/REPO format. Highest precedence; beats "
|
|
1228
|
+
"$DEFT_PROJECT_REPO and git-remote detection. Without a flag, "
|
|
1229
|
+
"env var, or git remote in the project root the script FAILS "
|
|
1230
|
+
"loudly rather than silently falling back to deft's own remote "
|
|
1231
|
+
"(#538)."
|
|
1232
|
+
),
|
|
1233
|
+
)
|
|
1234
|
+
parser.add_argument(
|
|
1235
|
+
"--project-root",
|
|
1236
|
+
default=None,
|
|
1237
|
+
help=(
|
|
1238
|
+
"Consumer project root. Used as CWD for git-remote detection "
|
|
1239
|
+
"so ``gh`` / ``git`` queries target the consumer repo, not "
|
|
1240
|
+
"deftai/directive (#538)."
|
|
1241
|
+
),
|
|
1242
|
+
)
|
|
1243
|
+
parser.add_argument(
|
|
1244
|
+
"--format",
|
|
1245
|
+
choices=["json", "markdown"],
|
|
1246
|
+
default="markdown",
|
|
1247
|
+
help="Output format (default: markdown)",
|
|
1248
|
+
)
|
|
1249
|
+
parser.add_argument(
|
|
1250
|
+
"--apply-lifecycle-fixes",
|
|
1251
|
+
action="store_true",
|
|
1252
|
+
default=False,
|
|
1253
|
+
help=(
|
|
1254
|
+
"Apply Section (c) fixes: move non-terminal closed-issue "
|
|
1255
|
+
"vBRIEFs to completed/, stamp plan.status=completed and "
|
|
1256
|
+
"vBRIEFInfo.updated. Idempotent on re-run. Reverse "
|
|
1257
|
+
"mismatches (terminal vBRIEF + reopened issue) are "
|
|
1258
|
+
"report-only -- never auto-reverse-moved. (#734)"
|
|
1259
|
+
),
|
|
1260
|
+
)
|
|
1261
|
+
parser.add_argument(
|
|
1262
|
+
"--report-unlinked",
|
|
1263
|
+
action="store_true",
|
|
1264
|
+
default=False,
|
|
1265
|
+
help=(
|
|
1266
|
+
"Emit the legacy three-section report including the "
|
|
1267
|
+
"``unlinked`` bucket (open issues with no matching vBRIEF). "
|
|
1268
|
+
"Requires fetching every open issue in the repo, which "
|
|
1269
|
+
"scales by O(repo-open-issue-count). Default invocation "
|
|
1270
|
+
"uses the inverted-lookup path (#754) and emits only "
|
|
1271
|
+
"sections (a) and (c)."
|
|
1272
|
+
),
|
|
1273
|
+
)
|
|
1274
|
+
parser.add_argument(
|
|
1275
|
+
"--max-open-issues",
|
|
1276
|
+
type=int,
|
|
1277
|
+
default=DEFAULT_MAX_OPEN_ISSUES,
|
|
1278
|
+
metavar="N",
|
|
1279
|
+
help=(
|
|
1280
|
+
f"Safety cap for the --report-unlinked path (default "
|
|
1281
|
+
f"{DEFAULT_MAX_OPEN_ISSUES}). When the paginated open-issue "
|
|
1282
|
+
"fetch exceeds N, abort cleanly with exit 1 and a "
|
|
1283
|
+
"diagnostic. Raise the cap explicitly when invoking "
|
|
1284
|
+
"--report-unlinked on a large repo. (#754)"
|
|
1285
|
+
),
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
args = parser.parse_args()
|
|
1289
|
+
vbrief_dir = Path(args.vbrief_dir).resolve()
|
|
1290
|
+
|
|
1291
|
+
if not vbrief_dir.is_dir():
|
|
1292
|
+
print(f"Error: vbrief directory not found: {vbrief_dir}", file=sys.stderr)
|
|
1293
|
+
return 1
|
|
1294
|
+
|
|
1295
|
+
# Resolve repo using the shared precedence: --repo > $DEFT_PROJECT_REPO >
|
|
1296
|
+
# git-remote in the (resolved) project root > legacy CWD-scoped
|
|
1297
|
+
# ``detect_repo()`` fallback. Never silently fall through to deft's own
|
|
1298
|
+
# origin (#538).
|
|
1299
|
+
project_root = resolve_project_root(args.project_root)
|
|
1300
|
+
repo = resolve_project_repo(args.repo, project_root=project_root)
|
|
1301
|
+
if repo is None:
|
|
1302
|
+
repo = detect_repo()
|
|
1303
|
+
if repo is None:
|
|
1304
|
+
print(
|
|
1305
|
+
"Error: could not detect repo. "
|
|
1306
|
+
"Pass --repo OWNER/NAME, set $DEFT_PROJECT_REPO, or run from "
|
|
1307
|
+
"a directory tree whose git remote origin is the consumer "
|
|
1308
|
+
"repo (#538).",
|
|
1309
|
+
file=sys.stderr,
|
|
1310
|
+
)
|
|
1311
|
+
# Exit 2 for this usage-style error keeps reconcile:issues
|
|
1312
|
+
# consistent with issue_ingest.py and scope_lifecycle.py, so
|
|
1313
|
+
# CI scripts/shell conditionals can treat "no repo detected"
|
|
1314
|
+
# as a single exit-code bucket (Greptile P2 on #562).
|
|
1315
|
+
return 2
|
|
1316
|
+
|
|
1317
|
+
# Scan vBRIEFs
|
|
1318
|
+
issue_to_vbriefs = scan_vbrief_dir(vbrief_dir)
|
|
1319
|
+
|
|
1320
|
+
# #754: branch on ``--report-unlinked``. The default path uses the
|
|
1321
|
+
# inverted-lookup helper -- O(vBRIEF-referenced-issue-count) cost,
|
|
1322
|
+
# no truncation possible. The opt-in legacy path fetches every open
|
|
1323
|
+
# issue and emits the three-section report; capped by
|
|
1324
|
+
# ``--max-open-issues`` so a 15k-open-issue repo cannot surprise
|
|
1325
|
+
# operators with a 30s+ fetch.
|
|
1326
|
+
# #1290 Phase B: resolve each vBRIEF's canonical lifecycle anchor
|
|
1327
|
+
# (planRef-first) so apply-mode never drags a cohort member into a
|
|
1328
|
+
# closed umbrella's terminal state. Computed only when apply-mode is
|
|
1329
|
+
# requested; the state fetch then covers both the reference-based
|
|
1330
|
+
# scan (human report) and the canonical anchors (apply candidates),
|
|
1331
|
+
# so a planRef issue absent from references[] still gets its state.
|
|
1332
|
+
anchors: list[dict] = []
|
|
1333
|
+
needed = set(issue_to_vbriefs.keys())
|
|
1334
|
+
if args.apply_lifecycle_fixes:
|
|
1335
|
+
anchors = scan_lifecycle_anchors(vbrief_dir)
|
|
1336
|
+
needed |= {
|
|
1337
|
+
a["issue_number"] for a in anchors if a["issue_number"] is not None
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
issue_state_map: dict[int, IssueState] | None = None
|
|
1341
|
+
if args.report_unlinked:
|
|
1342
|
+
open_issues = fetch_all_open_issues(repo, cwd=project_root)
|
|
1343
|
+
if open_issues is None:
|
|
1344
|
+
return 1
|
|
1345
|
+
if len(open_issues) > args.max_open_issues:
|
|
1346
|
+
print(
|
|
1347
|
+
f"Error: {len(open_issues)} open issues exceeds "
|
|
1348
|
+
f"--max-open-issues={args.max_open_issues}; raise the "
|
|
1349
|
+
"cap or drop --report-unlinked",
|
|
1350
|
+
file=sys.stderr,
|
|
1351
|
+
)
|
|
1352
|
+
return 1
|
|
1353
|
+
report = reconcile_with_unlinked(issue_to_vbriefs, open_issues)
|
|
1354
|
+
# Apply-mode still needs anchor states even on the legacy path.
|
|
1355
|
+
if args.apply_lifecycle_fixes:
|
|
1356
|
+
issue_state_map = fetch_issue_states(
|
|
1357
|
+
repo, needed, cwd=project_root
|
|
1358
|
+
)
|
|
1359
|
+
if issue_state_map is None:
|
|
1360
|
+
return 1
|
|
1361
|
+
else:
|
|
1362
|
+
# Inverted lookup: query just the vBRIEF-referenced subset.
|
|
1363
|
+
issue_state_map = fetch_issue_states(repo, needed, cwd=project_root)
|
|
1364
|
+
if issue_state_map is None:
|
|
1365
|
+
return 1
|
|
1366
|
+
report = reconcile(issue_to_vbriefs, issue_state_map)
|
|
1367
|
+
|
|
1368
|
+
# Output
|
|
1369
|
+
if args.format == "json":
|
|
1370
|
+
print(format_json(report))
|
|
1371
|
+
else:
|
|
1372
|
+
print(format_markdown(report))
|
|
1373
|
+
|
|
1374
|
+
# #734/#1290: apply mode -- move non-terminal closed-issue vBRIEFs to
|
|
1375
|
+
# their terminal folder (completed/ or cancelled/, routed by
|
|
1376
|
+
# stateReason). The apply candidate set is built from the canonical
|
|
1377
|
+
# anchors (Phase B), NOT the reference-based human report.
|
|
1378
|
+
if args.apply_lifecycle_fixes:
|
|
1379
|
+
apply_report = build_lifecycle_report(anchors, issue_state_map or {})
|
|
1380
|
+
candidates = sum(
|
|
1381
|
+
1
|
|
1382
|
+
for entry in apply_report.get("no_open_issue", [])
|
|
1383
|
+
for rel in entry.get("vbrief_files", [])
|
|
1384
|
+
if not is_terminal_lifecycle_path(rel)
|
|
1385
|
+
)
|
|
1386
|
+
moved, skipped, failures = apply_lifecycle_fixes(
|
|
1387
|
+
vbrief_dir, apply_report, project_root=project_root
|
|
1388
|
+
)
|
|
1389
|
+
total = moved + skipped + len(failures)
|
|
1390
|
+
print(
|
|
1391
|
+
f"[{moved}/{candidates}] vBRIEFs reconciled "
|
|
1392
|
+
f"(moved={moved}, already-terminal={skipped}, "
|
|
1393
|
+
f"failures={len(failures)})",
|
|
1394
|
+
file=sys.stderr,
|
|
1395
|
+
)
|
|
1396
|
+
for f in failures:
|
|
1397
|
+
print(f" -- FAIL: {f}", file=sys.stderr)
|
|
1398
|
+
if failures:
|
|
1399
|
+
return 1
|
|
1400
|
+
# Suppress unused-name warning for ``total``; kept for log clarity.
|
|
1401
|
+
del total
|
|
1402
|
+
|
|
1403
|
+
return 0
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def detect_repo() -> str | None:
|
|
1407
|
+
"""Auto-detect OWNER/REPO from git remote origin.
|
|
1408
|
+
|
|
1409
|
+
Legacy fallback kept for backwards compatibility with in-process tests
|
|
1410
|
+
that monkeypatch this symbol directly; the primary repo-resolution
|
|
1411
|
+
path goes through ``_project_context.resolve_project_repo``. Uses the
|
|
1412
|
+
same ``.git``-suffix-aware regex as ``_normalise_repo_slug`` so a
|
|
1413
|
+
dotted repo name (``acme/my.project``) isn't silently truncated to
|
|
1414
|
+
``acme/my`` when this fallback IS reached (Greptile P2 on #562).
|
|
1415
|
+
"""
|
|
1416
|
+
try:
|
|
1417
|
+
result = subprocess.run(
|
|
1418
|
+
["git", "remote", "get-url", "origin"],
|
|
1419
|
+
capture_output=True,
|
|
1420
|
+
text=True,
|
|
1421
|
+
timeout=10,
|
|
1422
|
+
)
|
|
1423
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
1424
|
+
return None
|
|
1425
|
+
|
|
1426
|
+
if result.returncode != 0:
|
|
1427
|
+
return None
|
|
1428
|
+
|
|
1429
|
+
url = result.stdout.strip()
|
|
1430
|
+
# Mirrors ``_normalise_repo_slug`` -- the legacy fallback used to
|
|
1431
|
+
# share its bug (``[^/.]+`` truncates dotted names).
|
|
1432
|
+
m = re.search(
|
|
1433
|
+
r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?(?:\s|$)",
|
|
1434
|
+
url,
|
|
1435
|
+
)
|
|
1436
|
+
if m:
|
|
1437
|
+
return f"{m.group(1)}/{m.group(2)}"
|
|
1438
|
+
return None
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
if __name__ == "__main__":
|
|
1442
|
+
raise SystemExit(main())
|