@deftai/directive-content 0.59.0 → 0.61.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 +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
|
@@ -1,871 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""triage_actions.py -- per-issue triage decision commands (#845 Story 3).
|
|
3
|
-
|
|
4
|
-
Provides eight commands consumed via ``tasks/triage-actions.yml``:
|
|
5
|
-
|
|
6
|
-
- ``accept(n, repo)`` -- record an accept audit entry AND delegate the vBRIEF
|
|
7
|
-
authoring to ``scripts/issue_ingest.py`` (#985). After ``log.append(entry)``
|
|
8
|
-
succeeds, ``ingest_single_for_accept`` is invoked to materialise the issue
|
|
9
|
-
in ``vbrief/proposed/`` per the refinement skill's three-tier inventory
|
|
10
|
-
model. If the ingest fails, the audit entry is ROLLED BACK so the log
|
|
11
|
-
never references an accept decision that did not actually produce a vBRIEF
|
|
12
|
-
(mirrors :func:`reject`'s rollback pattern).
|
|
13
|
-
- ``reject(n, repo, reason)`` -- close the upstream GitHub issue with
|
|
14
|
-
``gh issue close <n> --comment <reason> --reason 'not planned'``, apply the
|
|
15
|
-
``triage-rejected`` label, and record a reject audit entry. If the upstream
|
|
16
|
-
``gh`` call fails, the audit entry is ROLLED BACK so the log never references
|
|
17
|
-
a decision that did not actually take effect.
|
|
18
|
-
- ``defer(n, repo)`` -- record a defer audit entry.
|
|
19
|
-
- ``needs_ac(n, repo)`` -- record a needs-ac audit entry and post an
|
|
20
|
-
AC-request comment on the upstream issue.
|
|
21
|
-
- ``mark_duplicate(n, repo, of_n)`` -- validate ``of_n`` exists in the local
|
|
22
|
-
cache (Story 1) and record a mark-duplicate audit entry pointing at it.
|
|
23
|
-
- ``status(n, repo)`` -- return the latest decision for ``n`` (None if none).
|
|
24
|
-
- ``reset(n, repo)`` -- record a ``reset`` audit entry referencing the prior
|
|
25
|
-
decision id. History is NEVER deleted; reset is the reversible exit.
|
|
26
|
-
- ``history(n, repo)`` -- return all audit entries for ``n`` ordered by
|
|
27
|
-
timestamp.
|
|
28
|
-
|
|
29
|
-
All actions are idempotent on already-final state: invoking ``reject`` on an
|
|
30
|
-
already-rejected issue is a no-op (returns the existing ``decision_id``) and
|
|
31
|
-
does NOT re-call ``gh issue close`` nor re-write the audit log.
|
|
32
|
-
|
|
33
|
-
Upstream contracts (frozen public surfaces of Story 2 + #883 Story 2):
|
|
34
|
-
|
|
35
|
-
- ``scripts.candidates_log.append(entry: dict) -> str`` (decision_id)
|
|
36
|
-
- ``scripts.candidates_log.latest_decision(issue_number: int, repo: str) -> dict | None``
|
|
37
|
-
- ``scripts.candidates_log.find_by_issue(issue_number: int, repo: str) -> list[dict]``
|
|
38
|
-
- ``scripts.cache.cache_get(source: str, key: str, *, allow_stale=True) -> GetResult``
|
|
39
|
-
-- the unified cache replaces the legacy triage_cache.show(...) seam under
|
|
40
|
-
#883 Story 3.
|
|
41
|
-
|
|
42
|
-
The upstream PRs may not be merged when this script lands. Module-level
|
|
43
|
-
``candidates_log`` and ``cache`` references are therefore guarded with
|
|
44
|
-
``try / except ImportError`` so the module imports cleanly. Tests substitute
|
|
45
|
-
fakes via ``monkeypatch.setattr(triage_actions, "candidates_log", ...)`` and
|
|
46
|
-
``monkeypatch.setattr(triage_actions, "cache", ...)``.
|
|
47
|
-
|
|
48
|
-
Per ``conventions/task-caching.md`` the Taskfile fragment must NOT cache the
|
|
49
|
-
``cmds:`` block: every action accepts user-facing flags via ``{{.CLI_ARGS}}``.
|
|
50
|
-
"""
|
|
51
|
-
|
|
52
|
-
from __future__ import annotations
|
|
53
|
-
|
|
54
|
-
import argparse
|
|
55
|
-
import contextlib
|
|
56
|
-
import datetime as _dt
|
|
57
|
-
import json
|
|
58
|
-
import os
|
|
59
|
-
import subprocess
|
|
60
|
-
import sys
|
|
61
|
-
import uuid
|
|
62
|
-
from pathlib import Path
|
|
63
|
-
from typing import Any
|
|
64
|
-
|
|
65
|
-
# Make sibling scripts importable when invoked from the project root or via
|
|
66
|
-
# ``uv run python scripts/triage_actions.py``. Mirrors the pattern in
|
|
67
|
-
# ``scripts/policy_set.py`` so we can do ``import candidates_log``.
|
|
68
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
69
|
-
|
|
70
|
-
# #1145 / N5: route ``gh`` invocations through the source-aware shim so a
|
|
71
|
-
# future GitLab / Gitea / local consumer sees a loud ``NotImplementedError``
|
|
72
|
-
# pointing at #445 / #935 Workstream 6, not a confusing
|
|
73
|
-
# ``gh: command not found`` deep in the call stack. The shim resolves the
|
|
74
|
-
# binary via the #884 ``ghx`` -> ``gh`` preference ladder, so this also
|
|
75
|
-
# transparently picks up the cached proxy when it is installed.
|
|
76
|
-
import scm # noqa: E402 -- sibling-first path insertion above is intentional
|
|
77
|
-
|
|
78
|
-
# Public, frozen interfaces from #845 Story 2 (audit log) + #883 Story 2
|
|
79
|
-
# (unified cache). These imports may fail when an upstream PR has not yet
|
|
80
|
-
# merged onto the consumer's branch -- the module attributes are then
|
|
81
|
-
# ``None`` and tests substitute a fake. Production bootstrap lands all
|
|
82
|
-
# pieces together so the runtime path is intact.
|
|
83
|
-
try: # pragma: no cover -- exercised once Story 2 lands.
|
|
84
|
-
import candidates_log # type: ignore[import-not-found]
|
|
85
|
-
except ImportError: # pragma: no cover
|
|
86
|
-
candidates_log = None # type: ignore[assignment]
|
|
87
|
-
|
|
88
|
-
try: # pragma: no cover -- exercised once #883 Story 2 lands.
|
|
89
|
-
import cache # type: ignore[import-not-found]
|
|
90
|
-
except ImportError: # pragma: no cover
|
|
91
|
-
cache = None # type: ignore[assignment]
|
|
92
|
-
|
|
93
|
-
# #985: triage:accept delegates the vBRIEF authoring to issue_ingest after
|
|
94
|
-
# the audit-log append succeeds. Guarded so the module imports cleanly when
|
|
95
|
-
# issue_ingest pulls in transitive deps (e.g. ``cache``) that may not be
|
|
96
|
-
# present on a slimmed-down checkout. Tests substitute fakes via
|
|
97
|
-
# ``monkeypatch.setattr(triage_actions, "issue_ingest", ...)``.
|
|
98
|
-
try: # pragma: no cover -- exercised once #454 lands on the same checkout.
|
|
99
|
-
import issue_ingest # type: ignore[import-not-found]
|
|
100
|
-
except ImportError: # pragma: no cover
|
|
101
|
-
issue_ingest = None # type: ignore[assignment]
|
|
102
|
-
|
|
103
|
-
# Optional dep: resume-condition grammar parser (#1123 / D3). When
|
|
104
|
-
# absent (slim test checkout) ``defer(resume_on=...)`` falls through
|
|
105
|
-
# without pre-validation; the audit-log validator still accepts the
|
|
106
|
-
# string verbatim.
|
|
107
|
-
try: # pragma: no cover -- exercised once #1123 lands.
|
|
108
|
-
import resume_conditions # type: ignore[import-not-found]
|
|
109
|
-
except ImportError: # pragma: no cover
|
|
110
|
-
resume_conditions = None # type: ignore[assignment]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# Public constants ----------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
#: Project-relative path of the audit log written by Story 2's ``append``
|
|
116
|
-
#: (canonical location frozen in the Story 2 vBRIEF). Used ONLY by
|
|
117
|
-
#: :func:`_rollback_audit_entry` -- the normal write path goes through
|
|
118
|
-
#: ``candidates_log.append``.
|
|
119
|
-
AUDIT_LOG_REL_PATH = "vbrief/.eval/candidates.jsonl"
|
|
120
|
-
|
|
121
|
-
#: Label applied to a rejected upstream issue alongside ``gh issue close``.
|
|
122
|
-
REJECTED_LABEL = "triage-rejected"
|
|
123
|
-
|
|
124
|
-
#: Default color (6-hex, no leading '#') applied when auto-creating
|
|
125
|
-
#: :data:`REJECTED_LABEL` on a repository that lacks it (#1420). GitHub's
|
|
126
|
-
#: own ``invalid`` / ``wontfix`` palette red; chosen so an auto-created
|
|
127
|
-
#: label reads as a negative-disposition marker at a glance.
|
|
128
|
-
REJECTED_LABEL_COLOR = "B60205"
|
|
129
|
-
|
|
130
|
-
#: Description applied when auto-creating :data:`REJECTED_LABEL` (#1420).
|
|
131
|
-
REJECTED_LABEL_DESCRIPTION = "Issue rejected during deft triage"
|
|
132
|
-
|
|
133
|
-
#: Decision values we treat as terminal for idempotency purposes. Repeating
|
|
134
|
-
#: the SAME terminal decision against an issue already in that state is a
|
|
135
|
-
#: no-op (returns the prior decision_id, no audit / no upstream call).
|
|
136
|
-
_TERMINAL_DECISIONS = frozenset({"accept", "reject", "mark-duplicate"})
|
|
137
|
-
|
|
138
|
-
#: Default ``actor`` string when callers do not specify one.
|
|
139
|
-
_DEFAULT_ACTOR = "agent:triage"
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _now_iso() -> str:
|
|
143
|
-
"""Return an ISO-8601 UTC timestamp with the canonical ``Z`` suffix.
|
|
144
|
-
|
|
145
|
-
Story 2's audit-log schema regex is ``\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}
|
|
146
|
-
(\\.\\d+)?Z`` -- microseconds are accepted but we omit them so the on-disk
|
|
147
|
-
string is easy to grep. Defined as a module-level callable so tests can
|
|
148
|
-
monkeypatch it for deterministic, strictly-monotonic timestamps.
|
|
149
|
-
"""
|
|
150
|
-
return _dt.datetime.now(_dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _new_decision_id() -> str:
|
|
154
|
-
"""Generate a fresh UUID4 string for a new audit entry.
|
|
155
|
-
|
|
156
|
-
Defers to ``candidates_log.new_decision_id()`` when the upstream module is
|
|
157
|
-
importable so a future swap to UUID7 (time-ordered) is a one-file change.
|
|
158
|
-
Falls back to ``uuid.uuid4()`` so this module remains self-contained when
|
|
159
|
-
Story 2 is not yet on the branch (tests substitute a fake module anyway).
|
|
160
|
-
"""
|
|
161
|
-
if candidates_log is not None and hasattr(candidates_log, "new_decision_id"):
|
|
162
|
-
return str(candidates_log.new_decision_id())
|
|
163
|
-
return str(uuid.uuid4())
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
class TriageError(RuntimeError):
|
|
167
|
-
"""Raised when an action cannot complete (e.g. mark-duplicate target missing)."""
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
class UpstreamCloseError(TriageError):
|
|
171
|
-
"""``gh issue close`` failed. The companion audit entry has been rolled back."""
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
# Helpers -------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def _audit_log_path(project_root: Path | None = None) -> Path:
|
|
178
|
-
"""Resolve the absolute path of the candidates audit log."""
|
|
179
|
-
root = project_root or Path.cwd()
|
|
180
|
-
return root / AUDIT_LOG_REL_PATH
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def _resolve_actor(actor: str | None) -> str:
|
|
184
|
-
"""Default the actor to the local user identity, falling back to a marker."""
|
|
185
|
-
if actor:
|
|
186
|
-
return actor
|
|
187
|
-
return os.environ.get("USER") or os.environ.get("USERNAME") or _DEFAULT_ACTOR
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _require_log() -> Any:
|
|
191
|
-
"""Return the live ``candidates_log`` module or raise if Story 2 is missing."""
|
|
192
|
-
if candidates_log is None:
|
|
193
|
-
raise TriageError(
|
|
194
|
-
"scripts/candidates_log.py is not available in this checkout. "
|
|
195
|
-
"Story 2 (#845) must land or this PR must be rebased onto master."
|
|
196
|
-
)
|
|
197
|
-
return candidates_log
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _require_cache() -> Any:
|
|
201
|
-
"""Return the live ``cache`` module or raise if #883 Story 2 is missing."""
|
|
202
|
-
if cache is None:
|
|
203
|
-
raise TriageError(
|
|
204
|
-
"scripts/cache.py is not available in this checkout. "
|
|
205
|
-
"#883 Story 2 must land or this PR must be rebased onto master."
|
|
206
|
-
)
|
|
207
|
-
return cache
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _run_gh(args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
211
|
-
"""Wrapper around ``gh`` so tests can patch a single seam.
|
|
212
|
-
|
|
213
|
-
Routes through :func:`scripts.scm.call` (#1145 / N5) so the binary
|
|
214
|
-
resolution (the #884 ``ghx`` -> ``gh`` ladder) and the source-aware
|
|
215
|
-
indirection (GitLab / Gitea / local raise
|
|
216
|
-
:class:`NotImplementedError`) live in one place. Raises
|
|
217
|
-
:class:`UpstreamCloseError` on non-zero exit so callers can roll back.
|
|
218
|
-
|
|
219
|
-
The ``args`` list begins with the gh verb (e.g. ``"issue"``) followed
|
|
220
|
-
by its subcommand and flags -- the shim accepts the verb separately
|
|
221
|
-
so call sites do not have to know whether the underlying binary is
|
|
222
|
-
``gh`` or ``ghx``. An empty ``args`` is treated as a programming
|
|
223
|
-
error and surfaces as :class:`UpstreamCloseError` (mirrors the prior
|
|
224
|
-
``FileNotFoundError`` failure mode at the contract layer).
|
|
225
|
-
"""
|
|
226
|
-
if not args:
|
|
227
|
-
raise UpstreamCloseError("scm.call requires at least a verb; got empty args")
|
|
228
|
-
try:
|
|
229
|
-
return scm.call(
|
|
230
|
-
"github-issue",
|
|
231
|
-
args[0],
|
|
232
|
-
args[1:],
|
|
233
|
-
check=True,
|
|
234
|
-
capture_output=True,
|
|
235
|
-
text=True,
|
|
236
|
-
)
|
|
237
|
-
except FileNotFoundError as exc:
|
|
238
|
-
raise UpstreamCloseError(f"gh CLI not found on PATH: {exc}") from exc
|
|
239
|
-
except scm.ScmStubError as exc:
|
|
240
|
-
raise UpstreamCloseError(f"gh resolution failed: {exc}") from exc
|
|
241
|
-
except subprocess.CalledProcessError as exc:
|
|
242
|
-
stderr = (exc.stderr or "").strip()
|
|
243
|
-
raise UpstreamCloseError(f"gh {' '.join(args)} failed: {stderr}") from exc
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _rollback_audit_entry(decision_id: str, project_root: Path | None = None) -> bool:
|
|
247
|
-
"""Remove the audit-log line whose JSON ``decision_id`` matches.
|
|
248
|
-
|
|
249
|
-
Story 2 documents an append-only contract for the normal flow; the
|
|
250
|
-
rollback path is the explicit exceptional surface defined by the Story 3
|
|
251
|
-
vBRIEF Constraint narrative ("On reject upstream-close failure, ROLL
|
|
252
|
-
BACK the audit entry").
|
|
253
|
-
|
|
254
|
-
The read+filter+rewrite cycle MUST be serialised against
|
|
255
|
-
``candidates_log.append`` -- otherwise a concurrent appender (e.g.
|
|
256
|
-
Story 4 bulk ops) that commits between our ``open("r")`` and our
|
|
257
|
-
``write_text`` is silently clobbered, breaking the append-only
|
|
258
|
-
guarantee for unrelated entries (Greptile #879 P1). We therefore
|
|
259
|
-
acquire Story 2's own advisory lock primitive
|
|
260
|
-
(``candidates_log._append_lock``) for the duration of the rewrite.
|
|
261
|
-
The leading underscore is acknowledged: the alternative -- recreating
|
|
262
|
-
the lock-file + msvcrt / fcntl dance from scratch here -- duplicates
|
|
263
|
-
the cross-platform code path that Story 2 already encodes correctly.
|
|
264
|
-
|
|
265
|
-
Returns True if a line was removed.
|
|
266
|
-
"""
|
|
267
|
-
path = _audit_log_path(project_root)
|
|
268
|
-
if not path.is_file():
|
|
269
|
-
return False
|
|
270
|
-
|
|
271
|
-
if candidates_log is not None and hasattr(candidates_log, "_append_lock"):
|
|
272
|
-
lock_ctx = candidates_log._append_lock(path)
|
|
273
|
-
else:
|
|
274
|
-
lock_ctx = contextlib.nullcontext()
|
|
275
|
-
|
|
276
|
-
kept: list[str] = []
|
|
277
|
-
removed = False
|
|
278
|
-
with lock_ctx:
|
|
279
|
-
with path.open("r", encoding="utf-8") as fh:
|
|
280
|
-
for raw in fh:
|
|
281
|
-
stripped = raw.strip()
|
|
282
|
-
if not stripped:
|
|
283
|
-
continue
|
|
284
|
-
try:
|
|
285
|
-
entry = json.loads(stripped)
|
|
286
|
-
except json.JSONDecodeError:
|
|
287
|
-
# Preserve malformed lines verbatim (Story 2 read tolerates them).
|
|
288
|
-
kept.append(raw if raw.endswith("\n") else raw + "\n")
|
|
289
|
-
continue
|
|
290
|
-
if not removed and entry.get("decision_id") == decision_id:
|
|
291
|
-
removed = True
|
|
292
|
-
continue
|
|
293
|
-
kept.append(raw if raw.endswith("\n") else raw + "\n")
|
|
294
|
-
if removed:
|
|
295
|
-
path.write_text("".join(kept), encoding="utf-8")
|
|
296
|
-
return removed
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def _build_entry(
|
|
300
|
-
decision: str,
|
|
301
|
-
issue_number: int,
|
|
302
|
-
repo: str,
|
|
303
|
-
*,
|
|
304
|
-
actor: str,
|
|
305
|
-
reason: str | None = None,
|
|
306
|
-
linked_to: int | None = None,
|
|
307
|
-
prior_decision_id: str | None = None,
|
|
308
|
-
resume_on: str | None = None,
|
|
309
|
-
) -> dict[str, Any]:
|
|
310
|
-
"""Construct an audit-log entry that satisfies the Story 2 schema.
|
|
311
|
-
|
|
312
|
-
The Story 2 ``candidates_log.append`` is a strict validator: it does NOT
|
|
313
|
-
fill in ``decision_id`` / ``timestamp`` for the caller. We generate both
|
|
314
|
-
here (using :func:`_new_decision_id` and :func:`_now_iso`) so every code
|
|
315
|
-
path that lands an audit entry produces a valid record.
|
|
316
|
-
"""
|
|
317
|
-
entry: dict[str, Any] = {
|
|
318
|
-
"decision_id": _new_decision_id(),
|
|
319
|
-
"timestamp": _now_iso(),
|
|
320
|
-
"repo": repo,
|
|
321
|
-
"issue_number": int(issue_number),
|
|
322
|
-
"decision": decision,
|
|
323
|
-
"actor": actor,
|
|
324
|
-
}
|
|
325
|
-
if reason is not None:
|
|
326
|
-
entry["reason"] = reason
|
|
327
|
-
if linked_to is not None:
|
|
328
|
-
entry["linked_to"] = int(linked_to)
|
|
329
|
-
if prior_decision_id is not None:
|
|
330
|
-
entry["prior_decision_id"] = prior_decision_id
|
|
331
|
-
if resume_on is not None:
|
|
332
|
-
entry["resume_on"] = resume_on
|
|
333
|
-
return entry
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def _is_idempotent_repeat(
|
|
337
|
-
n: int, repo: str, decision: str, *, linked_to: int | None = None
|
|
338
|
-
) -> dict | None:
|
|
339
|
-
"""Return the prior entry if the requested action is a no-op."""
|
|
340
|
-
if decision not in _TERMINAL_DECISIONS:
|
|
341
|
-
return None
|
|
342
|
-
log = _require_log()
|
|
343
|
-
prior = log.latest_decision(n, repo)
|
|
344
|
-
if prior is None:
|
|
345
|
-
return None
|
|
346
|
-
if prior.get("decision") != decision:
|
|
347
|
-
return None
|
|
348
|
-
# mark-duplicate idempotency requires the SAME target.
|
|
349
|
-
if decision == "mark-duplicate" and prior.get("linked_to") != linked_to:
|
|
350
|
-
return None
|
|
351
|
-
return prior
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
# Public action surface ----------------------------------------------------
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def accept(
|
|
358
|
-
n: int,
|
|
359
|
-
repo: str,
|
|
360
|
-
*,
|
|
361
|
-
actor: str | None = None,
|
|
362
|
-
project_root: Path | None = None,
|
|
363
|
-
) -> str:
|
|
364
|
-
"""Record an accept audit entry AND delegate vBRIEF authoring to issue_ingest.
|
|
365
|
-
|
|
366
|
-
Performs (in order):
|
|
367
|
-
|
|
368
|
-
1. Idempotency check -- if the issue is already accepted, return the
|
|
369
|
-
prior ``decision_id`` without re-appending and WITHOUT re-ingesting.
|
|
370
|
-
The pre-existing ``vbrief/proposed/`` artefact written on the first
|
|
371
|
-
accept is preserved as-is.
|
|
372
|
-
2. Append the audit entry, capturing ``decision_id``.
|
|
373
|
-
3. Delegate to :func:`scripts.issue_ingest.ingest_single_for_accept` to
|
|
374
|
-
materialise the issue as a scope vBRIEF in ``vbrief/proposed/``
|
|
375
|
-
(per ``skills/deft-directive-refinement/SKILL.md`` Phase 0 Tier 3:
|
|
376
|
-
"task triage:accept is the canonical write path -- it delegates the
|
|
377
|
-
actual vBRIEF authoring to task issue:ingest so slug/reference/schema
|
|
378
|
-
rules stay in one place"). The ingest call is cache-first per #883;
|
|
379
|
-
slug rules + canonical reference shape stay owned by ``issue_ingest``
|
|
380
|
-
per #537.
|
|
381
|
-
4. On ingest failure: roll the audit entry back via
|
|
382
|
-
:func:`_rollback_audit_entry` and re-raise as :class:`TriageError`
|
|
383
|
-
(mirrors :func:`reject`'s upstream-close-failure handling).
|
|
384
|
-
|
|
385
|
-
Idempotency note: the idempotent short-circuit at step 1 deliberately
|
|
386
|
-
skips both the audit append AND the ingest delegation -- a re-accept
|
|
387
|
-
must NOT write a second proposed/ vBRIEF. Story 2's append-only audit
|
|
388
|
-
log preserves the original ``decision_id`` and the slug-stable vBRIEF
|
|
389
|
-
path keeps the original artefact reachable.
|
|
390
|
-
"""
|
|
391
|
-
actor_str = _resolve_actor(actor)
|
|
392
|
-
prior = _is_idempotent_repeat(n, repo, "accept")
|
|
393
|
-
if prior is not None:
|
|
394
|
-
return str(prior["decision_id"])
|
|
395
|
-
log = _require_log()
|
|
396
|
-
entry = _build_entry("accept", n, repo, actor=actor_str)
|
|
397
|
-
decision_id = str(log.append(entry))
|
|
398
|
-
try:
|
|
399
|
-
_delegate_accept_ingest(n, repo, project_root=project_root)
|
|
400
|
-
except Exception as exc: # noqa: BLE001 -- any ingest failure -> rollback
|
|
401
|
-
_rollback_audit_entry(decision_id, project_root=project_root)
|
|
402
|
-
# Surface as a structured TriageError so CLI / Taskfile callers exit
|
|
403
|
-
# non-zero with an actionable message instead of a raw traceback.
|
|
404
|
-
raise TriageError(
|
|
405
|
-
f"accept #{n} ({repo}): issue:ingest delegation failed; "
|
|
406
|
-
f"audit entry rolled back. Cause: {exc}"
|
|
407
|
-
) from exc
|
|
408
|
-
return decision_id
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def _delegate_accept_ingest(
|
|
412
|
-
n: int,
|
|
413
|
-
repo: str,
|
|
414
|
-
*,
|
|
415
|
-
project_root: Path | None = None,
|
|
416
|
-
) -> None:
|
|
417
|
-
"""Invoke ``issue_ingest.ingest_single_for_accept`` for ``(repo, n)``.
|
|
418
|
-
|
|
419
|
-
Raises :class:`TriageError` when ``scripts/issue_ingest.py`` is not
|
|
420
|
-
importable in this checkout (mirrors :func:`_require_log` /
|
|
421
|
-
:func:`_require_cache`). Any exception raised by the ingest path is
|
|
422
|
-
propagated unchanged so :func:`accept` can roll the audit entry back
|
|
423
|
-
with the original cause attached to the chained ``TriageError``.
|
|
424
|
-
"""
|
|
425
|
-
if issue_ingest is None:
|
|
426
|
-
raise TriageError(
|
|
427
|
-
"scripts/issue_ingest.py is not available in this checkout. "
|
|
428
|
-
"#454 (task issue:ingest) must land or this PR must be rebased "
|
|
429
|
-
"onto master."
|
|
430
|
-
)
|
|
431
|
-
issue_ingest.ingest_single_for_accept(n, repo, project_root=project_root)
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
def reject(
|
|
435
|
-
n: int,
|
|
436
|
-
repo: str,
|
|
437
|
-
reason: str,
|
|
438
|
-
*,
|
|
439
|
-
actor: str | None = None,
|
|
440
|
-
project_root: Path | None = None,
|
|
441
|
-
) -> str:
|
|
442
|
-
"""Close upstream + best-effort label + record. Roll back only on close failure.
|
|
443
|
-
|
|
444
|
-
Performs (in order):
|
|
445
|
-
|
|
446
|
-
1. Idempotency check -- if the issue is already rejected, return the
|
|
447
|
-
prior decision_id without re-calling gh.
|
|
448
|
-
2. Append the audit entry, capturing ``decision_id``.
|
|
449
|
-
3. ``gh issue close <n> --comment <reason> --reason 'not planned'``.
|
|
450
|
-
4. Best-effort ``gh issue edit <n> --add-label triage-rejected`` via
|
|
451
|
-
:func:`_ensure_rejected_label_applied` -- self-healing when the
|
|
452
|
-
label is missing on the repo (#1420).
|
|
453
|
-
5. On step 3 (close) failure ONLY: roll back the audit entry from the
|
|
454
|
-
JSONL (per Story 3 vBRIEF Constraint) and re-raise as
|
|
455
|
-
:class:`UpstreamCloseError`.
|
|
456
|
-
|
|
457
|
-
#1420 -- label-application is NOT load-bearing. The close-with-reason
|
|
458
|
-
is the decision that takes effect; once it succeeds the audit entry
|
|
459
|
-
MUST persist. A repository that lacks the ``triage-rejected`` label
|
|
460
|
-
used to fail step 4 and roll back the whole reject even though the
|
|
461
|
-
issue was already closed. The reject flow now auto-creates the label
|
|
462
|
-
when absent and, failing that, tolerates the missing label with a
|
|
463
|
-
stderr warning -- it never rolls back a successful close.
|
|
464
|
-
"""
|
|
465
|
-
actor_str = _resolve_actor(actor)
|
|
466
|
-
prior = _is_idempotent_repeat(n, repo, "reject")
|
|
467
|
-
if prior is not None:
|
|
468
|
-
return str(prior["decision_id"])
|
|
469
|
-
log = _require_log()
|
|
470
|
-
entry = _build_entry("reject", n, repo, actor=actor_str, reason=reason)
|
|
471
|
-
decision_id = str(log.append(entry))
|
|
472
|
-
# Step 3: the close-with-reason is the load-bearing action -- a close
|
|
473
|
-
# failure is the ONLY condition that rolls back the audit entry.
|
|
474
|
-
try:
|
|
475
|
-
_run_gh(
|
|
476
|
-
[
|
|
477
|
-
"issue",
|
|
478
|
-
"close",
|
|
479
|
-
str(n),
|
|
480
|
-
"--repo",
|
|
481
|
-
repo,
|
|
482
|
-
"--comment",
|
|
483
|
-
reason,
|
|
484
|
-
"--reason",
|
|
485
|
-
"not planned",
|
|
486
|
-
]
|
|
487
|
-
)
|
|
488
|
-
except UpstreamCloseError:
|
|
489
|
-
_rollback_audit_entry(decision_id, project_root=project_root)
|
|
490
|
-
raise
|
|
491
|
-
# Step 4: label application is best-effort and self-healing. A missing
|
|
492
|
-
# ``triage-rejected`` label MUST NOT roll back a successful close (#1420).
|
|
493
|
-
_ensure_rejected_label_applied(n, repo)
|
|
494
|
-
return decision_id
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
def _looks_like_missing_label(exc: UpstreamCloseError) -> bool:
|
|
498
|
-
"""Heuristic: did ``gh issue edit --add-label`` fail because the label is absent?
|
|
499
|
-
|
|
500
|
-
``gh`` surfaces a missing label as ``"'triage-rejected' not found"`` (or
|
|
501
|
-
a ``label ... not found`` variant). The check is intentionally broad --
|
|
502
|
-
a false positive only triggers a (harmless, idempotent) label-create
|
|
503
|
-
attempt, while a false negative would leave the #1420 bug unfixed.
|
|
504
|
-
"""
|
|
505
|
-
text = str(exc).lower()
|
|
506
|
-
return "not found" in text or "could not add label" in text
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
def _ensure_label_exists(repo: str) -> None:
|
|
510
|
-
"""Create :data:`REJECTED_LABEL` on ``repo`` when it is missing (#1420).
|
|
511
|
-
|
|
512
|
-
``gh label create`` exits non-zero when the label already exists; that
|
|
513
|
-
specific case is swallowed so a concurrent create or a pre-existing
|
|
514
|
-
label is not treated as an error. Any other failure propagates as
|
|
515
|
-
:class:`UpstreamCloseError` for the caller to tolerate (it must never
|
|
516
|
-
roll back the already-closed issue).
|
|
517
|
-
"""
|
|
518
|
-
try:
|
|
519
|
-
_run_gh(
|
|
520
|
-
[
|
|
521
|
-
"label",
|
|
522
|
-
"create",
|
|
523
|
-
REJECTED_LABEL,
|
|
524
|
-
"--repo",
|
|
525
|
-
repo,
|
|
526
|
-
"--description",
|
|
527
|
-
REJECTED_LABEL_DESCRIPTION,
|
|
528
|
-
"--color",
|
|
529
|
-
REJECTED_LABEL_COLOR,
|
|
530
|
-
]
|
|
531
|
-
)
|
|
532
|
-
except UpstreamCloseError as exc:
|
|
533
|
-
if "already exists" in str(exc).lower():
|
|
534
|
-
return
|
|
535
|
-
raise
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
def _ensure_rejected_label_applied(n: int, repo: str) -> None:
|
|
539
|
-
"""Apply :data:`REJECTED_LABEL` to issue ``n``, auto-creating it if missing.
|
|
540
|
-
|
|
541
|
-
Best-effort by contract (#1420): the caller has already closed the
|
|
542
|
-
issue, so this helper MUST NOT raise -- a failure to label is surfaced
|
|
543
|
-
on stderr but never rolls back the decision. The flow is:
|
|
544
|
-
|
|
545
|
-
1. Try ``gh issue edit --add-label triage-rejected``.
|
|
546
|
-
2. On a missing-label failure, create the label once and re-attempt.
|
|
547
|
-
3. On any continued failure, warn on stderr and return.
|
|
548
|
-
"""
|
|
549
|
-
try:
|
|
550
|
-
_run_gh(
|
|
551
|
-
["issue", "edit", str(n), "--repo", repo, "--add-label", REJECTED_LABEL]
|
|
552
|
-
)
|
|
553
|
-
return
|
|
554
|
-
except UpstreamCloseError as add_exc:
|
|
555
|
-
if not _looks_like_missing_label(add_exc):
|
|
556
|
-
print(
|
|
557
|
-
f"triage_actions: reject #{n} ({repo}) closed successfully but "
|
|
558
|
-
f"the {REJECTED_LABEL!r} label could not be applied: {add_exc}",
|
|
559
|
-
file=sys.stderr,
|
|
560
|
-
)
|
|
561
|
-
return
|
|
562
|
-
# The label is absent on the repo -- create it once, then re-add.
|
|
563
|
-
try:
|
|
564
|
-
_ensure_label_exists(repo)
|
|
565
|
-
_run_gh(
|
|
566
|
-
["issue", "edit", str(n), "--repo", repo, "--add-label", REJECTED_LABEL]
|
|
567
|
-
)
|
|
568
|
-
except UpstreamCloseError as heal_exc:
|
|
569
|
-
print(
|
|
570
|
-
f"triage_actions: reject #{n} ({repo}) closed successfully but the "
|
|
571
|
-
f"{REJECTED_LABEL!r} label is missing and auto-create/re-add "
|
|
572
|
-
f"failed: {heal_exc}",
|
|
573
|
-
file=sys.stderr,
|
|
574
|
-
)
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
def defer(
|
|
578
|
-
n: int,
|
|
579
|
-
repo: str,
|
|
580
|
-
reason: str | None = None,
|
|
581
|
-
*,
|
|
582
|
-
actor: str | None = None,
|
|
583
|
-
resume_on: str | None = None,
|
|
584
|
-
project_root: Path | None = None,
|
|
585
|
-
) -> str:
|
|
586
|
-
"""Record a defer audit entry (#1123 / D3 -- structured reason + resume_on).
|
|
587
|
-
|
|
588
|
-
``reason`` was free-text-only in #845 Story 3 and is now the structured
|
|
589
|
-
rationale field on the audit entry (still optional at the API layer for
|
|
590
|
-
back-compat with callers that pre-date #1123; the CLI surface treats it
|
|
591
|
-
as required so new operator-driven defers always carry rationale).
|
|
592
|
-
|
|
593
|
-
``resume_on`` is the optional structured condition that the resume
|
|
594
|
-
evaluator (`scripts/resume_conditions.evaluate_resume_eligibility`)
|
|
595
|
-
will later consult to surface this defer as ``resume-eligible``.
|
|
596
|
-
Pre-validated at write time when the ``resume_conditions`` module is
|
|
597
|
-
importable so a malformed expression cannot land in the audit log.
|
|
598
|
-
"""
|
|
599
|
-
if resume_on is not None and resume_conditions is not None:
|
|
600
|
-
# Will raise ResumeGrammarError (ValueError subclass) on a bad
|
|
601
|
-
# expression; we let it propagate as a TriageError-shaped
|
|
602
|
-
# ValueError so CLI / Taskfile callers exit non-zero with the
|
|
603
|
-
# parser's actionable message attached.
|
|
604
|
-
try:
|
|
605
|
-
resume_conditions.parse(resume_on)
|
|
606
|
-
except resume_conditions.ResumeGrammarError as exc:
|
|
607
|
-
raise TriageError(
|
|
608
|
-
f"defer #{n} ({repo}): invalid --resume-on expression -- {exc}"
|
|
609
|
-
) from exc
|
|
610
|
-
log = _require_log()
|
|
611
|
-
entry = _build_entry(
|
|
612
|
-
"defer",
|
|
613
|
-
n,
|
|
614
|
-
repo,
|
|
615
|
-
actor=_resolve_actor(actor),
|
|
616
|
-
reason=reason,
|
|
617
|
-
resume_on=resume_on,
|
|
618
|
-
)
|
|
619
|
-
return str(log.append(entry))
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def needs_ac(
|
|
623
|
-
n: int,
|
|
624
|
-
repo: str,
|
|
625
|
-
*,
|
|
626
|
-
actor: str | None = None,
|
|
627
|
-
comment: str | None = None,
|
|
628
|
-
project_root: Path | None = None,
|
|
629
|
-
) -> str:
|
|
630
|
-
"""Record a needs-ac audit entry and post an AC-request comment upstream.
|
|
631
|
-
|
|
632
|
-
The audit entry is appended FIRST so the trail records the request even
|
|
633
|
-
if gh comment fails (this is a non-blocking signal -- we tolerate the
|
|
634
|
-
upstream comment post failing without rolling back).
|
|
635
|
-
"""
|
|
636
|
-
log = _require_log()
|
|
637
|
-
body = comment or (
|
|
638
|
-
"This issue lacks acceptance criteria. Please add a Test/Acceptance "
|
|
639
|
-
"narrative before this can be triaged. (deft #845)"
|
|
640
|
-
)
|
|
641
|
-
entry = _build_entry("needs-ac", n, repo, actor=_resolve_actor(actor), reason=body)
|
|
642
|
-
decision_id = str(log.append(entry))
|
|
643
|
-
# Best-effort -- the audit entry is the source of truth; a failed
|
|
644
|
-
# upstream comment is surfaced on stderr but does NOT roll back the
|
|
645
|
-
# local trail. Greptile #879 P2: the prior `contextlib.suppress` here
|
|
646
|
-
# contradicted this docstring's "logged" claim by silencing the error
|
|
647
|
-
# entirely; the operator now sees the failure even when we keep the
|
|
648
|
-
# audit entry.
|
|
649
|
-
try:
|
|
650
|
-
_run_gh(["issue", "comment", str(n), "--repo", repo, "--body", body])
|
|
651
|
-
except UpstreamCloseError as exc:
|
|
652
|
-
print(
|
|
653
|
-
f"triage_actions: needs-ac comment not posted for #{n} "
|
|
654
|
-
f"({repo}): {exc}",
|
|
655
|
-
file=sys.stderr,
|
|
656
|
-
)
|
|
657
|
-
return decision_id
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
def mark_duplicate(
|
|
661
|
-
n: int,
|
|
662
|
-
repo: str,
|
|
663
|
-
of_n: int,
|
|
664
|
-
*,
|
|
665
|
-
actor: str | None = None,
|
|
666
|
-
project_root: Path | None = None,
|
|
667
|
-
) -> str:
|
|
668
|
-
"""Validate target exists in unified cache + record mark-duplicate audit entry.
|
|
669
|
-
|
|
670
|
-
Reads the target via :func:`scripts.cache.cache_get` (#883 Story 3 rebind
|
|
671
|
-
onto cache:*). The ``allow_stale=True`` flag lets the validation succeed
|
|
672
|
-
against an entry whose TTL has expired -- a stale-but-cached duplicate
|
|
673
|
-
target is still an acceptable cross-link reference; the operator can
|
|
674
|
-
refresh the entry later via ``task cache:fetch-all``.
|
|
675
|
-
"""
|
|
676
|
-
if int(of_n) == int(n):
|
|
677
|
-
raise TriageError(f"mark-duplicate target #{of_n} cannot equal source #{n}")
|
|
678
|
-
cache_mod = _require_cache()
|
|
679
|
-
key = f"{repo}/{int(of_n)}"
|
|
680
|
-
try:
|
|
681
|
-
cache_mod.cache_get("github-issue", key, allow_stale=True)
|
|
682
|
-
except Exception as exc: # noqa: BLE001 -- cache may raise any error type
|
|
683
|
-
raise TriageError(
|
|
684
|
-
f"mark-duplicate target #{of_n} not found in cache for {repo}: {exc}"
|
|
685
|
-
) from exc
|
|
686
|
-
prior = _is_idempotent_repeat(n, repo, "mark-duplicate", linked_to=int(of_n))
|
|
687
|
-
if prior is not None:
|
|
688
|
-
return str(prior["decision_id"])
|
|
689
|
-
log = _require_log()
|
|
690
|
-
entry = _build_entry(
|
|
691
|
-
"mark-duplicate",
|
|
692
|
-
n,
|
|
693
|
-
repo,
|
|
694
|
-
actor=_resolve_actor(actor),
|
|
695
|
-
linked_to=int(of_n),
|
|
696
|
-
)
|
|
697
|
-
return str(log.append(entry))
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
def status(n: int, repo: str) -> dict | None:
|
|
701
|
-
"""Return the latest decision for ``n`` in ``repo`` (None if none)."""
|
|
702
|
-
log = _require_log()
|
|
703
|
-
return log.latest_decision(n, repo)
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
def reset(
|
|
707
|
-
n: int,
|
|
708
|
-
repo: str,
|
|
709
|
-
*,
|
|
710
|
-
actor: str | None = None,
|
|
711
|
-
project_root: Path | None = None,
|
|
712
|
-
) -> str:
|
|
713
|
-
"""Record a reset audit entry referencing the prior decision_id.
|
|
714
|
-
|
|
715
|
-
Reset is reversible: it does NOT delete history, it appends a new entry
|
|
716
|
-
of type ``reset`` whose ``prior_decision_id`` is the most recent
|
|
717
|
-
non-reset decision. Re-resetting an already-reset issue is a no-op.
|
|
718
|
-
"""
|
|
719
|
-
log = _require_log()
|
|
720
|
-
prior = log.latest_decision(n, repo)
|
|
721
|
-
if prior is None:
|
|
722
|
-
raise TriageError(f"cannot reset #{n}: no prior decision recorded for {repo}")
|
|
723
|
-
if prior.get("decision") == "reset":
|
|
724
|
-
return str(prior["decision_id"])
|
|
725
|
-
entry = _build_entry(
|
|
726
|
-
"reset",
|
|
727
|
-
n,
|
|
728
|
-
repo,
|
|
729
|
-
actor=_resolve_actor(actor),
|
|
730
|
-
prior_decision_id=str(prior["decision_id"]),
|
|
731
|
-
)
|
|
732
|
-
return str(log.append(entry))
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
def history(n: int, repo: str) -> list[dict]:
|
|
736
|
-
"""Return audit entries for ``n`` ordered ascending by timestamp."""
|
|
737
|
-
log = _require_log()
|
|
738
|
-
entries = list(log.find_by_issue(n, repo))
|
|
739
|
-
entries.sort(key=lambda e: str(e.get("timestamp", "")))
|
|
740
|
-
return entries
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
# CLI plumbing --------------------------------------------------------------
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
def _format_decision(entry: dict | None) -> str:
|
|
747
|
-
if entry is None:
|
|
748
|
-
return "(no decision recorded)"
|
|
749
|
-
parts = [
|
|
750
|
-
f"decision={entry.get('decision')}",
|
|
751
|
-
f"issue=#{entry.get('issue_number')}",
|
|
752
|
-
f"repo={entry.get('repo')}",
|
|
753
|
-
f"actor={entry.get('actor')}",
|
|
754
|
-
f"timestamp={entry.get('timestamp')}",
|
|
755
|
-
f"decision_id={entry.get('decision_id')}",
|
|
756
|
-
]
|
|
757
|
-
if entry.get("reason"):
|
|
758
|
-
parts.append(f"reason={entry['reason']!r}")
|
|
759
|
-
if entry.get("linked_to") is not None:
|
|
760
|
-
parts.append(f"linked_to=#{entry['linked_to']}")
|
|
761
|
-
if entry.get("prior_decision_id"):
|
|
762
|
-
parts.append(f"prior_decision_id={entry['prior_decision_id']}")
|
|
763
|
-
return " " + " | ".join(parts)
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
767
|
-
parser = argparse.ArgumentParser(prog="triage_actions.py")
|
|
768
|
-
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
769
|
-
|
|
770
|
-
def _common(p: argparse.ArgumentParser) -> None:
|
|
771
|
-
p.add_argument("--issue", type=int, required=True, help="Issue number (N).")
|
|
772
|
-
p.add_argument("--repo", required=True, help="Upstream repo as owner/name.")
|
|
773
|
-
p.add_argument("--actor", default=None, help="Override the audit actor field.")
|
|
774
|
-
|
|
775
|
-
for cmd in ("accept", "status", "reset", "history"):
|
|
776
|
-
p = sub.add_parser(cmd)
|
|
777
|
-
_common(p)
|
|
778
|
-
|
|
779
|
-
# #1123: ``defer`` now requires --reason (replacing free-text defer)
|
|
780
|
-
# and optionally accepts --resume-on.
|
|
781
|
-
p_defer = sub.add_parser("defer")
|
|
782
|
-
_common(p_defer)
|
|
783
|
-
p_defer.add_argument(
|
|
784
|
-
"--reason",
|
|
785
|
-
required=True,
|
|
786
|
-
help="Structured rationale captured on the defer audit entry (#1123).",
|
|
787
|
-
)
|
|
788
|
-
p_defer.add_argument(
|
|
789
|
-
"--resume-on",
|
|
790
|
-
default=None,
|
|
791
|
-
dest="resume_on",
|
|
792
|
-
help=(
|
|
793
|
-
"Optional resume-condition expression (#1123). Grammar v1: "
|
|
794
|
-
"ref:closed:#N | ref:merged:#N | date:>=YYYY-MM-DD | "
|
|
795
|
-
"pending-count:>=N | pending-count:<=N, joined by AND/OR."
|
|
796
|
-
),
|
|
797
|
-
)
|
|
798
|
-
|
|
799
|
-
p_reject = sub.add_parser("reject")
|
|
800
|
-
_common(p_reject)
|
|
801
|
-
p_reject.add_argument("--reason", required=True)
|
|
802
|
-
|
|
803
|
-
p_needs = sub.add_parser("needs-ac")
|
|
804
|
-
_common(p_needs)
|
|
805
|
-
p_needs.add_argument("--comment", default=None)
|
|
806
|
-
|
|
807
|
-
p_dup = sub.add_parser("mark-duplicate")
|
|
808
|
-
_common(p_dup)
|
|
809
|
-
p_dup.add_argument("--of", type=int, required=True, dest="of_n")
|
|
810
|
-
|
|
811
|
-
return parser
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
def main(argv: list[str] | None = None) -> int:
|
|
815
|
-
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
816
|
-
from triage_help import intercept_help
|
|
817
|
-
|
|
818
|
-
rc = intercept_help("triage_actions", argv)
|
|
819
|
-
if rc is not None:
|
|
820
|
-
return rc
|
|
821
|
-
parser = _build_parser()
|
|
822
|
-
args = parser.parse_args(argv)
|
|
823
|
-
n = int(args.issue)
|
|
824
|
-
repo = str(args.repo)
|
|
825
|
-
actor = args.actor
|
|
826
|
-
|
|
827
|
-
try:
|
|
828
|
-
if args.cmd == "accept":
|
|
829
|
-
decision_id = accept(n, repo, actor=actor)
|
|
830
|
-
print(f"accept #{n} ({repo}) -> {decision_id}")
|
|
831
|
-
elif args.cmd == "reject":
|
|
832
|
-
decision_id = reject(n, repo, args.reason, actor=actor)
|
|
833
|
-
print(f"reject #{n} ({repo}) -> {decision_id}")
|
|
834
|
-
elif args.cmd == "defer":
|
|
835
|
-
decision_id = defer(
|
|
836
|
-
n,
|
|
837
|
-
repo,
|
|
838
|
-
args.reason,
|
|
839
|
-
actor=actor,
|
|
840
|
-
resume_on=getattr(args, "resume_on", None),
|
|
841
|
-
)
|
|
842
|
-
print(f"defer #{n} ({repo}) -> {decision_id}")
|
|
843
|
-
elif args.cmd == "needs-ac":
|
|
844
|
-
decision_id = needs_ac(n, repo, actor=actor, comment=args.comment)
|
|
845
|
-
print(f"needs-ac #{n} ({repo}) -> {decision_id}")
|
|
846
|
-
elif args.cmd == "mark-duplicate":
|
|
847
|
-
decision_id = mark_duplicate(n, repo, args.of_n, actor=actor)
|
|
848
|
-
print(f"mark-duplicate #{n} -> #{args.of_n} ({repo}) -> {decision_id}")
|
|
849
|
-
elif args.cmd == "status":
|
|
850
|
-
print(_format_decision(status(n, repo)))
|
|
851
|
-
elif args.cmd == "reset":
|
|
852
|
-
decision_id = reset(n, repo, actor=actor)
|
|
853
|
-
print(f"reset #{n} ({repo}) -> {decision_id}")
|
|
854
|
-
elif args.cmd == "history":
|
|
855
|
-
entries = history(n, repo)
|
|
856
|
-
if not entries:
|
|
857
|
-
print(_format_decision(None))
|
|
858
|
-
else:
|
|
859
|
-
for entry in entries:
|
|
860
|
-
print(_format_decision(entry))
|
|
861
|
-
else: # pragma: no cover -- argparse enforces above set
|
|
862
|
-
parser.print_help()
|
|
863
|
-
return 2
|
|
864
|
-
except TriageError as exc:
|
|
865
|
-
print(f"triage_actions: {exc}", file=sys.stderr)
|
|
866
|
-
return 1
|
|
867
|
-
return 0
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
if __name__ == "__main__":
|
|
871
|
-
sys.exit(main())
|