@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,1153 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""triage_bootstrap.py -- idempotent triage v1 installer (#883 Story 3 rebind).
|
|
3
|
+
|
|
4
|
+
Single-command opt-in for triage v1. Wires the consumer's project for
|
|
5
|
+
the pre-ingest triage workflow without touching any existing vBRIEF,
|
|
6
|
+
scope, or skill state. Designed to be re-runnable: every step is
|
|
7
|
+
idempotent and a second invocation is a no-op.
|
|
8
|
+
|
|
9
|
+
Steps (in order):
|
|
10
|
+
|
|
11
|
+
1. ``populate_cache`` -- delegates to :func:`scripts.cache.cache_fetch_all`
|
|
12
|
+
with ``--source=github-issue`` to mirror upstream issues into
|
|
13
|
+
``.deft-cache/github-issue/<owner>/<repo>/<N>/``. Gracefully degrades
|
|
14
|
+
to a deferred-action message when ``--repo`` is neither passed nor
|
|
15
|
+
inferable from ``git remote get-url origin`` and when the cache
|
|
16
|
+
module has not yet landed on the consumer's branch.
|
|
17
|
+
|
|
18
|
+
2. ``backfill_audit_log`` -- writes one ``accept`` audit entry per
|
|
19
|
+
scope vBRIEF currently in ``vbrief/proposed/``, ``vbrief/pending/``,
|
|
20
|
+
or ``vbrief/active/``. Skips ``vbrief/cancelled/`` so rejected items
|
|
21
|
+
are NOT reanimated. Skips ``vbrief/completed/`` because completed
|
|
22
|
+
work is not in the triage funnel. Delegates to
|
|
23
|
+
:func:`scripts.candidates_log.append` when present; falls back to a
|
|
24
|
+
self-contained JSONL append when not.
|
|
25
|
+
|
|
26
|
+
3. ``ensure_gitignore_entry`` -- append ``.deft-cache/`` to
|
|
27
|
+
``.gitignore`` when absent.
|
|
28
|
+
|
|
29
|
+
4. ``ensure_gitignore_eval_entries`` -- ensure the #1144 hybrid policy
|
|
30
|
+
is encoded: append the selective ``candidates.jsonl`` /
|
|
31
|
+
``summary-history.jsonl`` / ``scope-lifecycle.jsonl`` entries to
|
|
32
|
+
``.gitignore`` when missing, add the
|
|
33
|
+
``vbrief/.eval/*.jsonl merge=union`` rule to ``.gitattributes``,
|
|
34
|
+
and write the canonical ``vbrief/.eval/README.md``. Replaces the
|
|
35
|
+
pre-#1251 ``ensure_gitignore_eval_dir`` which appended a blanket
|
|
36
|
+
``vbrief/.eval/`` line that silently ignored the tracked
|
|
37
|
+
``slices.jsonl`` (#1132 / D13).
|
|
38
|
+
|
|
39
|
+
5. ``seed_candidates_log`` -- ensure ``vbrief/.eval/candidates.jsonl``
|
|
40
|
+
exists as a zero-length file so ``task verify:cache-fresh`` can
|
|
41
|
+
distinguish a never-bootstrapped consumer (no cache) from a
|
|
42
|
+
freshly-bootstrapped one (cache + empty audit log). Option A of
|
|
43
|
+
issue #1240.
|
|
44
|
+
|
|
45
|
+
Exit codes (three-state, mirrors ``scripts/preflight_branch.py``):
|
|
46
|
+
|
|
47
|
+
- ``0`` -- bootstrap completed (or all steps were no-ops on a re-run).
|
|
48
|
+
- ``1`` -- bootstrap failed at a runtime step.
|
|
49
|
+
- ``2`` -- config error: ``--project-root`` doesn't exist or isn't a
|
|
50
|
+
directory.
|
|
51
|
+
|
|
52
|
+
Refs:
|
|
53
|
+
|
|
54
|
+
- #883 (parent epic for the unified cache rebind).
|
|
55
|
+
- #845 (the pre-ingest triage workflow this script orchestrates).
|
|
56
|
+
- ``docs/privacy-nfr.md`` -- privacy contract for ``.deft-cache/``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import argparse
|
|
62
|
+
import contextlib
|
|
63
|
+
import datetime as _dt
|
|
64
|
+
import importlib
|
|
65
|
+
import json
|
|
66
|
+
import os
|
|
67
|
+
import re
|
|
68
|
+
import shutil
|
|
69
|
+
import subprocess
|
|
70
|
+
import sys
|
|
71
|
+
import threading
|
|
72
|
+
import time
|
|
73
|
+
import uuid
|
|
74
|
+
from collections.abc import Callable
|
|
75
|
+
from dataclasses import dataclass, field
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
from typing import Any
|
|
78
|
+
|
|
79
|
+
# Make sibling ``scripts`` modules importable when the consumer invokes
|
|
80
|
+
# this script via ``python scripts/triage_bootstrap.py`` from the
|
|
81
|
+
# project root.
|
|
82
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
83
|
+
|
|
84
|
+
# UTF-8-safe subprocess capture (#1366 / #1002). MUST be imported after the
|
|
85
|
+
# ``sys.path`` insert above so the sibling helper resolves whether deft is the
|
|
86
|
+
# project root or installed as a ``deft/`` subdirectory.
|
|
87
|
+
from _safe_subprocess import run_text # noqa: E402 -- needs sys.path insert above
|
|
88
|
+
|
|
89
|
+
# UTF-8 self-reconfigure (mirrors #814 fix). The Windows cp1252 default
|
|
90
|
+
# would crash on the ✓ / ⚠ glyphs we print in the recap.
|
|
91
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
92
|
+
if hasattr(_stream, "reconfigure"):
|
|
93
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
94
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Canonical cache-directory name. The unified cache writes to
|
|
98
|
+
#: ``.deft-cache/github-issue/<owner>/<repo>/<N>/`` under #883 Story 2.
|
|
99
|
+
CACHE_DIR_NAME = ".deft-cache"
|
|
100
|
+
|
|
101
|
+
#: Canonical audit-log path relative to the project root.
|
|
102
|
+
AUDIT_LOG_RELPATH = Path("vbrief") / ".eval" / "candidates.jsonl"
|
|
103
|
+
|
|
104
|
+
#: Lifecycle folders whose contents are backfilled with ``accept``
|
|
105
|
+
#: entries. ``cancelled/`` is excluded so rejected items are not
|
|
106
|
+
#: reanimated; ``completed/`` is excluded because completed work is no
|
|
107
|
+
#: longer in the triage funnel.
|
|
108
|
+
BACKFILL_FOLDERS = ("proposed", "pending", "active")
|
|
109
|
+
|
|
110
|
+
#: Canonical actor for bootstrap-emitted backfill entries.
|
|
111
|
+
BOOTSTRAP_ACTOR = "agent:bootstrap"
|
|
112
|
+
|
|
113
|
+
#: Cache source consumed by triage v1 (only github-issue is supported).
|
|
114
|
+
_CACHE_SOURCE: str = "github-issue"
|
|
115
|
+
|
|
116
|
+
#: Default wall-clock cap (seconds) on the cache:fetch-all step. The
|
|
117
|
+
#: underlying ``cache.cache_fetch_all`` shells out to ``task
|
|
118
|
+
#: scm:issue:view`` per issue with no per-call timeout, so a stuck
|
|
119
|
+
#: ``gh``/``ghx`` process (auth re-prompt, network stall, server-side
|
|
120
|
+
#: hang) will block the orchestrator indefinitely. The watchdog in
|
|
121
|
+
#: :func:`step_populate_cache` enforces this cap so the orchestrator
|
|
122
|
+
#: always exits in bounded time even when the underlying subprocess
|
|
123
|
+
#: tree is wedged. Override via ``--fetch-timeout-s`` or the
|
|
124
|
+
#: ``DEFT_BOOTSTRAP_FETCH_TIMEOUT_S`` env var. Set to ``0`` to disable
|
|
125
|
+
#: (legacy unbounded behavior). Sized for a 1000-issue full-backlog run
|
|
126
|
+
#: at the default 500ms inter-issue delay (#952).
|
|
127
|
+
DEFAULT_FETCH_TIMEOUT_S: int = 3600
|
|
128
|
+
|
|
129
|
+
#: subprocess.run timeout for ``git remote get-url origin``. Defensive:
|
|
130
|
+
#: a stuck ``git`` proxy (corporate VPN re-auth) would otherwise hang
|
|
131
|
+
#: bootstrap before any progress line is emitted.
|
|
132
|
+
_GIT_INFER_TIMEOUT_S: int = 10
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class StepOutcome:
|
|
137
|
+
"""Per-step result captured by the dispatcher."""
|
|
138
|
+
|
|
139
|
+
name: str
|
|
140
|
+
ok: bool
|
|
141
|
+
message: str
|
|
142
|
+
error: str | None = None
|
|
143
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Progress emit (#952)
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
#
|
|
150
|
+
# A real-world v0.26.0 backlog smoke (docs/smoke-2026-05-06-v0.26.0-scale.md)
|
|
151
|
+
# saw the orchestrator silently hang for 71+ minutes after cache:fetch-all
|
|
152
|
+
# *appeared* to complete -- the operator had no per-step visibility so could
|
|
153
|
+
# not tell whether the script was wedged inside step_populate_cache (the
|
|
154
|
+
# real culprit) or one of the post-fetch steps. The structured stderr lines
|
|
155
|
+
# below match the cadence of ``scripts/cache.py`` / ``cache_scanner.py``
|
|
156
|
+
# status output and let future operators (and the integration test) verify
|
|
157
|
+
# that each step is entered and exited.
|
|
158
|
+
|
|
159
|
+
#: Total number of steps executed by :func:`run_bootstrap`. Update if a
|
|
160
|
+
#: step is added or removed so the ``step <i>/<TOTAL>`` numerator stays
|
|
161
|
+
#: accurate. v0.32.0 (#1240): add step 5 ``seed_candidates_log``.
|
|
162
|
+
_TOTAL_STEPS: int = 5
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _emit_progress(
|
|
166
|
+
out: object | None,
|
|
167
|
+
step_index: int,
|
|
168
|
+
name: str,
|
|
169
|
+
phase: str,
|
|
170
|
+
detail: str = "",
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Write a single ``triage:bootstrap step <i>/<N> <name> -- <phase>`` line.
|
|
173
|
+
|
|
174
|
+
``out`` is any file-like object with a ``write()`` method (or ``None``
|
|
175
|
+
to silence emission, e.g. inside test fixtures that don't capture
|
|
176
|
+
stderr). The phase string is one of ``starting`` / ``done`` /
|
|
177
|
+
``error`` / ``timeout``; callers are free to add a parenthetical
|
|
178
|
+
``detail`` for cardinality (e.g. counts, repo, elapsed seconds).
|
|
179
|
+
"""
|
|
180
|
+
if out is None:
|
|
181
|
+
return
|
|
182
|
+
line = f"triage:bootstrap step {step_index}/{_TOTAL_STEPS} {name} -- {phase}"
|
|
183
|
+
if detail:
|
|
184
|
+
line = f"{line} ({detail})"
|
|
185
|
+
try:
|
|
186
|
+
out.write(line + "\n")
|
|
187
|
+
flush = getattr(out, "flush", None)
|
|
188
|
+
if callable(flush):
|
|
189
|
+
flush()
|
|
190
|
+
except (OSError, ValueError):
|
|
191
|
+
# A closed-stream / broken-pipe write must never propagate from
|
|
192
|
+
# an observability path; the bootstrap result is canonical.
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
#: Sentinel separating "func() returned None" from "runner thread died
|
|
197
|
+
#: before assigning result" (Greptile P1 cleanup for #955).
|
|
198
|
+
_RUNNER_UNSET: Any = object()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run_with_timeout(
|
|
202
|
+
func: Callable[[], Any],
|
|
203
|
+
timeout_s: float,
|
|
204
|
+
) -> tuple[bool, Any, Exception | None]:
|
|
205
|
+
"""Run ``func()`` in a daemon thread; return ``(completed, result, exc)``.
|
|
206
|
+
|
|
207
|
+
``completed`` is False when ``timeout_s`` elapsed; the daemon thread
|
|
208
|
+
is left running (load-bearing property for #952). Non-positive
|
|
209
|
+
``timeout_s`` disables the watchdog (legacy unbounded behavior).
|
|
210
|
+
|
|
211
|
+
Only :class:`Exception` is captured into ``box["exc"]``. A
|
|
212
|
+
:class:`BaseException` raised inside the daemon thread (``SystemExit`` /
|
|
213
|
+
``MemoryError`` / ...) terminates the runner silently -- Python
|
|
214
|
+
threading does not propagate ``BaseException`` to the joining thread.
|
|
215
|
+
To stop that masquerading as ``ok=True`` with ``succeeded=None``,
|
|
216
|
+
``box["result"]`` starts as a sentinel; a thread that joins without
|
|
217
|
+
setting either slot synthesizes a :class:`RuntimeError` so
|
|
218
|
+
:func:`step_populate_cache` reports ``ok=False`` (Greptile P1
|
|
219
|
+
cleanup for #955). Operator-issued Ctrl+C is unaffected.
|
|
220
|
+
"""
|
|
221
|
+
box: dict[str, Any] = {"result": _RUNNER_UNSET, "exc": None}
|
|
222
|
+
|
|
223
|
+
def _runner() -> None:
|
|
224
|
+
try:
|
|
225
|
+
box["result"] = func()
|
|
226
|
+
except Exception as exc: # noqa: BLE001 -- forward verbatim
|
|
227
|
+
box["exc"] = exc
|
|
228
|
+
|
|
229
|
+
thread = threading.Thread(
|
|
230
|
+
target=_runner, name="triage_bootstrap.populate_cache", daemon=True
|
|
231
|
+
)
|
|
232
|
+
thread.start()
|
|
233
|
+
thread.join(timeout_s if timeout_s and timeout_s > 0 else None)
|
|
234
|
+
if thread.is_alive():
|
|
235
|
+
return False, None, None
|
|
236
|
+
if box["result"] is _RUNNER_UNSET and box["exc"] is None:
|
|
237
|
+
# Thread joined without result OR exc -- unhandled BaseException.
|
|
238
|
+
return True, None, RuntimeError(
|
|
239
|
+
"worker thread terminated without completing "
|
|
240
|
+
"(unhandled BaseException not propagated by Python threading)"
|
|
241
|
+
)
|
|
242
|
+
result = None if box["result"] is _RUNNER_UNSET else box["result"]
|
|
243
|
+
return True, result, box["exc"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class BootstrapResult:
|
|
248
|
+
"""Aggregate result returned by :func:`run_bootstrap`."""
|
|
249
|
+
|
|
250
|
+
project_root: Path
|
|
251
|
+
repo: str | None
|
|
252
|
+
steps: list[StepOutcome] = field(default_factory=list)
|
|
253
|
+
exit_code: int = 0
|
|
254
|
+
|
|
255
|
+
def summary(self) -> str:
|
|
256
|
+
"""Render a recap the operator sees at the end of bootstrap."""
|
|
257
|
+
|
|
258
|
+
lines = ["", "Triage v1 bootstrap recap:"]
|
|
259
|
+
for step in self.steps:
|
|
260
|
+
mark = "✓" if step.ok else "✗"
|
|
261
|
+
lines.append(f" {mark} {step.name}: {step.message}")
|
|
262
|
+
if step.error:
|
|
263
|
+
lines.append(f" error: {step.error}")
|
|
264
|
+
if self.exit_code == 0:
|
|
265
|
+
lines.append("")
|
|
266
|
+
lines.append("Next steps:")
|
|
267
|
+
lines.append(
|
|
268
|
+
" task cache:fetch-all -- --source=github-issue "
|
|
269
|
+
"--repo OWNER/NAME # refresh the cache (#883 Story 2)"
|
|
270
|
+
)
|
|
271
|
+
lines.append(
|
|
272
|
+
" task cache:get -- github-issue OWNER/NAME/<N> "
|
|
273
|
+
"# inspect cached issue N"
|
|
274
|
+
)
|
|
275
|
+
lines.append(
|
|
276
|
+
" task triage:accept -- --issue <N> --repo OWNER/NAME "
|
|
277
|
+
"# accept issue N (#845 Story 3)"
|
|
278
|
+
)
|
|
279
|
+
lines.append(
|
|
280
|
+
" task triage:reject -- --issue <N> --repo OWNER/NAME --reason 'why' "
|
|
281
|
+
"# reject issue N"
|
|
282
|
+
)
|
|
283
|
+
lines.append(
|
|
284
|
+
" task triage:bulk-accept -- --repo OWNER/NAME --label adoption-blocker "
|
|
285
|
+
"# bulk accept"
|
|
286
|
+
)
|
|
287
|
+
lines.append(
|
|
288
|
+
" task triage:refresh-active "
|
|
289
|
+
"# pre-swarm freshness gate"
|
|
290
|
+
)
|
|
291
|
+
return "\n".join(lines)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Repo resolution
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
#: Regex mapping a ``git remote get-url origin`` value to ``(owner, repo)``.
|
|
299
|
+
_GIT_ORIGIN_RE = re.compile(
|
|
300
|
+
r"^(?:https?://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com[:/])"
|
|
301
|
+
r"(?P<owner>[A-Za-z0-9][A-Za-z0-9._-]*)/"
|
|
302
|
+
r"(?P<repo>[A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?/?\s*$"
|
|
303
|
+
)
|
|
304
|
+
_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _infer_repo_from_git(cwd: Path | None = None) -> str | None:
|
|
308
|
+
"""Infer ``owner/repo`` from ``git remote get-url origin``.
|
|
309
|
+
|
|
310
|
+
A bounded ``timeout`` is applied to the subprocess call so a stuck
|
|
311
|
+
``git`` proxy (corporate VPN re-auth, hung credential helper)
|
|
312
|
+
cannot wedge the orchestrator before any progress line lands
|
|
313
|
+
(#952 defensive). On timeout / OSError the function returns
|
|
314
|
+
``None`` and the caller falls back to its existing skip-with-OK
|
|
315
|
+
branch.
|
|
316
|
+
|
|
317
|
+
The capture is routed through :func:`_safe_subprocess.run_text`
|
|
318
|
+
(#1366), which FORCES ``encoding="utf-8", errors="replace"`` so a
|
|
319
|
+
non-ASCII byte on the captured stream (e.g. a localized ``git``
|
|
320
|
+
warning on stderr) decodes to U+FFFD instead of crashing Python's
|
|
321
|
+
subprocess reader thread with ``UnicodeDecodeError`` under the
|
|
322
|
+
Windows cp1252 codepage (#1002, the #798 chain at the
|
|
323
|
+
subprocess-read surface).
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
if shutil.which("git") is None:
|
|
327
|
+
return None
|
|
328
|
+
try:
|
|
329
|
+
proc = run_text(
|
|
330
|
+
["git", "remote", "get-url", "origin"],
|
|
331
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
332
|
+
timeout=_GIT_INFER_TIMEOUT_S,
|
|
333
|
+
)
|
|
334
|
+
except (OSError, subprocess.SubprocessError):
|
|
335
|
+
return None
|
|
336
|
+
if proc.returncode != 0:
|
|
337
|
+
return None
|
|
338
|
+
url = (proc.stdout or "").strip()
|
|
339
|
+
if not url:
|
|
340
|
+
return None
|
|
341
|
+
m = _GIT_ORIGIN_RE.search(url)
|
|
342
|
+
if not m:
|
|
343
|
+
return None
|
|
344
|
+
return f"{m.group('owner')}/{m.group('repo')}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# Step 1 -- populate cache via cache:fetch-all
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _load_cache_module() -> Any | None:
|
|
353
|
+
"""Return the unified cache module, or ``None`` if not importable."""
|
|
354
|
+
|
|
355
|
+
for candidate in ("cache", "scripts.cache"):
|
|
356
|
+
try:
|
|
357
|
+
return importlib.import_module(candidate)
|
|
358
|
+
except ModuleNotFoundError:
|
|
359
|
+
continue
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def step_populate_cache(
|
|
364
|
+
project_root: Path,
|
|
365
|
+
repo: str | None,
|
|
366
|
+
*,
|
|
367
|
+
cache_module: Any | None = None,
|
|
368
|
+
batch_size: int | None = None,
|
|
369
|
+
delay_ms: int | None = None,
|
|
370
|
+
state: str | None = None,
|
|
371
|
+
limit: int | None = None,
|
|
372
|
+
labels: tuple[str, ...] | None = None,
|
|
373
|
+
author: str | None = None,
|
|
374
|
+
fetch_timeout_s: float | None = None,
|
|
375
|
+
) -> StepOutcome:
|
|
376
|
+
"""Mirror upstream issues for ``repo`` via :func:`cache_fetch_all`.
|
|
377
|
+
|
|
378
|
+
``labels`` (#1033) and ``author`` (#1055) scope the cache:fetch-all
|
|
379
|
+
enumeration so an operator can bootstrap against a subset of the
|
|
380
|
+
backlog (e.g. one label, or one author) instead of the entire open
|
|
381
|
+
queue. Both forward to :func:`cache.cache_fetch_all` and compose
|
|
382
|
+
with AND semantics when supplied together.
|
|
383
|
+
|
|
384
|
+
Resolution precedence for ``repo``:
|
|
385
|
+
|
|
386
|
+
1. Explicit argument (kwargs / ``--repo`` flag / ``DEFT_TRIAGE_REPO`` env).
|
|
387
|
+
2. Inference from ``git remote get-url origin`` inside ``project_root``.
|
|
388
|
+
|
|
389
|
+
When neither resolves, the step returns ``ok=True`` with a friendly
|
|
390
|
+
skip message -- the gitignore + audit-log steps are still useful
|
|
391
|
+
without a repo. When the cache module is missing on the branch the
|
|
392
|
+
step degrades to a deferred-action message so the bootstrap exit
|
|
393
|
+
code stays 0 (per the re-runnable contract).
|
|
394
|
+
|
|
395
|
+
``fetch_timeout_s`` is the wall-clock cap on the wrapped
|
|
396
|
+
``cache.cache_fetch_all`` call; ``None`` selects
|
|
397
|
+
:data:`DEFAULT_FETCH_TIMEOUT_S`. ``0`` disables the watchdog and
|
|
398
|
+
restores the legacy unbounded behavior. The watchdog is the load-
|
|
399
|
+
bearing fix for #952: a stuck ``task scm:issue:view`` subprocess
|
|
400
|
+
(auth re-prompt, network stall, server hang) can no longer wedge
|
|
401
|
+
the orchestrator past this cap, even though Python cannot
|
|
402
|
+
reliably interrupt the underlying process tree.
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
effective_repo = repo
|
|
406
|
+
if effective_repo is None:
|
|
407
|
+
effective_repo = _infer_repo_from_git(cwd=project_root)
|
|
408
|
+
if effective_repo is None:
|
|
409
|
+
return StepOutcome(
|
|
410
|
+
name="populate_cache",
|
|
411
|
+
ok=True,
|
|
412
|
+
message=(
|
|
413
|
+
"skipped (no --repo provided and could not infer from "
|
|
414
|
+
"`git remote get-url origin`; pass --repo OWNER/NAME)"
|
|
415
|
+
),
|
|
416
|
+
details={"skipped": "no-repo"},
|
|
417
|
+
)
|
|
418
|
+
if not _REPO_RE.match(effective_repo):
|
|
419
|
+
return StepOutcome(
|
|
420
|
+
name="populate_cache",
|
|
421
|
+
ok=False,
|
|
422
|
+
message=f"invalid --repo {effective_repo!r}",
|
|
423
|
+
error="repo must be 'owner/name' (alphanumerics, '.', '_', '-' only)",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
cache_mod = cache_module if cache_module is not None else _load_cache_module()
|
|
427
|
+
if cache_mod is None:
|
|
428
|
+
return StepOutcome(
|
|
429
|
+
name="populate_cache",
|
|
430
|
+
ok=True,
|
|
431
|
+
message=(
|
|
432
|
+
"deferred (scripts/cache.py not present on this branch; "
|
|
433
|
+
"re-run after rebase to populate via task cache:fetch-all)"
|
|
434
|
+
),
|
|
435
|
+
details={"deferred": "cache-module-missing", "repo": effective_repo},
|
|
436
|
+
)
|
|
437
|
+
fetch_all = getattr(cache_mod, "cache_fetch_all", None)
|
|
438
|
+
if not callable(fetch_all):
|
|
439
|
+
return StepOutcome(
|
|
440
|
+
name="populate_cache",
|
|
441
|
+
ok=False,
|
|
442
|
+
message="cache_fetch_all is not callable",
|
|
443
|
+
error="#883 Story 2 contract violated: cache_fetch_all() not exposed",
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
kwargs: dict[str, Any] = {
|
|
447
|
+
"source": _CACHE_SOURCE,
|
|
448
|
+
"repo": effective_repo,
|
|
449
|
+
"cache_root": project_root / CACHE_DIR_NAME,
|
|
450
|
+
}
|
|
451
|
+
if batch_size is not None:
|
|
452
|
+
kwargs["batch_size"] = batch_size
|
|
453
|
+
if delay_ms is not None:
|
|
454
|
+
kwargs["delay_ms"] = delay_ms
|
|
455
|
+
if state is not None:
|
|
456
|
+
kwargs["state"] = state
|
|
457
|
+
if limit is not None:
|
|
458
|
+
kwargs["limit"] = limit
|
|
459
|
+
if labels:
|
|
460
|
+
kwargs["labels"] = labels
|
|
461
|
+
if author is not None:
|
|
462
|
+
kwargs["author"] = author
|
|
463
|
+
|
|
464
|
+
effective_timeout = (
|
|
465
|
+
fetch_timeout_s if fetch_timeout_s is not None else DEFAULT_FETCH_TIMEOUT_S
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
started = time.monotonic()
|
|
469
|
+
completed, report, exc = _run_with_timeout(
|
|
470
|
+
lambda: fetch_all(**kwargs), effective_timeout
|
|
471
|
+
)
|
|
472
|
+
elapsed = time.monotonic() - started
|
|
473
|
+
|
|
474
|
+
if not completed:
|
|
475
|
+
return StepOutcome(
|
|
476
|
+
name="populate_cache",
|
|
477
|
+
ok=False,
|
|
478
|
+
message=(
|
|
479
|
+
f"cache:fetch-all wall-clock timeout after "
|
|
480
|
+
f"{effective_timeout:g}s for repo={effective_repo} "
|
|
481
|
+
"(an underlying `task scm:issue:view` subprocess is likely "
|
|
482
|
+
"stuck; re-run with --fetch-timeout-s=0 to disable the "
|
|
483
|
+
"watchdog or with a higher value, or shrink the run via "
|
|
484
|
+
"--limit / --state=open)"
|
|
485
|
+
),
|
|
486
|
+
error=(
|
|
487
|
+
f"step_populate_cache exceeded fetch_timeout_s={effective_timeout:g}; "
|
|
488
|
+
"see #952 for the watchdog rationale"
|
|
489
|
+
),
|
|
490
|
+
details={
|
|
491
|
+
"repo": effective_repo,
|
|
492
|
+
"source": _CACHE_SOURCE,
|
|
493
|
+
"fetch_timeout_s": effective_timeout,
|
|
494
|
+
"elapsed_s": round(elapsed, 3),
|
|
495
|
+
"timed_out": True,
|
|
496
|
+
},
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if exc is not None:
|
|
500
|
+
# cache_fetch_all raised; report the failure honestly so callers
|
|
501
|
+
# (and the orchestrator's recap) see a non-OK populate step. The
|
|
502
|
+
# bootstrap is partial -- ``run_bootstrap`` continues to the
|
|
503
|
+
# remaining (cache-independent) steps and surfaces ``exit_code=1``
|
|
504
|
+
# via the aggregate ``any(not step.ok)`` rule (P1 cleanup for
|
|
505
|
+
# #955; SLizard finding ``step_populate_cache misreports ok=True
|
|
506
|
+
# on exception``). Re-run after the underlying issue is resolved.
|
|
507
|
+
return StepOutcome(
|
|
508
|
+
name="populate_cache",
|
|
509
|
+
ok=False,
|
|
510
|
+
message=(
|
|
511
|
+
f"cache:fetch-all raised {type(exc).__name__} for repo="
|
|
512
|
+
f"{effective_repo} (re-run after the underlying issue is "
|
|
513
|
+
"resolved; see error for detail)"
|
|
514
|
+
),
|
|
515
|
+
error=str(exc),
|
|
516
|
+
details={
|
|
517
|
+
"failed": "fetch-all-error",
|
|
518
|
+
"exc_type": type(exc).__name__,
|
|
519
|
+
"repo": effective_repo,
|
|
520
|
+
"elapsed_s": round(elapsed, 3),
|
|
521
|
+
"fetch_timeout_s": effective_timeout,
|
|
522
|
+
},
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# #1247: FetchAllReport's counter names are being renamed to
|
|
526
|
+
# ``issues_written`` / ``already_fresh`` / ``issues_failed`` (PR
|
|
527
|
+
# #1254). When the new ``summary_line()`` renderer is available we
|
|
528
|
+
# delegate so the recap stays unambiguous; otherwise we fall back
|
|
529
|
+
# to the legacy hand-formatted string. The compatibility shim lets
|
|
530
|
+
# this PR land before or after #1254 without an ordering coupling.
|
|
531
|
+
succeeded = getattr(report, "succeeded", None)
|
|
532
|
+
failed = getattr(report, "failed", None)
|
|
533
|
+
skipped = getattr(report, "skipped", None)
|
|
534
|
+
summary_line = getattr(report, "summary_line", None)
|
|
535
|
+
legacy_message = (
|
|
536
|
+
f"cache:fetch-all source={_CACHE_SOURCE} repo={effective_repo} "
|
|
537
|
+
f"succeeded={succeeded} failed={failed} skipped={skipped}"
|
|
538
|
+
)
|
|
539
|
+
message = legacy_message
|
|
540
|
+
if callable(summary_line):
|
|
541
|
+
# Greptile P2 finding on PR #1256: pre-flight the kwarg shape
|
|
542
|
+
# via ``inspect.signature(...).bind(...)`` so a future
|
|
543
|
+
# signature change is the ONLY thing that re-routes us to the
|
|
544
|
+
# legacy path. A ``TypeError`` from inside ``summary_line()``
|
|
545
|
+
# itself (post-#1254 implementation bug) now propagates rather
|
|
546
|
+
# than silently falling back to a cryptic
|
|
547
|
+
# ``succeeded=None failed=None skipped=None`` recap.
|
|
548
|
+
import inspect
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
sig = inspect.signature(summary_line)
|
|
552
|
+
except (TypeError, ValueError):
|
|
553
|
+
sig = None
|
|
554
|
+
kwargs_ok = True
|
|
555
|
+
if sig is not None:
|
|
556
|
+
try:
|
|
557
|
+
sig.bind(source=_CACHE_SOURCE, repo=effective_repo)
|
|
558
|
+
except TypeError:
|
|
559
|
+
kwargs_ok = False
|
|
560
|
+
if kwargs_ok:
|
|
561
|
+
message = summary_line(source=_CACHE_SOURCE, repo=effective_repo)
|
|
562
|
+
return StepOutcome(
|
|
563
|
+
name="populate_cache",
|
|
564
|
+
ok=True,
|
|
565
|
+
message=message,
|
|
566
|
+
details={
|
|
567
|
+
"repo": effective_repo,
|
|
568
|
+
"source": _CACHE_SOURCE,
|
|
569
|
+
"succeeded": succeeded,
|
|
570
|
+
"failed": failed,
|
|
571
|
+
"skipped": skipped,
|
|
572
|
+
"elapsed_s": round(elapsed, 3),
|
|
573
|
+
"fetch_timeout_s": effective_timeout,
|
|
574
|
+
},
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ---------------------------------------------------------------------------
|
|
579
|
+
# Step 2 -- backfill audit log with `accept` entries
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _now_iso() -> str:
|
|
584
|
+
"""Return current time as ISO-8601 UTC with the literal ``Z`` suffix."""
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
_dt.datetime.now(tz=_dt.timezone.utc) # noqa: UP017
|
|
588
|
+
.replace(microsecond=0)
|
|
589
|
+
.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _extract_issue_number(vbrief_data: dict[str, Any]) -> int | None:
|
|
594
|
+
"""Pull the issue number from a scope vBRIEF's references[] block."""
|
|
595
|
+
|
|
596
|
+
plan = vbrief_data.get("plan")
|
|
597
|
+
if not isinstance(plan, dict):
|
|
598
|
+
return None
|
|
599
|
+
refs = plan.get("references")
|
|
600
|
+
if not isinstance(refs, list):
|
|
601
|
+
return None
|
|
602
|
+
for ref in refs:
|
|
603
|
+
if not isinstance(ref, dict):
|
|
604
|
+
continue
|
|
605
|
+
if ref.get("type") != "x-vbrief/github-issue":
|
|
606
|
+
continue
|
|
607
|
+
uri = ref.get("uri", "")
|
|
608
|
+
if not isinstance(uri, str):
|
|
609
|
+
continue
|
|
610
|
+
tail = uri.rstrip("/").rsplit("/", 1)[-1]
|
|
611
|
+
if tail.isdigit():
|
|
612
|
+
return int(tail)
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _scan_lifecycle_folder(folder: Path) -> list[tuple[int, Path]]:
|
|
617
|
+
"""Walk a lifecycle folder, returning (issue_number, vbrief_path) tuples."""
|
|
618
|
+
|
|
619
|
+
results: list[tuple[int, Path]] = []
|
|
620
|
+
if not folder.exists() or not folder.is_dir():
|
|
621
|
+
return results
|
|
622
|
+
for path in sorted(folder.glob("*.vbrief.json")):
|
|
623
|
+
try:
|
|
624
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
625
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
626
|
+
continue
|
|
627
|
+
if not isinstance(data, dict):
|
|
628
|
+
continue
|
|
629
|
+
issue_number = _extract_issue_number(data)
|
|
630
|
+
if issue_number is None:
|
|
631
|
+
continue
|
|
632
|
+
results.append((issue_number, path))
|
|
633
|
+
return results
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _existing_audit_issue_numbers(audit_path: Path) -> set[int]:
|
|
637
|
+
"""Read the audit log and return the set of issue numbers already logged."""
|
|
638
|
+
|
|
639
|
+
if not audit_path.exists():
|
|
640
|
+
return set()
|
|
641
|
+
seen: set[int] = set()
|
|
642
|
+
try:
|
|
643
|
+
for line in audit_path.read_text(encoding="utf-8").splitlines():
|
|
644
|
+
stripped = line.strip()
|
|
645
|
+
if not stripped:
|
|
646
|
+
continue
|
|
647
|
+
try:
|
|
648
|
+
entry = json.loads(stripped)
|
|
649
|
+
except json.JSONDecodeError:
|
|
650
|
+
continue
|
|
651
|
+
if not isinstance(entry, dict):
|
|
652
|
+
continue
|
|
653
|
+
n = entry.get("issue_number")
|
|
654
|
+
if isinstance(n, int):
|
|
655
|
+
seen.add(n)
|
|
656
|
+
except (OSError, UnicodeDecodeError):
|
|
657
|
+
return set()
|
|
658
|
+
return seen
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _build_audit_entry(repo: str, issue_number: int, source_folder: str) -> dict[str, Any]:
|
|
662
|
+
"""Compose a single ``accept`` audit entry per Story 2's schema."""
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
"decision_id": str(uuid.uuid4()),
|
|
666
|
+
"timestamp": _now_iso(),
|
|
667
|
+
"repo": repo,
|
|
668
|
+
"issue_number": issue_number,
|
|
669
|
+
"decision": "accept",
|
|
670
|
+
"actor": BOOTSTRAP_ACTOR,
|
|
671
|
+
"reason": (
|
|
672
|
+
f"bootstrap backfill: vBRIEF already in vbrief/{source_folder}/ "
|
|
673
|
+
"at opt-in time"
|
|
674
|
+
),
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _append_audit_entry(audit_path: Path, entry: dict[str, Any]) -> None:
|
|
679
|
+
"""Self-contained JSONL append used when Story 2 hasn't merged yet."""
|
|
680
|
+
|
|
681
|
+
audit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
682
|
+
serialized = json.dumps(entry, ensure_ascii=False, sort_keys=True)
|
|
683
|
+
with audit_path.open("a", encoding="utf-8") as fh:
|
|
684
|
+
fh.write(serialized)
|
|
685
|
+
fh.write("\n")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def step_backfill_audit_log(project_root: Path, repo: str | None) -> StepOutcome:
|
|
689
|
+
"""Backfill ``accept`` audit entries for items already in lifecycle folders."""
|
|
690
|
+
|
|
691
|
+
if repo is None:
|
|
692
|
+
return StepOutcome(
|
|
693
|
+
name="backfill_audit_log",
|
|
694
|
+
ok=True,
|
|
695
|
+
message=(
|
|
696
|
+
"skipped (no --repo provided; pass --repo OWNER/NAME to backfill)"
|
|
697
|
+
),
|
|
698
|
+
details={"skipped": "no-repo"},
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
vbrief_root = project_root / "vbrief"
|
|
702
|
+
if not vbrief_root.exists() or not vbrief_root.is_dir():
|
|
703
|
+
return StepOutcome(
|
|
704
|
+
name="backfill_audit_log",
|
|
705
|
+
ok=True,
|
|
706
|
+
message=f"skipped (no vbrief/ directory under {project_root})",
|
|
707
|
+
details={"skipped": "no-vbrief"},
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
audit_path = project_root / AUDIT_LOG_RELPATH
|
|
711
|
+
already_logged = _existing_audit_issue_numbers(audit_path)
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
candidates_log = importlib.import_module("candidates_log")
|
|
715
|
+
story2_append = getattr(candidates_log, "append", None)
|
|
716
|
+
if not callable(story2_append):
|
|
717
|
+
story2_append = None
|
|
718
|
+
except ModuleNotFoundError:
|
|
719
|
+
story2_append = None
|
|
720
|
+
|
|
721
|
+
appended = 0
|
|
722
|
+
skipped_existing = 0
|
|
723
|
+
skipped_cancelled = 0
|
|
724
|
+
|
|
725
|
+
cancelled_dir = vbrief_root / "cancelled"
|
|
726
|
+
if cancelled_dir.exists():
|
|
727
|
+
skipped_cancelled = len(_scan_lifecycle_folder(cancelled_dir))
|
|
728
|
+
|
|
729
|
+
for folder_name in BACKFILL_FOLDERS:
|
|
730
|
+
folder_path = vbrief_root / folder_name
|
|
731
|
+
for issue_number, _vbrief_path in _scan_lifecycle_folder(folder_path):
|
|
732
|
+
if issue_number in already_logged:
|
|
733
|
+
skipped_existing += 1
|
|
734
|
+
continue
|
|
735
|
+
entry = _build_audit_entry(repo, issue_number, folder_name)
|
|
736
|
+
try:
|
|
737
|
+
if story2_append is not None:
|
|
738
|
+
story2_append(entry, path=audit_path)
|
|
739
|
+
else:
|
|
740
|
+
_append_audit_entry(audit_path, entry)
|
|
741
|
+
except Exception as exc: # noqa: BLE001
|
|
742
|
+
return StepOutcome(
|
|
743
|
+
name="backfill_audit_log",
|
|
744
|
+
ok=False,
|
|
745
|
+
message=(
|
|
746
|
+
f"append failed at issue #{issue_number} after "
|
|
747
|
+
f"{appended} successful writes"
|
|
748
|
+
),
|
|
749
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
750
|
+
details={
|
|
751
|
+
"appended": appended,
|
|
752
|
+
"skipped_existing": skipped_existing,
|
|
753
|
+
"skipped_cancelled": skipped_cancelled,
|
|
754
|
+
},
|
|
755
|
+
)
|
|
756
|
+
appended += 1
|
|
757
|
+
already_logged.add(issue_number)
|
|
758
|
+
|
|
759
|
+
return StepOutcome(
|
|
760
|
+
name="backfill_audit_log",
|
|
761
|
+
ok=True,
|
|
762
|
+
message=(
|
|
763
|
+
f"appended {appended} accepted entries; skipped "
|
|
764
|
+
f"{skipped_existing} (already logged); skipped "
|
|
765
|
+
f"{skipped_cancelled} (cancelled/, no reanimation)"
|
|
766
|
+
),
|
|
767
|
+
details={
|
|
768
|
+
"appended": appended,
|
|
769
|
+
"skipped_existing": skipped_existing,
|
|
770
|
+
"skipped_cancelled": skipped_cancelled,
|
|
771
|
+
"audit_path": str(audit_path),
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
# ---------------------------------------------------------------------------
|
|
777
|
+
# Step 3 + 4 -- ensure .deft-cache/ and vbrief/.eval/ are gitignored
|
|
778
|
+
# ---------------------------------------------------------------------------
|
|
779
|
+
#
|
|
780
|
+
# Implementation lives in scripts/_triage_bootstrap_gitignore.py to keep
|
|
781
|
+
# this module under the 1000-line MUST limit (coding/coding.md). The
|
|
782
|
+
# step functions are re-exported from that submodule so the public
|
|
783
|
+
# import surface (``triage_bootstrap.step_ensure_gitignore_entry`` /
|
|
784
|
+
# ``...eval_dir``) stays exactly as Story 3 shipped.
|
|
785
|
+
|
|
786
|
+
# Re-export the gitignore step functions and the canonical line
|
|
787
|
+
# constants. ``GITIGNORE_LINE`` / ``GITIGNORE_EVAL_ENTRIES`` /
|
|
788
|
+
# ``GITATTRIBUTES_EVAL_RULE`` are part of the module's public surface
|
|
789
|
+
# (consumers / tests reference ``triage_bootstrap.GITIGNORE_LINE``);
|
|
790
|
+
# the ``__all__``-style guard below keeps ruff F401 silent without
|
|
791
|
+
# losing the re-export. ``step_ensure_gitignore_eval_entries`` is the
|
|
792
|
+
# #1251 rename of the pre-existing ``step_ensure_gitignore_eval_dir``.
|
|
793
|
+
from _triage_bootstrap_gitignore import ( # noqa: E402, F401 -- re-exported public surface
|
|
794
|
+
GITATTRIBUTES_EVAL_RULE,
|
|
795
|
+
GITIGNORE_EVAL_ENTRIES,
|
|
796
|
+
GITIGNORE_LINE,
|
|
797
|
+
step_ensure_gitignore_entry,
|
|
798
|
+
step_ensure_gitignore_eval_entries,
|
|
799
|
+
step_seed_candidates_log,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# ---------------------------------------------------------------------------
|
|
803
|
+
# Dispatcher + CLI
|
|
804
|
+
# ---------------------------------------------------------------------------
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
#: Sentinel signalling that the caller did not pass a ``progress``
|
|
808
|
+
#: argument and the dispatcher should default to ``sys.stderr``. We
|
|
809
|
+
#: distinguish ``None`` (silent) from "not provided" (default to stderr)
|
|
810
|
+
#: so test callers can reliably suppress emission with ``progress=None``.
|
|
811
|
+
_PROGRESS_DEFAULT: object = object()
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def run_bootstrap(
|
|
815
|
+
project_root: Path,
|
|
816
|
+
repo: str | None,
|
|
817
|
+
*,
|
|
818
|
+
cache_module: Any | None = None,
|
|
819
|
+
batch_size: int | None = None,
|
|
820
|
+
delay_ms: int | None = None,
|
|
821
|
+
state: str | None = None,
|
|
822
|
+
limit: int | None = None,
|
|
823
|
+
labels: tuple[str, ...] | None = None,
|
|
824
|
+
author: str | None = None,
|
|
825
|
+
fetch_timeout_s: float | None = None,
|
|
826
|
+
progress: Any = _PROGRESS_DEFAULT,
|
|
827
|
+
) -> BootstrapResult:
|
|
828
|
+
"""Run the bootstrap pipeline, returning the aggregate result.
|
|
829
|
+
|
|
830
|
+
Dispatches the five mutating steps documented in the module
|
|
831
|
+
docstring (populate_cache, backfill_audit_log,
|
|
832
|
+
ensure_gitignore_entry, ensure_gitignore_eval_entries,
|
|
833
|
+
seed_candidates_log) and appends one :class:`StepOutcome` per
|
|
834
|
+
step. ``len(result.steps) == 5`` is the expected post-condition.
|
|
835
|
+
|
|
836
|
+
Repo resolution (#1237): the explicit ``repo`` argument takes
|
|
837
|
+
priority. When ``None``, the dispatcher infers from ``git remote
|
|
838
|
+
get-url origin`` ONCE up-front and threads the result through every
|
|
839
|
+
downstream step. Pre-#1237 the populate step did the inference
|
|
840
|
+
inside itself but the backfill step did not, so step 2 silently
|
|
841
|
+
no-op'd with ``details.skipped="no-repo"`` on the happy path even
|
|
842
|
+
when step 1 had resolved a slug from git origin. Lifting the
|
|
843
|
+
resolution makes the four steps see the same answer for the same
|
|
844
|
+
invocation.
|
|
845
|
+
|
|
846
|
+
``fetch_timeout_s`` is forwarded to :func:`step_populate_cache` and
|
|
847
|
+
bounds the cache:fetch-all step so the orchestrator always exits
|
|
848
|
+
even when an underlying subprocess hangs (#952).
|
|
849
|
+
|
|
850
|
+
``progress`` is a file-like sink for per-step status lines; it
|
|
851
|
+
defaults to ``sys.stderr`` and may be set to ``None`` to silence
|
|
852
|
+
emission. The lines mirror ``scripts/cache.py`` cadence so a future
|
|
853
|
+
operator can see exactly which step is in flight if the run wedges.
|
|
854
|
+
"""
|
|
855
|
+
|
|
856
|
+
progress_sink: Any = sys.stderr if progress is _PROGRESS_DEFAULT else progress
|
|
857
|
+
|
|
858
|
+
# #1237: resolve the repo ONCE so every downstream step sees the
|
|
859
|
+
# same answer. Mirrors the precedence chain used by
|
|
860
|
+
# ``step_populate_cache`` pre-#1237 (explicit -> git remote);
|
|
861
|
+
# consolidating it here eliminates the step-2 ``skipped=no-repo``
|
|
862
|
+
# gap documented on issue #1237.
|
|
863
|
+
effective_repo: str | None = repo
|
|
864
|
+
if effective_repo is None:
|
|
865
|
+
effective_repo = _infer_repo_from_git(cwd=project_root)
|
|
866
|
+
|
|
867
|
+
result = BootstrapResult(project_root=project_root, repo=effective_repo)
|
|
868
|
+
|
|
869
|
+
repo_detail = (
|
|
870
|
+
f"repo={effective_repo}" if effective_repo else "repo=<unresolved>"
|
|
871
|
+
)
|
|
872
|
+
effective_timeout = (
|
|
873
|
+
fetch_timeout_s if fetch_timeout_s is not None else DEFAULT_FETCH_TIMEOUT_S
|
|
874
|
+
)
|
|
875
|
+
timeout_detail = f"fetch_timeout_s={effective_timeout:g}"
|
|
876
|
+
|
|
877
|
+
_emit_progress(
|
|
878
|
+
progress_sink, 1, "populate_cache", "starting",
|
|
879
|
+
f"{repo_detail}; {timeout_detail}",
|
|
880
|
+
)
|
|
881
|
+
populate = step_populate_cache(
|
|
882
|
+
project_root,
|
|
883
|
+
effective_repo,
|
|
884
|
+
cache_module=cache_module,
|
|
885
|
+
batch_size=batch_size,
|
|
886
|
+
delay_ms=delay_ms,
|
|
887
|
+
state=state,
|
|
888
|
+
limit=limit,
|
|
889
|
+
labels=labels,
|
|
890
|
+
author=author,
|
|
891
|
+
fetch_timeout_s=fetch_timeout_s,
|
|
892
|
+
)
|
|
893
|
+
result.steps.append(populate)
|
|
894
|
+
populate_phase = "done" if populate.ok else (
|
|
895
|
+
"timeout" if populate.details.get("timed_out") else "error"
|
|
896
|
+
)
|
|
897
|
+
_emit_progress(
|
|
898
|
+
progress_sink, 1, "populate_cache", populate_phase, populate.message,
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
_emit_progress(progress_sink, 2, "backfill_audit_log", "starting", repo_detail)
|
|
902
|
+
backfill = step_backfill_audit_log(project_root, effective_repo)
|
|
903
|
+
result.steps.append(backfill)
|
|
904
|
+
_emit_progress(
|
|
905
|
+
progress_sink, 2, "backfill_audit_log",
|
|
906
|
+
"done" if backfill.ok else "error", backfill.message,
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
_emit_progress(progress_sink, 3, "ensure_gitignore_entry", "starting")
|
|
910
|
+
gi_cache = step_ensure_gitignore_entry(project_root)
|
|
911
|
+
result.steps.append(gi_cache)
|
|
912
|
+
_emit_progress(
|
|
913
|
+
progress_sink, 3, "ensure_gitignore_entry",
|
|
914
|
+
"done" if gi_cache.ok else "error", gi_cache.message,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
_emit_progress(progress_sink, 4, "ensure_gitignore_eval_entries", "starting")
|
|
918
|
+
gi_eval = step_ensure_gitignore_eval_entries(project_root)
|
|
919
|
+
result.steps.append(gi_eval)
|
|
920
|
+
_emit_progress(
|
|
921
|
+
progress_sink, 4, "ensure_gitignore_eval_entries",
|
|
922
|
+
"done" if gi_eval.ok else "error", gi_eval.message,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
# #1240 step 5: seed the audit log so verify:cache-fresh can tell
|
|
926
|
+
# "never bootstrapped" from "freshly bootstrapped, no triage
|
|
927
|
+
# actions yet". Always runs; independent of repo resolution.
|
|
928
|
+
_emit_progress(progress_sink, 5, "seed_candidates_log", "starting")
|
|
929
|
+
seed = step_seed_candidates_log(project_root)
|
|
930
|
+
result.steps.append(seed)
|
|
931
|
+
_emit_progress(
|
|
932
|
+
progress_sink, 5, "seed_candidates_log",
|
|
933
|
+
"done" if seed.ok else "error", seed.message,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
if any(not step.ok for step in result.steps):
|
|
937
|
+
result.exit_code = 1
|
|
938
|
+
return result
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
942
|
+
parser = argparse.ArgumentParser(
|
|
943
|
+
prog="triage_bootstrap.py",
|
|
944
|
+
description=(
|
|
945
|
+
"Idempotent triage v1 installer (#883 Story 3 rebind). "
|
|
946
|
+
"Re-runnable by design; reversible via "
|
|
947
|
+
"`rm -rf .deft-cache/ vbrief/.eval/` and removing the "
|
|
948
|
+
".deft-cache/ + vbrief/.eval/ lines from .gitignore."
|
|
949
|
+
),
|
|
950
|
+
)
|
|
951
|
+
parser.add_argument(
|
|
952
|
+
"--project-root",
|
|
953
|
+
default=os.environ.get("DEFT_PROJECT_ROOT", "."),
|
|
954
|
+
help=(
|
|
955
|
+
"Path to the consumer project root (default: $DEFT_PROJECT_ROOT or "
|
|
956
|
+
"current working directory)."
|
|
957
|
+
),
|
|
958
|
+
)
|
|
959
|
+
parser.add_argument(
|
|
960
|
+
"--repo",
|
|
961
|
+
default=os.environ.get("DEFT_TRIAGE_REPO"),
|
|
962
|
+
help=(
|
|
963
|
+
"Upstream repo slug 'owner/name'. Resolution precedence: "
|
|
964
|
+
"(1) this explicit flag; (2) the DEFT_TRIAGE_REPO env var; "
|
|
965
|
+
"(3) inferred from `git remote get-url origin` inside the populate "
|
|
966
|
+
"step. Bootstrap remains partial only when all three surfaces "
|
|
967
|
+
"fail to resolve a slug."
|
|
968
|
+
),
|
|
969
|
+
)
|
|
970
|
+
parser.add_argument(
|
|
971
|
+
"--limit",
|
|
972
|
+
type=int,
|
|
973
|
+
default=None,
|
|
974
|
+
help=(
|
|
975
|
+
"Cap on the number of issues fetched (forwarded to "
|
|
976
|
+
"cache:fetch-all --limit)."
|
|
977
|
+
),
|
|
978
|
+
)
|
|
979
|
+
parser.add_argument(
|
|
980
|
+
"--state",
|
|
981
|
+
default=None,
|
|
982
|
+
choices=["open", "closed", "all"],
|
|
983
|
+
help="Issue state filter forwarded to cache:fetch-all --state.",
|
|
984
|
+
)
|
|
985
|
+
parser.add_argument(
|
|
986
|
+
"--label",
|
|
987
|
+
action="append",
|
|
988
|
+
default=None,
|
|
989
|
+
dest="labels",
|
|
990
|
+
metavar="NAME[,NAME...]",
|
|
991
|
+
help=(
|
|
992
|
+
"Scope ingestion to issues carrying the given label(s) (#1033), "
|
|
993
|
+
"forwarded to cache:fetch-all --label. Repeatable and comma-"
|
|
994
|
+
"separated. Composes with --author via AND."
|
|
995
|
+
),
|
|
996
|
+
)
|
|
997
|
+
parser.add_argument(
|
|
998
|
+
"--author",
|
|
999
|
+
default=None,
|
|
1000
|
+
metavar="LOGIN",
|
|
1001
|
+
help=(
|
|
1002
|
+
"Scope ingestion to issues created by LOGIN (#1055), forwarded "
|
|
1003
|
+
"to cache:fetch-all --author. Composes with --label via AND."
|
|
1004
|
+
),
|
|
1005
|
+
)
|
|
1006
|
+
parser.add_argument(
|
|
1007
|
+
"--batch-size",
|
|
1008
|
+
type=int,
|
|
1009
|
+
default=None,
|
|
1010
|
+
dest="batch_size",
|
|
1011
|
+
help="Forwarded to cache:fetch-all --batch-size.",
|
|
1012
|
+
)
|
|
1013
|
+
parser.add_argument(
|
|
1014
|
+
"--delay-ms",
|
|
1015
|
+
type=int,
|
|
1016
|
+
default=None,
|
|
1017
|
+
dest="delay_ms",
|
|
1018
|
+
help="Forwarded to cache:fetch-all --delay-ms.",
|
|
1019
|
+
)
|
|
1020
|
+
parser.add_argument(
|
|
1021
|
+
"--fetch-timeout-s",
|
|
1022
|
+
type=float,
|
|
1023
|
+
default=_default_fetch_timeout_from_env(),
|
|
1024
|
+
dest="fetch_timeout_s",
|
|
1025
|
+
help=(
|
|
1026
|
+
"Wall-clock cap (seconds) on the cache:fetch-all step. The "
|
|
1027
|
+
"watchdog protects the orchestrator from a stuck `task "
|
|
1028
|
+
"scm:issue:view` subprocess so bootstrap always exits in "
|
|
1029
|
+
"bounded time (#952). 0 disables the cap (legacy unbounded "
|
|
1030
|
+
"behavior). Default: $DEFT_BOOTSTRAP_FETCH_TIMEOUT_S or "
|
|
1031
|
+
f"{DEFAULT_FETCH_TIMEOUT_S}s."
|
|
1032
|
+
),
|
|
1033
|
+
)
|
|
1034
|
+
parser.add_argument(
|
|
1035
|
+
"--quiet",
|
|
1036
|
+
action="store_true",
|
|
1037
|
+
dest="quiet",
|
|
1038
|
+
help=(
|
|
1039
|
+
"Suppress per-step `triage:bootstrap step <i>/<N> ...` progress "
|
|
1040
|
+
"lines on stderr. The recap and --json output are unaffected."
|
|
1041
|
+
),
|
|
1042
|
+
)
|
|
1043
|
+
parser.add_argument(
|
|
1044
|
+
"--json",
|
|
1045
|
+
action="store_true",
|
|
1046
|
+
dest="emit_json",
|
|
1047
|
+
help=(
|
|
1048
|
+
"Emit a structured JSON payload to stdout (one object per step) "
|
|
1049
|
+
"instead of the human-readable recap. Exit code is unchanged."
|
|
1050
|
+
),
|
|
1051
|
+
)
|
|
1052
|
+
return parser
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _normalise_label_filter(raw: list[str] | None) -> tuple[str, ...]:
|
|
1056
|
+
"""Flatten repeated + comma-separated ``--label`` values into a tuple.
|
|
1057
|
+
|
|
1058
|
+
Mirrors ``cache._normalise_label_filter`` so the bootstrap and the
|
|
1059
|
+
underlying cache:fetch-all surface parse the multi-label convention
|
|
1060
|
+
identically (#1033). ``argparse(action="append")`` yields one list
|
|
1061
|
+
entry per flag occurrence; each entry may itself be comma-separated.
|
|
1062
|
+
"""
|
|
1063
|
+
if not raw:
|
|
1064
|
+
return ()
|
|
1065
|
+
return tuple(
|
|
1066
|
+
item.strip()
|
|
1067
|
+
for value in raw
|
|
1068
|
+
for item in value.split(",")
|
|
1069
|
+
if item.strip()
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _default_fetch_timeout_from_env() -> float:
|
|
1074
|
+
"""Resolve the default ``--fetch-timeout-s`` from the environment.
|
|
1075
|
+
|
|
1076
|
+
Reads ``DEFT_BOOTSTRAP_FETCH_TIMEOUT_S`` and falls back to
|
|
1077
|
+
:data:`DEFAULT_FETCH_TIMEOUT_S` on absence or unparseable value. A
|
|
1078
|
+
bad value is silently ignored (the CLI default is the canonical
|
|
1079
|
+
constant) so a misconfigured env never blocks an opt-in run.
|
|
1080
|
+
"""
|
|
1081
|
+
raw = os.environ.get("DEFT_BOOTSTRAP_FETCH_TIMEOUT_S")
|
|
1082
|
+
if not raw:
|
|
1083
|
+
return float(DEFAULT_FETCH_TIMEOUT_S)
|
|
1084
|
+
try:
|
|
1085
|
+
return max(0.0, float(raw))
|
|
1086
|
+
except ValueError:
|
|
1087
|
+
return float(DEFAULT_FETCH_TIMEOUT_S)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _emit_json(result: BootstrapResult) -> str:
|
|
1091
|
+
"""Render the structured ``--json`` payload."""
|
|
1092
|
+
|
|
1093
|
+
payload = {
|
|
1094
|
+
"project_root": str(result.project_root),
|
|
1095
|
+
"repo": result.repo,
|
|
1096
|
+
"exit_code": result.exit_code,
|
|
1097
|
+
"steps": [
|
|
1098
|
+
{
|
|
1099
|
+
"name": s.name,
|
|
1100
|
+
"ok": s.ok,
|
|
1101
|
+
"message": s.message,
|
|
1102
|
+
"error": s.error,
|
|
1103
|
+
"details": s.details,
|
|
1104
|
+
}
|
|
1105
|
+
for s in result.steps
|
|
1106
|
+
],
|
|
1107
|
+
}
|
|
1108
|
+
return json.dumps(payload, sort_keys=True)
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1112
|
+
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
1113
|
+
from triage_help import intercept_help
|
|
1114
|
+
|
|
1115
|
+
rc = intercept_help("triage_bootstrap", argv)
|
|
1116
|
+
if rc is not None:
|
|
1117
|
+
return rc
|
|
1118
|
+
parser = _build_parser()
|
|
1119
|
+
args = parser.parse_args(argv)
|
|
1120
|
+
|
|
1121
|
+
project_root = Path(args.project_root).resolve()
|
|
1122
|
+
if not project_root.exists() or not project_root.is_dir():
|
|
1123
|
+
msg = (
|
|
1124
|
+
f"❌ triage:bootstrap: --project-root {project_root} does not exist "
|
|
1125
|
+
"or is not a directory."
|
|
1126
|
+
)
|
|
1127
|
+
print(msg, file=sys.stderr)
|
|
1128
|
+
return 2
|
|
1129
|
+
|
|
1130
|
+
labels = _normalise_label_filter(getattr(args, "labels", None))
|
|
1131
|
+
result = run_bootstrap(
|
|
1132
|
+
project_root=project_root,
|
|
1133
|
+
repo=args.repo,
|
|
1134
|
+
batch_size=args.batch_size,
|
|
1135
|
+
delay_ms=args.delay_ms,
|
|
1136
|
+
state=args.state,
|
|
1137
|
+
limit=args.limit,
|
|
1138
|
+
labels=labels,
|
|
1139
|
+
author=args.author,
|
|
1140
|
+
fetch_timeout_s=args.fetch_timeout_s,
|
|
1141
|
+
progress=None if args.quiet else sys.stderr,
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
if args.emit_json:
|
|
1145
|
+
print(_emit_json(result))
|
|
1146
|
+
else:
|
|
1147
|
+
print(result.summary())
|
|
1148
|
+
|
|
1149
|
+
return result.exit_code
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
if __name__ == "__main__":
|
|
1153
|
+
raise SystemExit(main())
|