@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,399 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""triage_subscribe.py -- subscribe / unsubscribe mutation verbs (D14 / #1133).
|
|
3
|
+
|
|
4
|
+
Two operations:
|
|
5
|
+
|
|
6
|
+
* :func:`subscribe` -- atomically appends a rule (or merges into an
|
|
7
|
+
existing one) on ``plan.policy.triageScope[]``. Supports
|
|
8
|
+
``--label=<L>`` (merges into an existing ``labels.any-of`` rule when
|
|
9
|
+
one exists, otherwise creates a new one), ``--milestone=<M>``
|
|
10
|
+
(appends a new ``{rule: "milestone", name: M}`` entry), and
|
|
11
|
+
``--issue=<N>`` (appends to the first ``explicit-watch`` rule's
|
|
12
|
+
``issues`` list).
|
|
13
|
+
* :func:`unsubscribe` -- atomically removes a rule entry. The reverse
|
|
14
|
+
of the operations above; out-of-scope cached issues are NOT deleted
|
|
15
|
+
from ``.deft-cache/`` (the existing scanner v2 cache pattern is
|
|
16
|
+
append-only at the framework level; lifecycle pruning is a separate
|
|
17
|
+
reconciliation step the operator triggers explicitly).
|
|
18
|
+
|
|
19
|
+
Every mutation writes a ``subscription-change`` audit record to a
|
|
20
|
+
NEW sidecar at ``vbrief/.eval/subscription-history.jsonl`` (mirrors
|
|
21
|
+
the D2 ``summary-history.jsonl`` precedent). The canonical
|
|
22
|
+
``candidates.jsonl`` schema (#845 Story 2) is FROZEN -- it requires a
|
|
23
|
+
``decision`` from a fixed vocabulary and a per-issue ``issue_number``
|
|
24
|
+
+ ``repo`` pair, neither of which fit a subscription-level mutation.
|
|
25
|
+
Using a sidecar keeps the frozen schema intact while preserving the
|
|
26
|
+
"audit entry on every mutation" contract from the issue body.
|
|
27
|
+
|
|
28
|
+
Verbs are idempotent: re-subscribing to an already-subscribed signal
|
|
29
|
+
returns ``(False, "<reason>")`` without touching the file or the
|
|
30
|
+
audit log. After a mutating call, the CLI prints a reconciliation
|
|
31
|
+
hint pointing the operator at ``task triage:bootstrap -- --resume``
|
|
32
|
+
to backfill / mark out-of-scope cached entries.
|
|
33
|
+
|
|
34
|
+
CLI shim lives at ``scripts/_triage_subscribe_cli.py`` (1000-line cap).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import contextlib
|
|
40
|
+
import getpass
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
import uuid
|
|
45
|
+
from datetime import UTC, datetime
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
# Sibling imports
|
|
50
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
51
|
+
|
|
52
|
+
# UTF-8 self-reconfigure
|
|
53
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
54
|
+
if hasattr(_stream, "reconfigure"):
|
|
55
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
56
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
SUBSCRIPTION_HISTORY_REL_PATH = "vbrief/.eval/subscription-history.jsonl"
|
|
60
|
+
SUBSCRIPTION_HISTORY_SCHEMA = "deft.triage.subscription-change.v1"
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Public API
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def subscribe(
|
|
68
|
+
project_root: Path,
|
|
69
|
+
*,
|
|
70
|
+
label: str | None = None,
|
|
71
|
+
milestone: str | None = None,
|
|
72
|
+
issue: int | None = None,
|
|
73
|
+
issue_note: str = "added via task triage:subscribe",
|
|
74
|
+
actor: str | None = None,
|
|
75
|
+
) -> tuple[bool, str]:
|
|
76
|
+
"""Add a rule (or merge into an existing one) on ``plan.policy.triageScope[]``.
|
|
77
|
+
|
|
78
|
+
Exactly one of ``label``, ``milestone``, ``issue`` MUST be set.
|
|
79
|
+
Returns ``(changed, message)``. Idempotent: re-subscribing to an
|
|
80
|
+
already-covered signal is a no-op with informational ``message``.
|
|
81
|
+
|
|
82
|
+
On a successful mutation, atomically writes PROJECT-DEFINITION and
|
|
83
|
+
appends a ``subscription-change`` record to
|
|
84
|
+
``vbrief/.eval/subscription-history.jsonl``.
|
|
85
|
+
"""
|
|
86
|
+
return _mutate(
|
|
87
|
+
project_root,
|
|
88
|
+
op="subscribe",
|
|
89
|
+
label=label,
|
|
90
|
+
milestone=milestone,
|
|
91
|
+
issue=issue,
|
|
92
|
+
issue_note=issue_note,
|
|
93
|
+
actor=actor,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def unsubscribe(
|
|
98
|
+
project_root: Path,
|
|
99
|
+
*,
|
|
100
|
+
label: str | None = None,
|
|
101
|
+
milestone: str | None = None,
|
|
102
|
+
issue: int | None = None,
|
|
103
|
+
actor: str | None = None,
|
|
104
|
+
) -> tuple[bool, str]:
|
|
105
|
+
"""Remove a rule entry from ``plan.policy.triageScope[]``.
|
|
106
|
+
|
|
107
|
+
Idempotent: removing an already-absent signal is a no-op. Returns
|
|
108
|
+
``(changed, message)`` mirroring :func:`subscribe`.
|
|
109
|
+
"""
|
|
110
|
+
return _mutate(
|
|
111
|
+
project_root,
|
|
112
|
+
op="unsubscribe",
|
|
113
|
+
label=label,
|
|
114
|
+
milestone=milestone,
|
|
115
|
+
issue=issue,
|
|
116
|
+
actor=actor,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Internals
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _mutate(
|
|
126
|
+
project_root: Path,
|
|
127
|
+
*,
|
|
128
|
+
op: str,
|
|
129
|
+
label: str | None,
|
|
130
|
+
milestone: str | None,
|
|
131
|
+
issue: int | None,
|
|
132
|
+
issue_note: str = "added via task triage:subscribe",
|
|
133
|
+
actor: str | None = None,
|
|
134
|
+
) -> tuple[bool, str]:
|
|
135
|
+
"""Shared subscribe/unsubscribe core."""
|
|
136
|
+
chosen = [
|
|
137
|
+
name
|
|
138
|
+
for name, val in (("label", label), ("milestone", milestone), ("issue", issue))
|
|
139
|
+
if val is not None
|
|
140
|
+
]
|
|
141
|
+
if len(chosen) != 1:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"{op}() requires exactly one of --label / --milestone / --issue; "
|
|
144
|
+
f"got {chosen}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
from _project_definition_io import (
|
|
148
|
+
atomic_write_project_definition,
|
|
149
|
+
load_project_definition_for_mutation,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
data, path = load_project_definition_for_mutation(project_root)
|
|
153
|
+
plan = data.setdefault("plan", {})
|
|
154
|
+
if not isinstance(plan, dict):
|
|
155
|
+
raise ValueError(f"PROJECT-DEFINITION at {path} has a non-object 'plan' key")
|
|
156
|
+
policy = plan.setdefault("policy", {})
|
|
157
|
+
if not isinstance(policy, dict):
|
|
158
|
+
raise ValueError(f"PROJECT-DEFINITION at {path} has a non-object 'plan.policy' key")
|
|
159
|
+
rules = policy.setdefault("triageScope", [])
|
|
160
|
+
if not isinstance(rules, list):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"PROJECT-DEFINITION at {path} has a non-list 'plan.policy.triageScope'"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
before = _snapshot_rules(rules)
|
|
166
|
+
if op == "subscribe":
|
|
167
|
+
changed, message = _apply_subscribe(rules, label, milestone, issue, issue_note)
|
|
168
|
+
elif op == "unsubscribe":
|
|
169
|
+
changed, message = _apply_unsubscribe(rules, label, milestone, issue)
|
|
170
|
+
else: # pragma: no cover -- defensive
|
|
171
|
+
raise ValueError(f"unknown op {op!r}")
|
|
172
|
+
|
|
173
|
+
if not changed:
|
|
174
|
+
return False, message
|
|
175
|
+
|
|
176
|
+
atomic_write_project_definition(path, data)
|
|
177
|
+
after = _snapshot_rules(rules)
|
|
178
|
+
record_subscription_change(
|
|
179
|
+
project_root,
|
|
180
|
+
op=op,
|
|
181
|
+
label=label,
|
|
182
|
+
milestone=milestone,
|
|
183
|
+
issue=issue,
|
|
184
|
+
before=before,
|
|
185
|
+
after=after,
|
|
186
|
+
actor=actor,
|
|
187
|
+
)
|
|
188
|
+
return True, message
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _apply_subscribe(
|
|
192
|
+
rules: list[Any],
|
|
193
|
+
label: str | None,
|
|
194
|
+
milestone: str | None,
|
|
195
|
+
issue: int | None,
|
|
196
|
+
issue_note: str,
|
|
197
|
+
) -> tuple[bool, str]:
|
|
198
|
+
if label is not None:
|
|
199
|
+
# Find or create a labels rule (any-of). When an existing labels
|
|
200
|
+
# rule uses all-of we leave it alone and append a new any-of rule
|
|
201
|
+
# so we don't silently weaken the operator's all-of intent.
|
|
202
|
+
for rule in rules:
|
|
203
|
+
if (
|
|
204
|
+
isinstance(rule, dict)
|
|
205
|
+
and rule.get("rule") == "labels"
|
|
206
|
+
and isinstance(rule.get("any-of"), list)
|
|
207
|
+
):
|
|
208
|
+
if label in rule["any-of"]:
|
|
209
|
+
return False, f"already-subscribed (labels.any-of contains {label!r})"
|
|
210
|
+
rule["any-of"].append(label)
|
|
211
|
+
return True, f"added {label!r} to existing labels.any-of"
|
|
212
|
+
rules.append({"rule": "labels", "any-of": [label]})
|
|
213
|
+
return True, f"created new labels.any-of rule for {label!r}"
|
|
214
|
+
|
|
215
|
+
if milestone is not None:
|
|
216
|
+
for rule in rules:
|
|
217
|
+
if (
|
|
218
|
+
isinstance(rule, dict)
|
|
219
|
+
and rule.get("rule") == "milestone"
|
|
220
|
+
and rule.get("name") == milestone
|
|
221
|
+
):
|
|
222
|
+
return False, f"already-subscribed (milestone {milestone!r})"
|
|
223
|
+
rules.append({"rule": "milestone", "name": milestone})
|
|
224
|
+
return True, f"added milestone rule for {milestone!r}"
|
|
225
|
+
|
|
226
|
+
if issue is not None:
|
|
227
|
+
for rule in rules:
|
|
228
|
+
if (
|
|
229
|
+
isinstance(rule, dict)
|
|
230
|
+
and rule.get("rule") == "explicit-watch"
|
|
231
|
+
and isinstance(rule.get("issues"), list)
|
|
232
|
+
):
|
|
233
|
+
if any(isinstance(e, dict) and e.get("n") == issue for e in rule["issues"]):
|
|
234
|
+
return False, f"already-subscribed (explicit-watch issue #{issue})"
|
|
235
|
+
rule["issues"].append({"n": issue, "note": issue_note})
|
|
236
|
+
return True, f"added #{issue} to existing explicit-watch"
|
|
237
|
+
rules.append(
|
|
238
|
+
{
|
|
239
|
+
"rule": "explicit-watch",
|
|
240
|
+
"issues": [{"n": issue, "note": issue_note}],
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
return True, f"created new explicit-watch rule for #{issue}"
|
|
244
|
+
|
|
245
|
+
return False, "no-op" # pragma: no cover -- guarded by _mutate
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _apply_unsubscribe(
|
|
249
|
+
rules: list[Any],
|
|
250
|
+
label: str | None,
|
|
251
|
+
milestone: str | None,
|
|
252
|
+
issue: int | None,
|
|
253
|
+
) -> tuple[bool, str]:
|
|
254
|
+
if label is not None:
|
|
255
|
+
for i, rule in enumerate(rules):
|
|
256
|
+
if not isinstance(rule, dict) or rule.get("rule") != "labels":
|
|
257
|
+
continue
|
|
258
|
+
for key in ("any-of", "all-of"):
|
|
259
|
+
items = rule.get(key)
|
|
260
|
+
if isinstance(items, list) and label in items:
|
|
261
|
+
items.remove(label)
|
|
262
|
+
if not items:
|
|
263
|
+
# Drop the whole rule when the last label is gone.
|
|
264
|
+
rules.pop(i)
|
|
265
|
+
return True, f"removed {label!r} from labels.{key}"
|
|
266
|
+
return False, f"not-subscribed (no labels rule mentions {label!r})"
|
|
267
|
+
|
|
268
|
+
if milestone is not None:
|
|
269
|
+
for i, rule in enumerate(rules):
|
|
270
|
+
if (
|
|
271
|
+
isinstance(rule, dict)
|
|
272
|
+
and rule.get("rule") == "milestone"
|
|
273
|
+
and rule.get("name") == milestone
|
|
274
|
+
):
|
|
275
|
+
rules.pop(i)
|
|
276
|
+
return True, f"removed milestone rule for {milestone!r}"
|
|
277
|
+
return False, f"not-subscribed (no milestone rule for {milestone!r})"
|
|
278
|
+
|
|
279
|
+
if issue is not None:
|
|
280
|
+
for i, rule in enumerate(rules):
|
|
281
|
+
if not isinstance(rule, dict) or rule.get("rule") != "explicit-watch":
|
|
282
|
+
continue
|
|
283
|
+
items = rule.get("issues")
|
|
284
|
+
if not isinstance(items, list):
|
|
285
|
+
continue
|
|
286
|
+
new_items = [e for e in items if not (isinstance(e, dict) and e.get("n") == issue)]
|
|
287
|
+
if len(new_items) != len(items):
|
|
288
|
+
if not new_items:
|
|
289
|
+
rules.pop(i)
|
|
290
|
+
else:
|
|
291
|
+
rule["issues"] = new_items
|
|
292
|
+
return True, f"removed #{issue} from explicit-watch"
|
|
293
|
+
return False, f"not-subscribed (no explicit-watch entry for #{issue})"
|
|
294
|
+
|
|
295
|
+
return False, "no-op" # pragma: no cover
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _snapshot_rules(rules: list[Any]) -> list[Any]:
|
|
299
|
+
"""Return a JSON-safe deep copy of the rules list for audit diffing."""
|
|
300
|
+
return json.loads(json.dumps(rules))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _utc_iso(dt: datetime | None = None) -> str:
|
|
304
|
+
return (dt or datetime.now(UTC)).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _resolve_actor(actor: str | None) -> str:
|
|
308
|
+
if isinstance(actor, str) and actor.strip():
|
|
309
|
+
return actor
|
|
310
|
+
env_actor = os.environ.get("DEFT_TRIAGE_ACTOR")
|
|
311
|
+
if isinstance(env_actor, str) and env_actor.strip():
|
|
312
|
+
return env_actor
|
|
313
|
+
try:
|
|
314
|
+
return f"user:{getpass.getuser()}"
|
|
315
|
+
except (KeyError, OSError):
|
|
316
|
+
return "user:unknown"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def record_subscription_change(
|
|
320
|
+
project_root: Path,
|
|
321
|
+
*,
|
|
322
|
+
op: str,
|
|
323
|
+
label: str | None = None,
|
|
324
|
+
milestone: str | None = None,
|
|
325
|
+
issue: int | None = None,
|
|
326
|
+
author: str | None = None,
|
|
327
|
+
before: list[Any] | None = None,
|
|
328
|
+
after: list[Any] | None = None,
|
|
329
|
+
actor: str | None = None,
|
|
330
|
+
extra: dict[str, Any] | None = None,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Append one JSONL record to ``vbrief/.eval/subscription-history.jsonl``.
|
|
333
|
+
|
|
334
|
+
Public since D14c (#1182): the ignore-list mutation surface
|
|
335
|
+
(``scripts/triage_scope_drift.add_ignore``) and the new
|
|
336
|
+
``task triage:scope`` wrapper verbs need to write the same audit
|
|
337
|
+
trail subscribe / unsubscribe already write. ``op`` carries the
|
|
338
|
+
verb-name discriminator (``subscribe``, ``unsubscribe``,
|
|
339
|
+
``ignore-label``, ``ignore-milestone``, ``ignore-author``);
|
|
340
|
+
schema field names mirror the discriminator (``label`` /
|
|
341
|
+
``milestone`` / ``issue`` / ``author``).
|
|
342
|
+
|
|
343
|
+
``extra`` is a per-op opaque blob (e.g. ``{"any-of": [...]}`` for
|
|
344
|
+
ignore-author) preserved verbatim in the JSONL record so consumers
|
|
345
|
+
can audit the structured payload.
|
|
346
|
+
|
|
347
|
+
Pure-stdlib append. Failures are silenced via ``contextlib.suppress``
|
|
348
|
+
because the sidecar is observability, not load-bearing for the
|
|
349
|
+
mutation itself.
|
|
350
|
+
"""
|
|
351
|
+
history_path = project_root / SUBSCRIPTION_HISTORY_REL_PATH
|
|
352
|
+
record: dict[str, Any] = {
|
|
353
|
+
"schema": SUBSCRIPTION_HISTORY_SCHEMA,
|
|
354
|
+
"change_id": str(uuid.uuid4()),
|
|
355
|
+
"timestamp": _utc_iso(),
|
|
356
|
+
"actor": _resolve_actor(actor),
|
|
357
|
+
"op": op,
|
|
358
|
+
"label": label,
|
|
359
|
+
"milestone": milestone,
|
|
360
|
+
"issue": issue,
|
|
361
|
+
"author": author,
|
|
362
|
+
"before": before if before is not None else [],
|
|
363
|
+
"after": after if after is not None else [],
|
|
364
|
+
}
|
|
365
|
+
if extra:
|
|
366
|
+
record["extra"] = extra
|
|
367
|
+
line = json.dumps(record, sort_keys=True, ensure_ascii=False)
|
|
368
|
+
with contextlib.suppress(OSError):
|
|
369
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
with open(history_path, "a", encoding="utf-8", newline="") as fh:
|
|
371
|
+
fh.write(line + "\n")
|
|
372
|
+
fh.flush()
|
|
373
|
+
with contextlib.suppress(OSError):
|
|
374
|
+
os.fsync(fh.fileno())
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# Backward-compat alias for the private name retained for callers that
|
|
378
|
+
# imported the leading-underscore form before D14c (#1182).
|
|
379
|
+
_append_subscription_change = record_subscription_change
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def main(argv: list[str] | None = None) -> int:
|
|
383
|
+
"""CLI entry point. Delegates to :mod:`_triage_subscribe_cli`."""
|
|
384
|
+
import sys as _sys
|
|
385
|
+
|
|
386
|
+
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
387
|
+
from triage_help import intercept_help
|
|
388
|
+
|
|
389
|
+
rc = intercept_help("triage_subscribe", argv)
|
|
390
|
+
if rc is not None:
|
|
391
|
+
return rc
|
|
392
|
+
|
|
393
|
+
from _triage_subscribe_cli import run_cli
|
|
394
|
+
|
|
395
|
+
return run_cli(argv, _sys.modules[__name__])
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
sys.exit(main())
|