@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,1064 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
r"""
|
|
3
|
+
issue_ingest.py -- Ingest GitHub issues into vBRIEF lifecycle folders.
|
|
4
|
+
|
|
5
|
+
Every post-GA issue would otherwise live only on GitHub and reappear in the
|
|
6
|
+
``task reconcile:issues`` unlinked section monotonically -- this script lets a
|
|
7
|
+
maintainer (or an agent running the refinement skill) materialise an issue as a
|
|
8
|
+
scope vBRIEF with origin provenance so the rest of the framework can reason
|
|
9
|
+
about it. Single-issue mode fetches one issue number and writes one scope
|
|
10
|
+
vBRIEF; bulk mode scans all open issues (optionally filtered by label) and
|
|
11
|
+
ingests anything not already referenced by an existing vBRIEF.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
uv run python scripts/issue_ingest.py <N> [--status proposed|pending|active]
|
|
15
|
+
uv run python scripts/issue_ingest.py --all [--label LABEL]
|
|
16
|
+
[--status STATUS] [--dry-run]
|
|
17
|
+
uv run python scripts/issue_ingest.py [--vbrief-dir DIR] [--repo OWNER/REPO] ...
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
0 -- ingest completed successfully
|
|
21
|
+
1 -- duplicate (single-issue mode; the issue already has a vBRIEF)
|
|
22
|
+
2 -- external error (missing gh, API failure, usage error)
|
|
23
|
+
|
|
24
|
+
Story: #454 (task issue:ingest).
|
|
25
|
+
|
|
26
|
+
Issue bodies are opaque upstream Markdown text. Never decode them through
|
|
27
|
+
Python or JSON string-escape semantics after the GitHub JSON payload has been
|
|
28
|
+
parsed; literal substrings such as ``\vbrief``, ``\task``, ``\n``, and
|
|
29
|
+
``\u0041`` must remain literal text in ``plan.narratives.Overview``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
# Make sibling scripts importable both when run as __main__ and when imported
|
|
43
|
+
# by tests that pre-populate sys.path with the ``scripts/`` directory.
|
|
44
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
45
|
+
|
|
46
|
+
# #1145 / N5: route the ``gh api`` round-trip in :func:`_fetch_single_issue`
|
|
47
|
+
# through the source-aware shim so a future GitLab / Gitea / local consumer
|
|
48
|
+
# sees ``NotImplementedError`` pointing at #445 / #935 Workstream 6 instead
|
|
49
|
+
# of a confusing ``gh: command not found`` deep in the call stack. The shim
|
|
50
|
+
# resolves the binary via the #884 ``ghx`` -> ``gh`` preference ladder.
|
|
51
|
+
import scm # noqa: E402 -- sibling-first path insertion above is intentional
|
|
52
|
+
from _project_context import resolve_project_repo, resolve_project_root # noqa: E402
|
|
53
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
54
|
+
from _vbrief_build import EMITTED_VBRIEF_VERSION, TODAY, slugify # noqa: E402
|
|
55
|
+
from reconcile_issues import ( # noqa: E402
|
|
56
|
+
GITHUB_ISSUE_REF_TYPES,
|
|
57
|
+
LIFECYCLE_FOLDERS,
|
|
58
|
+
detect_repo,
|
|
59
|
+
extract_references_from_vbrief,
|
|
60
|
+
fetch_open_issues,
|
|
61
|
+
parse_issue_number,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# #883 unified cache surface (optional). When present we prefer the cached
|
|
65
|
+
# raw.json payload over a live ``gh api`` round-trip so a Phase 0 walk that
|
|
66
|
+
# pre-populated the cache (``task cache:fetch-all``) does not re-spend the
|
|
67
|
+
# REST budget per issue. The import is guarded so this module imports cleanly
|
|
68
|
+
# in checkouts where ``scripts/cache.py`` is not yet on the branch -- tests
|
|
69
|
+
# substitute fakes via ``monkeypatch.setattr(issue_ingest, "cache", ...)``.
|
|
70
|
+
try: # pragma: no cover -- exercised once #883 Story 2 lands.
|
|
71
|
+
import cache # type: ignore[import-not-found] # noqa: E402
|
|
72
|
+
except ImportError: # pragma: no cover
|
|
73
|
+
cache = None # type: ignore[assignment]
|
|
74
|
+
|
|
75
|
+
reconfigure_stdio()
|
|
76
|
+
|
|
77
|
+
# --- Constants --------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
# Allowed target lifecycle folders for ingestion. The rest (``completed/``,
|
|
80
|
+
# ``cancelled/``) are terminal states; a freshly ingested issue doesn't belong
|
|
81
|
+
# there.
|
|
82
|
+
INGEST_STATUSES: tuple[str, ...] = ("proposed", "pending", "active")
|
|
83
|
+
|
|
84
|
+
# #1096: provenance-narrative parsers. ``_build_issue_vbrief`` emits
|
|
85
|
+
# ``narratives.Origin = "Ingested from <full-URL>"`` when a browser URL
|
|
86
|
+
# resolves, or ``"Ingested from issue #N"`` when no URL is available.
|
|
87
|
+
# ``vBRIEFInfo.description = "Scope vBRIEF ingested from GitHub issue #N"``
|
|
88
|
+
# is the secondary signal. Both shapes yield the same canonical provenance
|
|
89
|
+
# issue number for the dedup pass.
|
|
90
|
+
_ORIGIN_URL_RE = re.compile(
|
|
91
|
+
r"https?://github\.com/[^/\s]+/[^/\s]+/issues/(\d+)"
|
|
92
|
+
)
|
|
93
|
+
_ORIGIN_BARE_RE = re.compile(r"issue\s*#(\d+)", re.IGNORECASE)
|
|
94
|
+
|
|
95
|
+
# Map status keyword -> (folder, plan.status) pair used in the generated
|
|
96
|
+
# scope vBRIEF file.
|
|
97
|
+
_STATUS_MAP: dict[str, tuple[str, str]] = {
|
|
98
|
+
"proposed": ("proposed", "proposed"),
|
|
99
|
+
"pending": ("pending", "pending"),
|
|
100
|
+
"active": ("active", "running"),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# #1248: body-parsing patterns. The ingester previously emitted stub-only
|
|
104
|
+
# vBRIEFs (no ``Overview``, ``plan.items == []``) which forced the
|
|
105
|
+
# refinement workflow to re-read the GitHub issue body by hand. The
|
|
106
|
+
# patterns below extract acceptance-criteria checklists, numbered AC
|
|
107
|
+
# items, and Closes / Refs / Blocked-by cross-references from the issue
|
|
108
|
+
# body so downstream consumers (``deft-directive-refinement``,
|
|
109
|
+
# ``task triage:queue`` dedup) have substantive content to project from.
|
|
110
|
+
|
|
111
|
+
# GitHub-flavoured Markdown task-list line. Captures the marker (space /
|
|
112
|
+
# x / X) and the trailing title text. The trailing ``$`` anchors against
|
|
113
|
+
# trailing whitespace so a multi-line list item only contributes the
|
|
114
|
+
# first line; deeper nesting / continuation lines are explicitly out of
|
|
115
|
+
# scope for v1 and noted as a follow-up in the issue body.
|
|
116
|
+
_CHECKBOX_RE = re.compile(
|
|
117
|
+
r"^\s*[-*+]\s+\[([ xX])\]\s+(.+?)\s*$",
|
|
118
|
+
re.MULTILINE,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Heading whose text contains "Acceptance Criteria" (case-insensitive).
|
|
122
|
+
# Used as the entry point for the AC-section fallback when the body
|
|
123
|
+
# carries no checkbox-style task list.
|
|
124
|
+
_AC_HEADING_RE = re.compile(
|
|
125
|
+
r"^(#{1,6})\s+.*\bacceptance\s+criteria\b.*$",
|
|
126
|
+
re.IGNORECASE | re.MULTILINE,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Bullet- or numbered-list item. Used inside the AC section after the
|
|
130
|
+
# heading match -- both ``- foo`` / ``* foo`` / ``+ foo`` and
|
|
131
|
+
# ``1. foo`` / ``1) foo`` shapes are accepted.
|
|
132
|
+
_LIST_ITEM_RE = re.compile(
|
|
133
|
+
r"^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$",
|
|
134
|
+
re.MULTILINE,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Closing / referencing / blocking keyword -> canonical ``x-vbrief/*``
|
|
138
|
+
# reference type. Ordering is significant: ``blocked by`` is matched
|
|
139
|
+
# before ``blocks`` would be (the latter is intentionally absent because
|
|
140
|
+
# ``Blocks #N`` on the source issue has the inverse semantic of the
|
|
141
|
+
# ingested issue being blocked). Patterns are applied against a body
|
|
142
|
+
# stripped of fenced and inline code spans so Markdown examples don't
|
|
143
|
+
# produce spurious cross-refs.
|
|
144
|
+
_CROSS_REF_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
145
|
+
(
|
|
146
|
+
"x-vbrief/closes",
|
|
147
|
+
re.compile(
|
|
148
|
+
r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b",
|
|
149
|
+
re.IGNORECASE,
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
(
|
|
153
|
+
"x-vbrief/blocks",
|
|
154
|
+
re.compile(
|
|
155
|
+
r"\bblocked[\s\-]+by\s+#(\d+)\b",
|
|
156
|
+
re.IGNORECASE,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
(
|
|
160
|
+
"x-vbrief/refs",
|
|
161
|
+
re.compile(
|
|
162
|
+
r"\b(?:refs?|references?|see\s+also|related(?:\s+to)?)\s+#(\d+)\b",
|
|
163
|
+
re.IGNORECASE,
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Fenced code block (triple-backtick OR tilde-fence) and inline code
|
|
169
|
+
# span (single backtick, no embedded backtick / newline). Stripped
|
|
170
|
+
# before cross-ref / plan-item extraction so a body that quotes
|
|
171
|
+
# ``Closes #N`` as an illustration does not produce a real cross-ref.
|
|
172
|
+
# The capturing group + ``\1`` backreference enforces matching
|
|
173
|
+
# delimiters (a ``~~~`` fence cannot be closed by ``\`\`\``) per the
|
|
174
|
+
# GitHub Flavoured Markdown spec.
|
|
175
|
+
_CODE_FENCE_RE = re.compile(r"(```|~~~).*?\1", re.DOTALL)
|
|
176
|
+
_INLINE_CODE_RE = re.compile(r"`[^`\n]*`")
|
|
177
|
+
|
|
178
|
+
_CONTROL_CHAR_LABELS: dict[str, str] = {
|
|
179
|
+
"\b": "U+0008 backspace",
|
|
180
|
+
"\t": "U+0009 tab",
|
|
181
|
+
"\v": "U+000B vertical tab",
|
|
182
|
+
"\f": "U+000C form feed",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- Helpers ----------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _strip_code_blocks(body: str) -> str:
|
|
190
|
+
"""Return ``body`` with Markdown code spans elided (#1248).
|
|
191
|
+
|
|
192
|
+
Fenced code blocks (triple-backtick) and inline single-backtick code
|
|
193
|
+
spans are replaced with the empty string before cross-ref / plan-item
|
|
194
|
+
extraction. This prevents an issue body that *quotes* ``Closes #N``
|
|
195
|
+
as a syntax example (the #1248 body is itself an example -- it
|
|
196
|
+
embeds a JSON block illustrating the stub-only shape) from producing
|
|
197
|
+
spurious cross-refs or plan-items.
|
|
198
|
+
"""
|
|
199
|
+
if not body:
|
|
200
|
+
return ""
|
|
201
|
+
return _INLINE_CODE_RE.sub("", _CODE_FENCE_RE.sub("", body))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _has_non_indentation_prefix(text: str, index: int) -> bool:
|
|
205
|
+
"""Return True when a tab at ``index`` follows non-whitespace on its line."""
|
|
206
|
+
line_start = text.rfind("\n", 0, index) + 1
|
|
207
|
+
return any(ch not in " \t" for ch in text[line_start:index])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _body_control_character_labels(body: str) -> list[str]:
|
|
211
|
+
"""Return visible labels for unexpected control characters in issue body text."""
|
|
212
|
+
labels: list[str] = []
|
|
213
|
+
seen: set[str] = set()
|
|
214
|
+
for index, char in enumerate(body):
|
|
215
|
+
if char == "\t" and not _has_non_indentation_prefix(body, index):
|
|
216
|
+
continue
|
|
217
|
+
label = _CONTROL_CHAR_LABELS.get(char)
|
|
218
|
+
if label is None and ord(char) < 32 and char not in {"\n", "\r"}:
|
|
219
|
+
label = f"U+{ord(char):04X} control character"
|
|
220
|
+
if label and label not in seen:
|
|
221
|
+
seen.add(label)
|
|
222
|
+
labels.append(label)
|
|
223
|
+
return labels
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _warn_body_control_characters(number: int, body: str) -> None:
|
|
227
|
+
"""Surface decoded upstream control characters before writing a vBRIEF."""
|
|
228
|
+
labels = _body_control_character_labels(body)
|
|
229
|
+
if not labels:
|
|
230
|
+
return
|
|
231
|
+
print(
|
|
232
|
+
f"Warning: issue #{number} body contains unexpected control characters "
|
|
233
|
+
f"({', '.join(labels)}); preserving Overview verbatim, but "
|
|
234
|
+
"verify_encoding will flag the generated vBRIEF narrative.",
|
|
235
|
+
file=sys.stderr,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _extract_plan_items(body: str) -> list[dict]:
|
|
240
|
+
"""Extract ``plan.items[]`` entries from a GitHub issue body (#1248).
|
|
241
|
+
|
|
242
|
+
Detection ladder:
|
|
243
|
+
|
|
244
|
+
1. Markdown task-list checkboxes (``- [ ] foo`` / ``- [x] bar``) --
|
|
245
|
+
the GitHub-native shape that ``deft-directive-refinement`` and
|
|
246
|
+
``task triage:queue`` both project from. Unchecked boxes map to
|
|
247
|
+
``status = "proposed"``; checked boxes map to
|
|
248
|
+
``status = "completed"`` so an issue that ships partial progress
|
|
249
|
+
is reflected honestly.
|
|
250
|
+
2. Bullet- / numbered-list items underneath an ``Acceptance
|
|
251
|
+
Criteria`` heading -- the second-most-common shape across the
|
|
252
|
+
2026-05-20 audit cohort. Stops at the next heading at the same
|
|
253
|
+
or higher level.
|
|
254
|
+
3. Graceful degradation: when neither shape is present, return an
|
|
255
|
+
empty list. The vBRIEF still carries ``narratives.Overview`` so
|
|
256
|
+
refinement can refine *something*; ``plan.items`` is only ever
|
|
257
|
+
populated when there is structured source material to project
|
|
258
|
+
from.
|
|
259
|
+
|
|
260
|
+
Every emitted item carries the schema-required ``title`` + ``status``
|
|
261
|
+
keys (``minLength: 1`` on ``title``; ``status`` from the canonical
|
|
262
|
+
``Status`` enum). Duplicate titles are de-duped while preserving
|
|
263
|
+
document order.
|
|
264
|
+
"""
|
|
265
|
+
if not body:
|
|
266
|
+
return []
|
|
267
|
+
text = _strip_code_blocks(body)
|
|
268
|
+
|
|
269
|
+
items: list[dict] = []
|
|
270
|
+
seen: set[str] = set()
|
|
271
|
+
for match in _CHECKBOX_RE.finditer(text):
|
|
272
|
+
marker = match.group(1)
|
|
273
|
+
title_text = match.group(2).strip()
|
|
274
|
+
if not title_text or title_text in seen:
|
|
275
|
+
continue
|
|
276
|
+
seen.add(title_text)
|
|
277
|
+
status = "completed" if marker.lower() == "x" else "proposed"
|
|
278
|
+
items.append({"title": title_text, "status": status})
|
|
279
|
+
if items:
|
|
280
|
+
return items
|
|
281
|
+
|
|
282
|
+
# Fallback: numbered / bulleted list under an Acceptance Criteria heading.
|
|
283
|
+
return _extract_ac_section_items(text)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _extract_ac_section_items(text: str) -> list[dict]:
|
|
287
|
+
"""Extract list items from an Acceptance Criteria section (#1248 fallback).
|
|
288
|
+
|
|
289
|
+
Walks for an ``Acceptance Criteria`` heading (any level 1-6,
|
|
290
|
+
case-insensitive). When found, slices the body to the section --
|
|
291
|
+
bounded by the next heading at the same-or-higher level -- and
|
|
292
|
+
returns each bullet / numbered list item as a PlanItem dict with
|
|
293
|
+
``status = "proposed"``.
|
|
294
|
+
"""
|
|
295
|
+
heading_match = _AC_HEADING_RE.search(text)
|
|
296
|
+
if not heading_match:
|
|
297
|
+
return []
|
|
298
|
+
heading_level = len(heading_match.group(1))
|
|
299
|
+
section_start = heading_match.end()
|
|
300
|
+
next_heading_re = re.compile(
|
|
301
|
+
rf"^#{{1,{heading_level}}}\s+\S",
|
|
302
|
+
re.MULTILINE,
|
|
303
|
+
)
|
|
304
|
+
after = text[section_start:]
|
|
305
|
+
next_match = next_heading_re.search(after)
|
|
306
|
+
section_text = after[: next_match.start()] if next_match else after
|
|
307
|
+
|
|
308
|
+
items: list[dict] = []
|
|
309
|
+
seen: set[str] = set()
|
|
310
|
+
for li in _LIST_ITEM_RE.finditer(section_text):
|
|
311
|
+
title_text = li.group(1).strip()
|
|
312
|
+
# Defensive: strip a leftover ``[ ]`` / ``[x]`` checkbox prefix
|
|
313
|
+
# if a maintainer mixed checkbox + numbered shapes inside the
|
|
314
|
+
# AC section. Preserve the checked state so a completed item
|
|
315
|
+
# in a numbered+checkbox mixed AC list lands as ``completed``
|
|
316
|
+
# rather than being silently demoted to ``proposed`` (downstream
|
|
317
|
+
# consumers ``deft-directive-refinement`` / ``task triage:queue``
|
|
318
|
+
# treat ``status`` as signal for remaining work).
|
|
319
|
+
status = "proposed"
|
|
320
|
+
cb = re.match(r"\[([ xX])\]\s+(.+)", title_text)
|
|
321
|
+
if cb:
|
|
322
|
+
title_text = cb.group(2).strip()
|
|
323
|
+
if cb.group(1).lower() == "x":
|
|
324
|
+
status = "completed"
|
|
325
|
+
if not title_text or title_text in seen:
|
|
326
|
+
continue
|
|
327
|
+
seen.add(title_text)
|
|
328
|
+
items.append({"title": title_text, "status": status})
|
|
329
|
+
return items
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _extract_cross_refs(
|
|
333
|
+
body: str,
|
|
334
|
+
repo_url: str,
|
|
335
|
+
*,
|
|
336
|
+
exclude: set[int] | None = None,
|
|
337
|
+
) -> list[dict]:
|
|
338
|
+
"""Extract Closes / Refs / Blocked-by cross-refs from issue body (#1248).
|
|
339
|
+
|
|
340
|
+
Returns a list of canonical ``VBriefReference`` dicts (``{uri, type,
|
|
341
|
+
title}``) ready to append to ``plan.references[]``. Reference types:
|
|
342
|
+
|
|
343
|
+
- ``x-vbrief/closes`` for ``Closes / Fixes / Resolves #N`` (inflected
|
|
344
|
+
forms ``closed`` / ``fixed`` / ``resolved`` accepted).
|
|
345
|
+
- ``x-vbrief/blocks`` for ``Blocked by #N`` -- the dependency
|
|
346
|
+
direction the issue body expresses (this scope is blocked by #N).
|
|
347
|
+
- ``x-vbrief/refs`` for ``Refs / References / See also / Related #N``.
|
|
348
|
+
|
|
349
|
+
Skips matches that fall inside fenced or inline code spans (the
|
|
350
|
+
body is passed through :func:`_strip_code_blocks` first) and any
|
|
351
|
+
issue number in ``exclude`` -- callers pass the provenance issue
|
|
352
|
+
number itself so a self-reference (e.g. ``Closes #1248`` in #1248's
|
|
353
|
+
own body) does not produce a duplicate reference to the canonical
|
|
354
|
+
``x-vbrief/github-issue`` origin.
|
|
355
|
+
|
|
356
|
+
Returns an empty list when ``repo_url`` is empty -- the canonical
|
|
357
|
+
``VBriefReference`` shape requires ``uri``, and synthesising a
|
|
358
|
+
URL without a repo handle would be dishonest.
|
|
359
|
+
"""
|
|
360
|
+
if not body or not repo_url:
|
|
361
|
+
return []
|
|
362
|
+
text = _strip_code_blocks(body)
|
|
363
|
+
refs: list[dict] = []
|
|
364
|
+
seen: set[tuple[str, int]] = set()
|
|
365
|
+
excluded = exclude or set()
|
|
366
|
+
for ref_type, pattern in _CROSS_REF_PATTERNS:
|
|
367
|
+
for match in pattern.finditer(text):
|
|
368
|
+
number = int(match.group(1))
|
|
369
|
+
if number in excluded:
|
|
370
|
+
continue
|
|
371
|
+
key = (ref_type, number)
|
|
372
|
+
if key in seen:
|
|
373
|
+
continue
|
|
374
|
+
seen.add(key)
|
|
375
|
+
refs.append(
|
|
376
|
+
{
|
|
377
|
+
"uri": f"{repo_url}/issues/{number}",
|
|
378
|
+
"type": ref_type,
|
|
379
|
+
"title": f"Issue #{number}",
|
|
380
|
+
}
|
|
381
|
+
)
|
|
382
|
+
return refs
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _provenance_issue_number(data: dict) -> int | None:
|
|
386
|
+
"""Extract the provenance issue number from a vBRIEF data dict (#1096).
|
|
387
|
+
|
|
388
|
+
A vBRIEF is the *provenance owner* of issue ``#N`` when its
|
|
389
|
+
``plan.narratives.Origin`` (or, as a secondary signal,
|
|
390
|
+
``vBRIEFInfo.description``) states it was ingested from ``#N``. Both
|
|
391
|
+
canonical Origin shapes emitted by :func:`_build_issue_vbrief` are
|
|
392
|
+
accepted:
|
|
393
|
+
|
|
394
|
+
- ``Ingested from https://github.com/<owner>/<repo>/issues/<N>``
|
|
395
|
+
- ``Ingested from issue #<N>`` (no-URL fallback)
|
|
396
|
+
|
|
397
|
+
Returns the provenance issue number or ``None`` when the vBRIEF carries
|
|
398
|
+
no recognisable ``Ingested from ...`` signal (e.g. a hand-authored
|
|
399
|
+
kaizen brief that merely references GitHub issues, or a legacy v0.5
|
|
400
|
+
fixture predating the Origin convention -- the caller's fallback
|
|
401
|
+
heuristic in :func:`_scan_provenance_refs` handles back-compat).
|
|
402
|
+
"""
|
|
403
|
+
if not isinstance(data, dict):
|
|
404
|
+
return None
|
|
405
|
+
plan = data.get("plan", {})
|
|
406
|
+
narratives = plan.get("narratives", {}) if isinstance(plan, dict) else {}
|
|
407
|
+
origin = (
|
|
408
|
+
narratives.get("Origin", "") if isinstance(narratives, dict) else ""
|
|
409
|
+
)
|
|
410
|
+
info = data.get("vBRIEFInfo", {})
|
|
411
|
+
description = info.get("description", "") if isinstance(info, dict) else ""
|
|
412
|
+
|
|
413
|
+
for text in (origin, description):
|
|
414
|
+
if not isinstance(text, str) or not text:
|
|
415
|
+
continue
|
|
416
|
+
m = _ORIGIN_URL_RE.search(text)
|
|
417
|
+
if m:
|
|
418
|
+
return int(m.group(1))
|
|
419
|
+
m = _ORIGIN_BARE_RE.search(text)
|
|
420
|
+
if m:
|
|
421
|
+
return int(m.group(1))
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _scan_provenance_refs(vbrief_dir: Path) -> dict[int, list[str]]:
|
|
426
|
+
"""Scan vBRIEF lifecycle folders and return a provenance-only dedup map (#1096).
|
|
427
|
+
|
|
428
|
+
Differentiates *provenance* references (the vBRIEF was actually
|
|
429
|
+
ingested from issue ``#N`` -- ``plan.narratives.Origin`` confirms it
|
|
430
|
+
AND a canonical ``x-vbrief/github-issue`` reference points at ``#N``)
|
|
431
|
+
from *informational* references (companion / sibling / related-plan
|
|
432
|
+
mentions, even when typed ``x-vbrief/github-issue``). Only the
|
|
433
|
+
provenance owner of an issue is returned, so ``task issue:ingest --
|
|
434
|
+
<N>`` no longer false-positives on informational references that
|
|
435
|
+
merely mention ``#N`` (closes #1096).
|
|
436
|
+
|
|
437
|
+
Per-vBRIEF resolution rule:
|
|
438
|
+
|
|
439
|
+
1. If ``plan.narratives.Origin`` (or ``vBRIEFInfo.description``)
|
|
440
|
+
identifies a provenance issue number ``P`` AND any
|
|
441
|
+
``x-vbrief/github-issue`` reference points at ``P`` -> that vBRIEF
|
|
442
|
+
is the provenance owner of ``P`` (only). Other
|
|
443
|
+
``x-vbrief/github-issue`` references on the same vBRIEF are
|
|
444
|
+
treated as informational and contribute nothing to the dedup map.
|
|
445
|
+
2. If no ``Origin`` provenance signal is present (legacy v0.5
|
|
446
|
+
fixtures, hand-authored stubs) -> fall back to the FIRST
|
|
447
|
+
``x-vbrief/github-issue`` reference as the implied provenance.
|
|
448
|
+
This preserves dedup for unmigrated trees per the #1096 vBRIEF's
|
|
449
|
+
out-of-scope clause ("the fix should make new ingest correct
|
|
450
|
+
without requiring a data-migration sweep first").
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Mapping of issue_number -> list of vBRIEF file paths (relative to
|
|
454
|
+
``vbrief_dir``) where each listed vBRIEF is the *provenance* owner
|
|
455
|
+
of that issue. The list shape matches
|
|
456
|
+
:func:`reconcile_issues.scan_vbrief_dir` so callers can swap the
|
|
457
|
+
two functions transparently.
|
|
458
|
+
"""
|
|
459
|
+
issue_to_vbriefs: dict[int, list[str]] = {}
|
|
460
|
+
|
|
461
|
+
for folder in LIFECYCLE_FOLDERS:
|
|
462
|
+
folder_path = vbrief_dir / folder
|
|
463
|
+
if not folder_path.is_dir():
|
|
464
|
+
continue
|
|
465
|
+
for vbrief_file in sorted(folder_path.glob("*.vbrief.json")):
|
|
466
|
+
try:
|
|
467
|
+
data = json.loads(vbrief_file.read_text(encoding="utf-8"))
|
|
468
|
+
except (json.JSONDecodeError, OSError):
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
refs = extract_references_from_vbrief(data)
|
|
472
|
+
github_refs: list[tuple[dict, int]] = []
|
|
473
|
+
for ref in refs:
|
|
474
|
+
if ref.get("type") not in GITHUB_ISSUE_REF_TYPES:
|
|
475
|
+
continue
|
|
476
|
+
num = parse_issue_number(ref)
|
|
477
|
+
if num is not None:
|
|
478
|
+
github_refs.append((ref, num))
|
|
479
|
+
|
|
480
|
+
if not github_refs:
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
provenance_num = _provenance_issue_number(data)
|
|
484
|
+
if provenance_num is not None:
|
|
485
|
+
# Origin/description identifies ``provenance_num`` -- only
|
|
486
|
+
# count the matching github-issue ref. Companion refs to
|
|
487
|
+
# other issues on the same vBRIEF are informational.
|
|
488
|
+
if not any(num == provenance_num for _, num in github_refs):
|
|
489
|
+
# Origin narrative claims a number not borne out by any
|
|
490
|
+
# github-issue reference. Honest behaviour is to skip
|
|
491
|
+
# this vBRIEF -- treating the Origin claim alone as
|
|
492
|
+
# provenance would re-introduce the false-positive
|
|
493
|
+
# surface the legacy-ref fallback below is bounded
|
|
494
|
+
# against.
|
|
495
|
+
continue
|
|
496
|
+
owner_num = provenance_num
|
|
497
|
+
else:
|
|
498
|
+
# Legacy fallback: first github-issue ref is provenance.
|
|
499
|
+
owner_num = github_refs[0][1]
|
|
500
|
+
|
|
501
|
+
rel_path = f"{folder}/{vbrief_file.name}"
|
|
502
|
+
issue_to_vbriefs.setdefault(owner_num, []).append(rel_path)
|
|
503
|
+
|
|
504
|
+
return issue_to_vbriefs
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _build_issue_vbrief(
|
|
508
|
+
issue: dict, status: str, repo_url: str
|
|
509
|
+
) -> tuple[dict, str]:
|
|
510
|
+
"""Build a scope vBRIEF dict (and the target lifecycle folder) from a GitHub issue dict.
|
|
511
|
+
|
|
512
|
+
``issue`` is the JSON payload returned by ``gh api repos/.../issues/N`` or
|
|
513
|
+
one element of the ``gh issue list --json number,title,labels,url,body``
|
|
514
|
+
array.
|
|
515
|
+
|
|
516
|
+
Emits canonical vBRIEF v0.6 output (#639 + #988):
|
|
517
|
+
- ``vBRIEFInfo.version = EMITTED_VBRIEF_VERSION`` (``"0.6"``) -- the
|
|
518
|
+
canonical schema pin (const ``"0.6"`` in
|
|
519
|
+
``vbrief/schemas/vbrief-core.schema.json``).
|
|
520
|
+
- ``plan.narratives.Overview`` carries the GitHub issue body verbatim
|
|
521
|
+
when present (#988). This is the contract documented in
|
|
522
|
+
``skills/deft-directive-swarm/SKILL.md`` Phase 0 Step 0B; the prior
|
|
523
|
+
implementation only emitted ``Description`` (= title) and dropped
|
|
524
|
+
the body, producing stub vBRIEFs that failed every downstream
|
|
525
|
+
"acceptance criteria present" check. ``narratives.Labels`` is kept
|
|
526
|
+
for backward compatibility but ``plan.tags`` is now the structured
|
|
527
|
+
surface for downstream filtering.
|
|
528
|
+
- ``plan.tags`` is a list of label-name strings when the issue carries
|
|
529
|
+
labels (#988). The Plan schema's ``tags`` array (line 162 of
|
|
530
|
+
``vbrief/schemas/vbrief-core.schema.json``) accepts arbitrary
|
|
531
|
+
strings; this lets consumers filter without parsing the freeform
|
|
532
|
+
``narratives.Labels`` text.
|
|
533
|
+
- ``plan.references`` uses the canonical
|
|
534
|
+
``VBriefReference`` shape ``{uri, type: "x-vbrief/github-issue",
|
|
535
|
+
title: "Issue #{N}: {title}"}`` documented in
|
|
536
|
+
``conventions/references.md`` (matches ``scripts/_vbrief_build.py::
|
|
537
|
+
create_scope_vbrief``). The legacy bare
|
|
538
|
+
``{type: "github-issue", id: "#N", url}`` shape is NEVER emitted.
|
|
539
|
+
- When no browser URL can be resolved (neither the issue payload's
|
|
540
|
+
``url`` nor a non-empty ``repo_url``) the reference is omitted --
|
|
541
|
+
``VBriefReference`` requires ``uri``, so we cannot honestly emit
|
|
542
|
+
one. The caller still has the issue number in ``plan.narratives["Origin"]``.
|
|
543
|
+
"""
|
|
544
|
+
number = int(issue["number"])
|
|
545
|
+
title = str(issue.get("title", f"Issue #{number}")) or f"Issue #{number}"
|
|
546
|
+
url = str(issue.get("url", "")) or (
|
|
547
|
+
f"{repo_url}/issues/{number}" if repo_url else ""
|
|
548
|
+
)
|
|
549
|
+
body = issue.get("body")
|
|
550
|
+
body_str = str(body) if isinstance(body, str) and body else ""
|
|
551
|
+
labels = issue.get("labels", []) or []
|
|
552
|
+
label_names = [
|
|
553
|
+
(lbl.get("name") if isinstance(lbl, dict) else str(lbl))
|
|
554
|
+
for lbl in labels
|
|
555
|
+
if (isinstance(lbl, dict) and lbl.get("name")) or isinstance(lbl, str)
|
|
556
|
+
]
|
|
557
|
+
folder, plan_status = _STATUS_MAP[status]
|
|
558
|
+
|
|
559
|
+
narratives: dict[str, str] = {
|
|
560
|
+
"Description": title,
|
|
561
|
+
"Origin": f"Ingested from {url}" if url else f"Ingested from issue #{number}",
|
|
562
|
+
}
|
|
563
|
+
if body_str:
|
|
564
|
+
# #988: carry the issue body verbatim to ``narratives.Overview`` so
|
|
565
|
+
# the swarm Phase 0 "acceptance criteria present" check has source
|
|
566
|
+
# text to project from. #1248 widens this surface by ALSO emitting
|
|
567
|
+
# structured ``plan.items[]`` + ``plan.references[]`` cross-refs
|
|
568
|
+
# derived from the body, so refinement / triage:queue have more
|
|
569
|
+
# than just an opaque blob to work with.
|
|
570
|
+
_warn_body_control_characters(number, body_str)
|
|
571
|
+
narratives["Overview"] = body_str
|
|
572
|
+
if label_names:
|
|
573
|
+
narratives["Labels"] = ", ".join(label_names)
|
|
574
|
+
|
|
575
|
+
# #1248: derive ``plan.items[]`` from the issue body's task-list /
|
|
576
|
+
# acceptance-criteria checklist (graceful degradation to ``[]`` when
|
|
577
|
+
# neither shape is present).
|
|
578
|
+
plan_items = _extract_plan_items(body_str) if body_str else []
|
|
579
|
+
|
|
580
|
+
plan: dict = {
|
|
581
|
+
"title": title,
|
|
582
|
+
"status": plan_status,
|
|
583
|
+
"narratives": narratives,
|
|
584
|
+
"items": plan_items,
|
|
585
|
+
}
|
|
586
|
+
if label_names:
|
|
587
|
+
# #988: structured-surface mirror of ``narratives.Labels`` so
|
|
588
|
+
# consumers can filter by tag without parsing the freeform string.
|
|
589
|
+
plan["tags"] = list(label_names)
|
|
590
|
+
|
|
591
|
+
# #639 + #1248: canonical v0.6 VBriefReference shape, with the body-
|
|
592
|
+
# derived Closes / Refs / Blocked-by cross-refs appended after the
|
|
593
|
+
# canonical ``x-vbrief/github-issue`` origin. Only emit when we have
|
|
594
|
+
# a resolvable URL -- the schema requires ``uri`` and we must not
|
|
595
|
+
# forge one. Matches ``scripts/_vbrief_build.py::create_scope_vbrief``
|
|
596
|
+
# and ``conventions/references.md``.
|
|
597
|
+
if url:
|
|
598
|
+
references: list[dict] = [
|
|
599
|
+
{
|
|
600
|
+
"uri": url,
|
|
601
|
+
"type": "x-vbrief/github-issue",
|
|
602
|
+
"title": f"Issue #{number}: {title}",
|
|
603
|
+
}
|
|
604
|
+
]
|
|
605
|
+
if body_str and repo_url:
|
|
606
|
+
# Use ``repo_url`` (not ``url``) so cross-refs target sibling
|
|
607
|
+
# issues under the same repo even when ``url`` already
|
|
608
|
+
# resolves a specific issue; exclude ``number`` so a
|
|
609
|
+
# self-referencing ``Closes #N`` in the body does not
|
|
610
|
+
# duplicate the canonical origin reference above.
|
|
611
|
+
references.extend(
|
|
612
|
+
_extract_cross_refs(
|
|
613
|
+
body_str, repo_url, exclude={number}
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
plan["references"] = references
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
"vBRIEFInfo": {
|
|
620
|
+
"version": EMITTED_VBRIEF_VERSION,
|
|
621
|
+
"description": f"Scope vBRIEF ingested from GitHub issue #{number}",
|
|
622
|
+
},
|
|
623
|
+
"plan": plan,
|
|
624
|
+
}, folder
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _target_filename(number: int, title: str) -> str:
|
|
628
|
+
"""Build the ``YYYY-MM-DD-<N>-<slug>.vbrief.json`` filename for an issue."""
|
|
629
|
+
slug = slugify(title) or f"issue-{number}"
|
|
630
|
+
return f"{TODAY}-{number}-{slug}.vbrief.json"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _fetch_from_cache(
|
|
634
|
+
repo: str,
|
|
635
|
+
number: int,
|
|
636
|
+
*,
|
|
637
|
+
cache_root: Path | None = None,
|
|
638
|
+
) -> dict | None:
|
|
639
|
+
"""Read the unified cache (#883) for ``(github-issue, repo/number)`` if fresh.
|
|
640
|
+
|
|
641
|
+
Returns the parsed ``raw.json`` payload when present and not stale,
|
|
642
|
+
``None`` otherwise (cache miss, stale entry, parse failure, or the
|
|
643
|
+
cache module is not importable). The caller falls back to a live
|
|
644
|
+
``gh api`` round-trip via :func:`_fetch_single_issue` on ``None``.
|
|
645
|
+
|
|
646
|
+
Cache freshness is delegated to :func:`scripts.cache.cache_get` with
|
|
647
|
+
``allow_stale=False`` -- this matches the #883 contract that callers
|
|
648
|
+
opt in to stale entries explicitly. The unified cache TTL for
|
|
649
|
+
``github-issue`` is 7 days (see ``scripts/cache.py::SOURCE_TTL_SECONDS``).
|
|
650
|
+
"""
|
|
651
|
+
if cache is None:
|
|
652
|
+
return None
|
|
653
|
+
key = f"{repo}/{int(number)}"
|
|
654
|
+
try:
|
|
655
|
+
result = cache.cache_get(
|
|
656
|
+
"github-issue", key, cache_root=cache_root, allow_stale=False
|
|
657
|
+
)
|
|
658
|
+
except Exception: # noqa: BLE001 -- any cache error -> live fetch fallback
|
|
659
|
+
return None
|
|
660
|
+
raw_path = Path(result.entry_dir) / "raw.json"
|
|
661
|
+
if not raw_path.exists():
|
|
662
|
+
return None
|
|
663
|
+
try:
|
|
664
|
+
issue: Any = json.loads(raw_path.read_text(encoding="utf-8"))
|
|
665
|
+
except (OSError, json.JSONDecodeError):
|
|
666
|
+
return None
|
|
667
|
+
if not isinstance(issue, dict):
|
|
668
|
+
return None
|
|
669
|
+
# Mirror the normalisation _fetch_single_issue applies to live ``gh api``
|
|
670
|
+
# output: prefer ``html_url`` (browser URL) over ``url`` (REST API URL)
|
|
671
|
+
# when both are present. The cache populated by ``task cache:fetch-all``
|
|
672
|
+
# uses ``gh issue list --json ...,url`` which already emits the browser
|
|
673
|
+
# URL, so this branch is a no-op for cached payloads -- but keep it
|
|
674
|
+
# defensive so a future cache populator using ``gh api`` directly still
|
|
675
|
+
# produces honest output here.
|
|
676
|
+
if issue.get("html_url"):
|
|
677
|
+
issue["url"] = issue["html_url"]
|
|
678
|
+
return issue
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _fetch_issue(
|
|
682
|
+
repo: str,
|
|
683
|
+
number: int,
|
|
684
|
+
*,
|
|
685
|
+
cwd: Path | None = None,
|
|
686
|
+
cache_root: Path | None = None,
|
|
687
|
+
) -> dict | None:
|
|
688
|
+
"""Fetch a single issue, preferring the unified cache over live ``gh api``.
|
|
689
|
+
|
|
690
|
+
#988: when ``.deft-cache/github-issue/<owner>/<repo>/<N>/raw.json`` is
|
|
691
|
+
fresh, return the cached payload directly so a Phase 0 walk that
|
|
692
|
+
pre-populated the cache via ``task cache:fetch-all`` does not re-spend
|
|
693
|
+
the REST budget per issue. Falls back to live ``gh api`` on cache miss
|
|
694
|
+
or stale entries (per #883 cache freshness rules).
|
|
695
|
+
"""
|
|
696
|
+
cached = _fetch_from_cache(repo, number, cache_root=cache_root)
|
|
697
|
+
if cached is not None:
|
|
698
|
+
return cached
|
|
699
|
+
return _fetch_single_issue(repo, number, cwd=cwd)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _fetch_single_issue(
|
|
703
|
+
repo: str,
|
|
704
|
+
number: int,
|
|
705
|
+
*,
|
|
706
|
+
cwd: Path | None = None,
|
|
707
|
+
) -> dict | None:
|
|
708
|
+
"""Fetch a single issue via ``gh api repos/{repo}/issues/{number}``.
|
|
709
|
+
|
|
710
|
+
Routes through :func:`scripts.scm.call` (#1145 / N5) so a future
|
|
711
|
+
non-GitHub consumer raises a loud ``NotImplementedError`` pointing at
|
|
712
|
+
#445 / #935 Workstream 6 rather than failing deep in the call stack
|
|
713
|
+
with ``gh: command not found``. The shim resolves the binary via the
|
|
714
|
+
#884 ``ghx`` -> ``gh`` preference ladder so cached responses are
|
|
715
|
+
transparently picked up when ``ghx`` is installed.
|
|
716
|
+
|
|
717
|
+
Returns the parsed issue dict on success, ``None`` on error (with the
|
|
718
|
+
reason printed to stderr).
|
|
719
|
+
"""
|
|
720
|
+
try:
|
|
721
|
+
result = scm.call(
|
|
722
|
+
"github-issue",
|
|
723
|
+
"api",
|
|
724
|
+
[f"repos/{repo}/issues/{number}"],
|
|
725
|
+
timeout=30,
|
|
726
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
727
|
+
)
|
|
728
|
+
except FileNotFoundError:
|
|
729
|
+
print("Error: gh CLI not found. Install GitHub CLI.", file=sys.stderr)
|
|
730
|
+
return None
|
|
731
|
+
except scm.ScmStubError as exc:
|
|
732
|
+
print(f"Error: gh CLI resolution failed: {exc}", file=sys.stderr)
|
|
733
|
+
return None
|
|
734
|
+
except subprocess.TimeoutExpired:
|
|
735
|
+
print("Error: gh CLI timed out.", file=sys.stderr)
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
if result.returncode != 0:
|
|
739
|
+
print(
|
|
740
|
+
f"Error: gh CLI failed fetching #{number}: {result.stderr.strip()}",
|
|
741
|
+
file=sys.stderr,
|
|
742
|
+
)
|
|
743
|
+
return None
|
|
744
|
+
try:
|
|
745
|
+
issue = json.loads(result.stdout)
|
|
746
|
+
except json.JSONDecodeError:
|
|
747
|
+
print(
|
|
748
|
+
f"Error: failed to parse gh CLI output for #{number}.",
|
|
749
|
+
file=sys.stderr,
|
|
750
|
+
)
|
|
751
|
+
return None
|
|
752
|
+
# #639 follow-up (Greptile P1): ``gh api repos/{repo}/issues/{N}``
|
|
753
|
+
# ALWAYS returns both ``url`` (REST API URL, ``https://api.github.com/repos/...``)
|
|
754
|
+
# and ``html_url`` (browser URL, ``https://github.com/{owner}/{repo}/issues/{N}``).
|
|
755
|
+
# The previous ``"url" not in issue`` guard was therefore always False for
|
|
756
|
+
# real gh api output, so ``issue["url"]`` leaked through as the REST API
|
|
757
|
+
# URL and ended up in the canonical ``uri`` field -- contradicting the
|
|
758
|
+
# ``conventions/references.md`` spec which requires the browser URL.
|
|
759
|
+
# ``fetch_open_issues`` (``gh issue list --json ...,url``) already returns
|
|
760
|
+
# ``url`` = browser URL, so unconditionally preferring ``html_url`` when
|
|
761
|
+
# present aligns the single-issue and bulk paths.
|
|
762
|
+
if "html_url" in issue and issue.get("html_url"):
|
|
763
|
+
issue["url"] = issue["html_url"]
|
|
764
|
+
return issue
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
# --- Core actions -----------------------------------------------------------
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def ingest_one(
|
|
771
|
+
issue: dict,
|
|
772
|
+
*,
|
|
773
|
+
vbrief_dir: Path,
|
|
774
|
+
status: str,
|
|
775
|
+
repo_url: str,
|
|
776
|
+
dry_run: bool = False,
|
|
777
|
+
existing_refs: dict[int, list[str]] | None = None,
|
|
778
|
+
) -> tuple[str, Path | None, str]:
|
|
779
|
+
"""Ingest a single issue dict.
|
|
780
|
+
|
|
781
|
+
Returns ``(result, path, message)`` where ``result`` is one of ``"created"``,
|
|
782
|
+
``"dryrun"``, or ``"duplicate"``. ``path`` is the written (or would-be) file
|
|
783
|
+
path; for ``duplicate`` it points at the pre-existing vBRIEF that already
|
|
784
|
+
references this issue.
|
|
785
|
+
"""
|
|
786
|
+
number = int(issue["number"])
|
|
787
|
+
# #1096: provenance-aware dedup. Only count vBRIEFs that were actually
|
|
788
|
+
# ingested from issue #N (Origin-narrative-confirmed) -- companion /
|
|
789
|
+
# related-plan / sibling-mention references that merely cite #N do NOT
|
|
790
|
+
# block ingest.
|
|
791
|
+
refs = (
|
|
792
|
+
existing_refs
|
|
793
|
+
if existing_refs is not None
|
|
794
|
+
else _scan_provenance_refs(vbrief_dir)
|
|
795
|
+
)
|
|
796
|
+
if number in refs:
|
|
797
|
+
existing = refs[number][0]
|
|
798
|
+
return "duplicate", vbrief_dir / existing, f"#{number} already ingested at {existing}"
|
|
799
|
+
|
|
800
|
+
vbrief, folder = _build_issue_vbrief(issue, status, repo_url)
|
|
801
|
+
filename = _target_filename(number, str(issue.get("title", "")))
|
|
802
|
+
target = vbrief_dir / folder / filename
|
|
803
|
+
|
|
804
|
+
if dry_run:
|
|
805
|
+
return "dryrun", target, f"DRY-RUN would write {folder}/{filename}"
|
|
806
|
+
|
|
807
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
808
|
+
target.write_text(
|
|
809
|
+
json.dumps(vbrief, indent=2, ensure_ascii=False) + "\n",
|
|
810
|
+
encoding="utf-8",
|
|
811
|
+
)
|
|
812
|
+
return "created", target, f"CREATED {folder}/{filename}"
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def ingest_bulk(
|
|
816
|
+
issues: list[dict],
|
|
817
|
+
*,
|
|
818
|
+
vbrief_dir: Path,
|
|
819
|
+
status: str,
|
|
820
|
+
repo_url: str,
|
|
821
|
+
label: str | None = None,
|
|
822
|
+
dry_run: bool = False,
|
|
823
|
+
) -> dict:
|
|
824
|
+
"""Ingest a list of issues.
|
|
825
|
+
|
|
826
|
+
Filters by ``label`` first (if provided), then delegates to
|
|
827
|
+
``ingest_one`` for each remaining issue. Returns a summary dict:
|
|
828
|
+
``{"created": [...], "duplicate": [...], "dryrun": [...], "total": N}``.
|
|
829
|
+
"""
|
|
830
|
+
if label:
|
|
831
|
+
filtered = []
|
|
832
|
+
for issue in issues:
|
|
833
|
+
for lbl in issue.get("labels", []) or []:
|
|
834
|
+
name = lbl.get("name") if isinstance(lbl, dict) else str(lbl)
|
|
835
|
+
if name == label:
|
|
836
|
+
filtered.append(issue)
|
|
837
|
+
break
|
|
838
|
+
issues = filtered
|
|
839
|
+
|
|
840
|
+
# #1096: provenance-aware dedup. See :func:`_scan_provenance_refs`.
|
|
841
|
+
refs = _scan_provenance_refs(vbrief_dir)
|
|
842
|
+
|
|
843
|
+
# Values are list[str] for the three bucket keys and int for "total",
|
|
844
|
+
# hence the union annotation.
|
|
845
|
+
summary: dict[str, list[str] | int] = {"created": [], "duplicate": [], "dryrun": []}
|
|
846
|
+
for issue in issues:
|
|
847
|
+
result, path, _msg = ingest_one(
|
|
848
|
+
issue,
|
|
849
|
+
vbrief_dir=vbrief_dir,
|
|
850
|
+
status=status,
|
|
851
|
+
repo_url=repo_url,
|
|
852
|
+
dry_run=dry_run,
|
|
853
|
+
existing_refs=refs,
|
|
854
|
+
)
|
|
855
|
+
summary[result].append(str(path.relative_to(vbrief_dir)) if path else "")
|
|
856
|
+
# After a real write the refs map would now contain this number;
|
|
857
|
+
# update in place so duplicates inside the same batch are detected.
|
|
858
|
+
if result == "created":
|
|
859
|
+
refs.setdefault(int(issue["number"]), []).append(
|
|
860
|
+
str(path.relative_to(vbrief_dir))
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
summary["total"] = len(issues)
|
|
864
|
+
return summary
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# --- CLI --------------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _resolve_repo_url(repo: str) -> str:
|
|
871
|
+
"""Produce a browser URL from an OWNER/REPO pair (or empty if none)."""
|
|
872
|
+
if not repo:
|
|
873
|
+
return ""
|
|
874
|
+
if repo.startswith(("http://", "https://")):
|
|
875
|
+
return repo.rstrip("/")
|
|
876
|
+
if re.match(r"^[^/]+/[^/]+$", repo):
|
|
877
|
+
return f"https://github.com/{repo}"
|
|
878
|
+
return ""
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
882
|
+
parser = argparse.ArgumentParser(
|
|
883
|
+
description="Ingest GitHub issues as scope vBRIEFs in vbrief/ lifecycle folders.",
|
|
884
|
+
)
|
|
885
|
+
parser.add_argument(
|
|
886
|
+
"number",
|
|
887
|
+
nargs="?",
|
|
888
|
+
type=int,
|
|
889
|
+
help="GitHub issue number to ingest (single-issue mode)",
|
|
890
|
+
)
|
|
891
|
+
parser.add_argument(
|
|
892
|
+
"--all",
|
|
893
|
+
action="store_true",
|
|
894
|
+
help="Bulk mode -- ingest all open issues (optionally filtered by --label)",
|
|
895
|
+
)
|
|
896
|
+
parser.add_argument(
|
|
897
|
+
"--label",
|
|
898
|
+
default=None,
|
|
899
|
+
help="Only ingest issues carrying this label (bulk mode)",
|
|
900
|
+
)
|
|
901
|
+
parser.add_argument(
|
|
902
|
+
"--status",
|
|
903
|
+
default="proposed",
|
|
904
|
+
choices=INGEST_STATUSES,
|
|
905
|
+
help="Target lifecycle folder / plan.status (default: proposed)",
|
|
906
|
+
)
|
|
907
|
+
parser.add_argument(
|
|
908
|
+
"--dry-run",
|
|
909
|
+
action="store_true",
|
|
910
|
+
help="Print what would be written without creating files",
|
|
911
|
+
)
|
|
912
|
+
parser.add_argument(
|
|
913
|
+
"--vbrief-dir",
|
|
914
|
+
default="./vbrief",
|
|
915
|
+
help="Path to vbrief/ directory (default: ./vbrief)",
|
|
916
|
+
)
|
|
917
|
+
parser.add_argument(
|
|
918
|
+
"--repo",
|
|
919
|
+
default=None,
|
|
920
|
+
help=(
|
|
921
|
+
"GitHub repo in OWNER/REPO format. Highest precedence; beats "
|
|
922
|
+
"$DEFT_PROJECT_REPO and git-remote detection. Without a flag, "
|
|
923
|
+
"env var, or git remote in the project root the script FAILS "
|
|
924
|
+
"loudly rather than silently falling back to deft's own remote "
|
|
925
|
+
"(#538)."
|
|
926
|
+
),
|
|
927
|
+
)
|
|
928
|
+
parser.add_argument(
|
|
929
|
+
"--project-root",
|
|
930
|
+
default=None,
|
|
931
|
+
help=(
|
|
932
|
+
"Consumer project root. Used as CWD for git-remote detection "
|
|
933
|
+
"so ``gh`` / ``git`` queries target the consumer repo, not "
|
|
934
|
+
"deftai/directive (#538)."
|
|
935
|
+
),
|
|
936
|
+
)
|
|
937
|
+
return parser
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def main(argv: list[str] | None = None) -> int:
|
|
941
|
+
parser = build_parser()
|
|
942
|
+
args = parser.parse_args(argv)
|
|
943
|
+
|
|
944
|
+
if args.number is None and not args.all:
|
|
945
|
+
parser.error("Provide an issue number or --all")
|
|
946
|
+
|
|
947
|
+
if args.number is not None and args.all:
|
|
948
|
+
parser.error("Use either a single issue number OR --all, not both")
|
|
949
|
+
|
|
950
|
+
vbrief_dir = Path(args.vbrief_dir).resolve()
|
|
951
|
+
if not vbrief_dir.exists():
|
|
952
|
+
vbrief_dir.mkdir(parents=True, exist_ok=True)
|
|
953
|
+
|
|
954
|
+
project_root = resolve_project_root(args.project_root)
|
|
955
|
+
repo = resolve_project_repo(args.repo, project_root=project_root)
|
|
956
|
+
# Fall back to the legacy CWD-scoped ``detect_repo`` only when no
|
|
957
|
+
# project root could be inferred; that path still exists because
|
|
958
|
+
# some in-process test suites monkeypatch ``detect_repo`` directly.
|
|
959
|
+
if not repo:
|
|
960
|
+
repo = detect_repo()
|
|
961
|
+
if not repo:
|
|
962
|
+
print(
|
|
963
|
+
"Error: could not detect repo. "
|
|
964
|
+
"Pass --repo OWNER/NAME, set $DEFT_PROJECT_REPO, or run from "
|
|
965
|
+
"a directory tree whose git remote origin is the consumer "
|
|
966
|
+
"repo (#538).",
|
|
967
|
+
file=sys.stderr,
|
|
968
|
+
)
|
|
969
|
+
return 2
|
|
970
|
+
repo_url = _resolve_repo_url(repo)
|
|
971
|
+
|
|
972
|
+
if args.all:
|
|
973
|
+
issues = fetch_open_issues(repo, cwd=project_root)
|
|
974
|
+
if issues is None:
|
|
975
|
+
return 2
|
|
976
|
+
summary = ingest_bulk(
|
|
977
|
+
issues,
|
|
978
|
+
vbrief_dir=vbrief_dir,
|
|
979
|
+
status=args.status,
|
|
980
|
+
repo_url=repo_url,
|
|
981
|
+
label=args.label,
|
|
982
|
+
dry_run=args.dry_run,
|
|
983
|
+
)
|
|
984
|
+
print(
|
|
985
|
+
"issue:ingest bulk summary: "
|
|
986
|
+
f"{len(summary['created'])} created, "
|
|
987
|
+
f"{len(summary['duplicate'])} duplicate, "
|
|
988
|
+
f"{len(summary['dryrun'])} dry-run "
|
|
989
|
+
f"(total considered: {summary['total']})"
|
|
990
|
+
)
|
|
991
|
+
for entry in summary["created"]:
|
|
992
|
+
print(f" CREATED {entry}")
|
|
993
|
+
for entry in summary["dryrun"]:
|
|
994
|
+
print(f" DRY-RUN {entry}")
|
|
995
|
+
for entry in summary["duplicate"]:
|
|
996
|
+
print(f" SKIP {entry} (already has scope vBRIEF)")
|
|
997
|
+
return 0
|
|
998
|
+
|
|
999
|
+
# Single-issue mode -- prefer the unified cache (#883/#988) before
|
|
1000
|
+
# falling back to a live ``gh api`` round-trip.
|
|
1001
|
+
issue = _fetch_issue(repo, args.number, cwd=project_root)
|
|
1002
|
+
if issue is None:
|
|
1003
|
+
return 2
|
|
1004
|
+
result, path, msg = ingest_one(
|
|
1005
|
+
issue,
|
|
1006
|
+
vbrief_dir=vbrief_dir,
|
|
1007
|
+
status=args.status,
|
|
1008
|
+
repo_url=repo_url,
|
|
1009
|
+
dry_run=args.dry_run,
|
|
1010
|
+
)
|
|
1011
|
+
print(msg)
|
|
1012
|
+
if result == "duplicate":
|
|
1013
|
+
return 1
|
|
1014
|
+
return 0
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def ingest_single_for_accept(
|
|
1018
|
+
n: int,
|
|
1019
|
+
repo: str,
|
|
1020
|
+
*,
|
|
1021
|
+
project_root: Path | None = None,
|
|
1022
|
+
status: str = "proposed",
|
|
1023
|
+
cache_root: Path | None = None,
|
|
1024
|
+
) -> tuple[str, Path | None]:
|
|
1025
|
+
"""Ingest a single issue on behalf of ``triage_actions.accept`` (#985).
|
|
1026
|
+
|
|
1027
|
+
The triage skill's contract is that ``task triage:accept`` delegates the
|
|
1028
|
+
actual vBRIEF authoring to ``task issue:ingest`` so slug / reference /
|
|
1029
|
+
schema rules stay in one place (per ``conventions/references.md`` and
|
|
1030
|
+
``skills/deft-directive-refinement/SKILL.md`` Phase 0 Tier 3). This is
|
|
1031
|
+
the importable Python entry point that ``scripts/triage_actions.py::accept``
|
|
1032
|
+
calls after the audit-log append succeeds.
|
|
1033
|
+
|
|
1034
|
+
Resolves ``vbrief_dir`` to ``<project_root>/vbrief`` (created on demand)
|
|
1035
|
+
and the ``repo_url`` to the canonical browser URL via
|
|
1036
|
+
:func:`_resolve_repo_url`. Fetches the issue via :func:`_fetch_issue`
|
|
1037
|
+
(cache-first per #988) and writes the vBRIEF via :func:`ingest_one`.
|
|
1038
|
+
|
|
1039
|
+
Returns the ``(result, path)`` tuple from :func:`ingest_one`. Raises
|
|
1040
|
+
:class:`RuntimeError` on fetch failure so the caller (``accept``) can
|
|
1041
|
+
roll the audit-log entry back.
|
|
1042
|
+
"""
|
|
1043
|
+
root = (project_root or Path.cwd()).resolve()
|
|
1044
|
+
vbrief_dir = (root / "vbrief").resolve()
|
|
1045
|
+
if not vbrief_dir.exists():
|
|
1046
|
+
vbrief_dir.mkdir(parents=True, exist_ok=True)
|
|
1047
|
+
repo_url = _resolve_repo_url(repo)
|
|
1048
|
+
issue = _fetch_issue(repo, n, cwd=root, cache_root=cache_root)
|
|
1049
|
+
if issue is None:
|
|
1050
|
+
raise RuntimeError(
|
|
1051
|
+
f"failed to fetch GitHub issue #{n} from {repo} "
|
|
1052
|
+
"(unified cache miss + live gh api fetch failed; see stderr)"
|
|
1053
|
+
)
|
|
1054
|
+
result, path, _msg = ingest_one(
|
|
1055
|
+
issue,
|
|
1056
|
+
vbrief_dir=vbrief_dir,
|
|
1057
|
+
status=status,
|
|
1058
|
+
repo_url=repo_url,
|
|
1059
|
+
)
|
|
1060
|
+
return result, path
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
if __name__ == "__main__":
|
|
1064
|
+
raise SystemExit(main())
|