@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,932 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""triage_classify.py -- auto-classification for cached upstream issues (#1129 / D10).
|
|
3
|
+
|
|
4
|
+
Wave-1 D10 child of umbrella #1119. Anchored to Current Shape comment
|
|
5
|
+
4471901622 on issue #1129. Ships consumer-agnostic primitives only:
|
|
6
|
+
|
|
7
|
+
* :data:`UNIVERSAL_RULES` -- four hardcoded framework rules (Decision 1):
|
|
8
|
+
1. Body contains any hold-marker phrase -> defer ``hold marker in body``.
|
|
9
|
+
2. Closed upstream AND never triaged -> archive ``closed upstream and
|
|
10
|
+
never triaged``.
|
|
11
|
+
3. No activity > 90 days AND body absent/<50 chars
|
|
12
|
+
-> defer ``dormant; needs AC
|
|
13
|
+
refresh``.
|
|
14
|
+
4. Already referenced from pending/active vBRIEFs
|
|
15
|
+
-> accept ``already referenced
|
|
16
|
+
from a scope vBRIEF``.
|
|
17
|
+
* :data:`DEFAULT_HOLD_MARKERS` -- four default hold-marker phrases
|
|
18
|
+
(``do not implement`` / ``BLOCKED`` / ``HOLDING`` /
|
|
19
|
+
``Holding / capture only``). Overridable per-consumer via
|
|
20
|
+
``plan.policy.triageHoldMarkers[]`` (Decision 3).
|
|
21
|
+
* ``plan.policy.triageAutoClassify[]`` typed-policy schema (Decision 2):
|
|
22
|
+
|
|
23
|
+
.. code-block:: json
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"match": {
|
|
27
|
+
"labels": {"any-of": [...]} | {"all-of": [...]},
|
|
28
|
+
"body-text": {"any-of": [...]},
|
|
29
|
+
"state": "open" | "closed",
|
|
30
|
+
"age-days": {"gt": N}
|
|
31
|
+
},
|
|
32
|
+
"action": "defer" | "archive" | "escalate" | "accept",
|
|
33
|
+
"reason": "<text>",
|
|
34
|
+
"resume-on": "<D3 resume condition>" // optional
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Framework default for the typed array = **empty** (Decision 2). The four
|
|
38
|
+
universal rules above are HARDCODED and consumer-specific label rules
|
|
39
|
+
layer on top.
|
|
40
|
+
|
|
41
|
+
* Order of evaluation: framework universal rules first, then consumer
|
|
42
|
+
rules in declared order; **first match wins** (Decision 2).
|
|
43
|
+
|
|
44
|
+
Public API:
|
|
45
|
+
|
|
46
|
+
* :func:`validate_classify_rules` / :func:`validate_hold_markers`
|
|
47
|
+
* :func:`resolve_classify_rules` / :func:`resolve_hold_markers`
|
|
48
|
+
* :func:`classify_issue`
|
|
49
|
+
* :func:`validate_triage_auto_classify_on_plan` / :func:`validate_triage_hold_markers_on_plan`
|
|
50
|
+
-- vbrief_validate hooks
|
|
51
|
+
|
|
52
|
+
§12 boundary: this module ships ZERO deft-specific label / milestone /
|
|
53
|
+
state values. Consumer-specific label rules live OUTSIDE the framework
|
|
54
|
+
(see #1186 consumer-example child of #1119).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import contextlib
|
|
60
|
+
import json
|
|
61
|
+
import sys
|
|
62
|
+
from collections.abc import Iterable
|
|
63
|
+
from dataclasses import dataclass
|
|
64
|
+
from datetime import UTC, datetime, timedelta
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from typing import Any
|
|
67
|
+
|
|
68
|
+
# Make sibling scripts importable when invoked as
|
|
69
|
+
# ``python scripts/triage_classify.py``.
|
|
70
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
71
|
+
|
|
72
|
+
# UTF-8 self-reconfigure -- the recap printed by ``--list`` includes the
|
|
73
|
+
# checkmark glyphs that cp1252 cannot encode.
|
|
74
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
75
|
+
if hasattr(_stream, "reconfigure"):
|
|
76
|
+
with contextlib.suppress(AttributeError, ValueError):
|
|
77
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Public constants
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
#: Filesystem-relative location of the PROJECT-DEFINITION vBRIEF.
|
|
85
|
+
PROJECT_DEFINITION_REL_PATH = "vbrief/PROJECT-DEFINITION.vbrief.json"
|
|
86
|
+
|
|
87
|
+
#: Threshold in days for the "dormant" universal rule (Decision 1).
|
|
88
|
+
DORMANT_AGE_DAYS: int = 90
|
|
89
|
+
|
|
90
|
+
#: Threshold in characters for "thin body" used by the dormant rule.
|
|
91
|
+
THIN_BODY_THRESHOLD_CHARS: int = 50
|
|
92
|
+
|
|
93
|
+
#: Default hold-marker phrases (Decision 1 + Decision 3). Consumers may
|
|
94
|
+
#: extend this list via ``plan.policy.triageHoldMarkers[]``. Note that
|
|
95
|
+
#: the matching is case-INsensitive for the all-lowercase / all-uppercase
|
|
96
|
+
#: idioms commonly used in issue bodies.
|
|
97
|
+
DEFAULT_HOLD_MARKERS: tuple[str, ...] = (
|
|
98
|
+
"do not implement",
|
|
99
|
+
"BLOCKED",
|
|
100
|
+
"HOLDING",
|
|
101
|
+
"Holding / capture only",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
#: Recognised action values for a consumer rule.
|
|
105
|
+
VALID_ACTIONS: frozenset[str] = frozenset({"defer", "archive", "escalate", "accept"})
|
|
106
|
+
|
|
107
|
+
#: Recognised state values for the ``match.state`` predicate.
|
|
108
|
+
VALID_STATES: frozenset[str] = frozenset({"open", "closed"})
|
|
109
|
+
|
|
110
|
+
#: Internal discriminators for the four framework universal rules. These
|
|
111
|
+
#: are NOT exposed in the consumer schema; the validator below rejects
|
|
112
|
+
#: any consumer rule whose ``match`` block omits the typed predicates.
|
|
113
|
+
_UNIVERSAL_RULE_KINDS: tuple[str, ...] = (
|
|
114
|
+
"universal:hold-marker",
|
|
115
|
+
"universal:closed-never-triaged",
|
|
116
|
+
"universal:dormant-thin-body",
|
|
117
|
+
"universal:vbrief-referenced",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class ClassificationResult:
|
|
123
|
+
"""Outcome of :func:`classify_issue` when a rule matches.
|
|
124
|
+
|
|
125
|
+
``rule_source`` is ``"framework"`` for the four hardcoded universal
|
|
126
|
+
rules and ``"consumer"`` for rules pulled from
|
|
127
|
+
``plan.policy.triageAutoClassify[]``. ``rule_index`` is the 0-based
|
|
128
|
+
position within the resolved rule list (universal rules occupy
|
|
129
|
+
indices 0..3; consumer rules start at index 4).
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
action: str
|
|
133
|
+
reason: str
|
|
134
|
+
rule_index: int
|
|
135
|
+
rule_source: str
|
|
136
|
+
rule_kind: str
|
|
137
|
+
resume_on: str | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Framework universal rules (Decision 1) -- HARDCODED, consumer-agnostic.
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
#: The four framework universal rules. Encoded as opaque ``rule`` objects
|
|
145
|
+
#: so they share the same dispatch surface as consumer rules; the
|
|
146
|
+
#: discriminator strings live in :data:`_UNIVERSAL_RULE_KINDS` and are
|
|
147
|
+
#: NOT writable from consumer config (the validator rejects any consumer
|
|
148
|
+
#: rule whose discriminator starts with ``universal:``).
|
|
149
|
+
UNIVERSAL_RULES: tuple[dict[str, Any], ...] = (
|
|
150
|
+
{
|
|
151
|
+
"rule": "universal:hold-marker",
|
|
152
|
+
"action": "defer",
|
|
153
|
+
"reason": "hold marker in body",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"rule": "universal:closed-never-triaged",
|
|
157
|
+
"action": "archive",
|
|
158
|
+
"reason": "closed upstream and never triaged",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"rule": "universal:dormant-thin-body",
|
|
162
|
+
"action": "defer",
|
|
163
|
+
"reason": "dormant; needs AC refresh",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"rule": "universal:vbrief-referenced",
|
|
167
|
+
"action": "accept",
|
|
168
|
+
"reason": "already referenced from a scope vBRIEF",
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Time helpers
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _utc_now() -> datetime:
|
|
179
|
+
return datetime.now(UTC)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _parse_iso(stamp: str) -> datetime:
|
|
183
|
+
text = stamp.strip()
|
|
184
|
+
if text.endswith("Z"):
|
|
185
|
+
text = text[:-1] + "+00:00"
|
|
186
|
+
return datetime.fromisoformat(text)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _ts_to_dt(value: Any) -> datetime | None:
|
|
190
|
+
if not isinstance(value, str) or not value:
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
dt = _parse_iso(value)
|
|
194
|
+
except (ValueError, TypeError):
|
|
195
|
+
return None
|
|
196
|
+
if dt.tzinfo is None:
|
|
197
|
+
dt = dt.replace(tzinfo=UTC)
|
|
198
|
+
return dt
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Schema validation -- consumer rules
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate_classify_rules(rules: Any) -> tuple[list[str], list[str]]:
|
|
207
|
+
"""Validate a ``plan.policy.triageAutoClassify`` payload.
|
|
208
|
+
|
|
209
|
+
Returns ``(errors, warnings)``. ``errors`` is empty on success. The
|
|
210
|
+
contract follows the same shape as ``triage_scope.validate_scope_rules``
|
|
211
|
+
so call-sites can splice the two error lists together.
|
|
212
|
+
|
|
213
|
+
Validation rules (Decision 2):
|
|
214
|
+
|
|
215
|
+
* The top-level value MUST be a list (omission is fine and resolves
|
|
216
|
+
to an empty consumer rule set via :func:`resolve_classify_rules`).
|
|
217
|
+
* Each rule MUST be an object with at minimum ``match``, ``action``,
|
|
218
|
+
and ``reason`` keys.
|
|
219
|
+
* The ``match`` block MUST contain at least one recognised
|
|
220
|
+
predicate (``labels``, ``body-text``, ``state``, ``age-days``);
|
|
221
|
+
an empty ``match`` (matches every issue) is rejected as ambiguous.
|
|
222
|
+
* Per-predicate field shape is checked (label list, body-text list,
|
|
223
|
+
state enum, age-days ``{gt: N}``).
|
|
224
|
+
* ``action`` MUST be one of :data:`VALID_ACTIONS`.
|
|
225
|
+
* ``reason`` MUST be a non-empty string.
|
|
226
|
+
* ``resume-on`` is optional but, when present, MUST be a non-empty
|
|
227
|
+
string (a D3 resume-condition expression).
|
|
228
|
+
* Any ``rule`` discriminator starting with ``universal:`` is REJECTED
|
|
229
|
+
because the framework universal rules are hardcoded and cannot be
|
|
230
|
+
re-bound from consumer config.
|
|
231
|
+
"""
|
|
232
|
+
errors: list[str] = []
|
|
233
|
+
warnings: list[str] = []
|
|
234
|
+
|
|
235
|
+
if rules is None:
|
|
236
|
+
return errors, warnings
|
|
237
|
+
|
|
238
|
+
if not isinstance(rules, list):
|
|
239
|
+
errors.append(
|
|
240
|
+
"plan.policy.triageAutoClassify must be a list of rule objects; "
|
|
241
|
+
f"got {type(rules).__name__}"
|
|
242
|
+
)
|
|
243
|
+
return errors, warnings
|
|
244
|
+
|
|
245
|
+
for i, rule in enumerate(rules):
|
|
246
|
+
prefix = f"plan.policy.triageAutoClassify[{i}]"
|
|
247
|
+
if not isinstance(rule, dict):
|
|
248
|
+
errors.append(f"{prefix} must be an object, got {type(rule).__name__}")
|
|
249
|
+
continue
|
|
250
|
+
_validate_consumer_rule(rule, prefix, errors, warnings)
|
|
251
|
+
|
|
252
|
+
return errors, warnings
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _validate_consumer_rule(
|
|
256
|
+
rule: dict[str, Any], prefix: str, errors: list[str], warnings: list[str]
|
|
257
|
+
) -> None:
|
|
258
|
+
# Reject re-binding the universal discriminators.
|
|
259
|
+
kind = rule.get("rule")
|
|
260
|
+
if isinstance(kind, str) and kind.startswith("universal:"):
|
|
261
|
+
errors.append(
|
|
262
|
+
f"{prefix}.rule {kind!r} is reserved for framework universal "
|
|
263
|
+
"rules (#1129 Decision 1); consumer rules MUST omit the "
|
|
264
|
+
"'rule' field or use a non-'universal:' discriminator"
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# action
|
|
269
|
+
action = rule.get("action")
|
|
270
|
+
if not isinstance(action, str) or action not in VALID_ACTIONS:
|
|
271
|
+
errors.append(
|
|
272
|
+
f"{prefix}.action must be one of {sorted(VALID_ACTIONS)}; "
|
|
273
|
+
f"got {action!r}"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# reason
|
|
277
|
+
reason = rule.get("reason")
|
|
278
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
279
|
+
errors.append(f"{prefix}.reason must be a non-empty string")
|
|
280
|
+
|
|
281
|
+
# resume-on (optional)
|
|
282
|
+
if "resume-on" in rule:
|
|
283
|
+
ro = rule["resume-on"]
|
|
284
|
+
if not isinstance(ro, str) or not ro.strip():
|
|
285
|
+
errors.append(f"{prefix}.resume-on must be a non-empty string when set")
|
|
286
|
+
|
|
287
|
+
# match block
|
|
288
|
+
match = rule.get("match")
|
|
289
|
+
if not isinstance(match, dict):
|
|
290
|
+
errors.append(f"{prefix}.match must be an object")
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
recognised_predicates = {"labels", "body-text", "state", "age-days"}
|
|
294
|
+
extra = sorted(set(match) - recognised_predicates)
|
|
295
|
+
if extra:
|
|
296
|
+
warnings.append(
|
|
297
|
+
f"{prefix}.match: ignoring unrecognised predicate(s) {extra}; "
|
|
298
|
+
f"expected one or more of {sorted(recognised_predicates)}"
|
|
299
|
+
)
|
|
300
|
+
used_predicates = sorted(set(match) & recognised_predicates)
|
|
301
|
+
if not used_predicates:
|
|
302
|
+
errors.append(
|
|
303
|
+
f"{prefix}.match requires at least one of "
|
|
304
|
+
f"{sorted(recognised_predicates)}"
|
|
305
|
+
)
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
if "labels" in match:
|
|
309
|
+
_validate_labels_predicate(match["labels"], f"{prefix}.match.labels", errors)
|
|
310
|
+
if "body-text" in match:
|
|
311
|
+
_validate_body_text_predicate(
|
|
312
|
+
match["body-text"], f"{prefix}.match.body-text", errors
|
|
313
|
+
)
|
|
314
|
+
if "state" in match:
|
|
315
|
+
state = match["state"]
|
|
316
|
+
if state not in VALID_STATES:
|
|
317
|
+
errors.append(
|
|
318
|
+
f"{prefix}.match.state must be one of {sorted(VALID_STATES)}; "
|
|
319
|
+
f"got {state!r}"
|
|
320
|
+
)
|
|
321
|
+
if "age-days" in match:
|
|
322
|
+
_validate_age_days_predicate(
|
|
323
|
+
match["age-days"], f"{prefix}.match.age-days", errors
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _validate_labels_predicate(value: Any, prefix: str, errors: list[str]) -> None:
|
|
328
|
+
if not isinstance(value, dict):
|
|
329
|
+
errors.append(f"{prefix} must be an object")
|
|
330
|
+
return
|
|
331
|
+
any_of = value.get("any-of")
|
|
332
|
+
all_of = value.get("all-of")
|
|
333
|
+
if any_of is None and all_of is None:
|
|
334
|
+
errors.append(f"{prefix} requires 'any-of' or 'all-of'")
|
|
335
|
+
return
|
|
336
|
+
if any_of is not None and all_of is not None:
|
|
337
|
+
errors.append(f"{prefix}: 'any-of' and 'all-of' are mutually exclusive")
|
|
338
|
+
return
|
|
339
|
+
target = any_of if any_of is not None else all_of
|
|
340
|
+
which = "any-of" if any_of is not None else "all-of"
|
|
341
|
+
if not isinstance(target, list) or not target:
|
|
342
|
+
errors.append(f"{prefix}.{which} must be a non-empty list of strings")
|
|
343
|
+
return
|
|
344
|
+
for j, label in enumerate(target):
|
|
345
|
+
if not isinstance(label, str) or not label:
|
|
346
|
+
errors.append(f"{prefix}.{which}[{j}] must be a non-empty string")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _validate_body_text_predicate(value: Any, prefix: str, errors: list[str]) -> None:
|
|
350
|
+
if not isinstance(value, dict):
|
|
351
|
+
errors.append(f"{prefix} must be an object")
|
|
352
|
+
return
|
|
353
|
+
any_of = value.get("any-of")
|
|
354
|
+
if not isinstance(any_of, list) or not any_of:
|
|
355
|
+
errors.append(f"{prefix}.any-of must be a non-empty list of strings")
|
|
356
|
+
return
|
|
357
|
+
for j, needle in enumerate(any_of):
|
|
358
|
+
if not isinstance(needle, str) or not needle:
|
|
359
|
+
errors.append(f"{prefix}.any-of[{j}] must be a non-empty string")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _validate_age_days_predicate(value: Any, prefix: str, errors: list[str]) -> None:
|
|
363
|
+
if not isinstance(value, dict):
|
|
364
|
+
errors.append(f"{prefix} must be an object")
|
|
365
|
+
return
|
|
366
|
+
if "gt" not in value:
|
|
367
|
+
errors.append(f"{prefix} requires a 'gt' integer threshold")
|
|
368
|
+
return
|
|
369
|
+
gt = value["gt"]
|
|
370
|
+
if (
|
|
371
|
+
not isinstance(gt, int)
|
|
372
|
+
or isinstance(gt, bool)
|
|
373
|
+
or gt < 0
|
|
374
|
+
):
|
|
375
|
+
errors.append(f"{prefix}.gt must be a non-negative integer; got {gt!r}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Schema validation -- hold markers
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def validate_hold_markers(markers: Any) -> tuple[list[str], list[str]]:
|
|
384
|
+
"""Validate a ``plan.policy.triageHoldMarkers`` payload.
|
|
385
|
+
|
|
386
|
+
Returns ``(errors, warnings)``. An unset / missing list resolves to
|
|
387
|
+
:data:`DEFAULT_HOLD_MARKERS` (Decision 3). An EMPTY list is accepted
|
|
388
|
+
and silences the hold-marker universal rule entirely (operators who
|
|
389
|
+
want zero hold-marker matching can set ``triageHoldMarkers: []``).
|
|
390
|
+
"""
|
|
391
|
+
errors: list[str] = []
|
|
392
|
+
warnings: list[str] = []
|
|
393
|
+
if markers is None:
|
|
394
|
+
return errors, warnings
|
|
395
|
+
if not isinstance(markers, list):
|
|
396
|
+
errors.append(
|
|
397
|
+
"plan.policy.triageHoldMarkers must be a list of strings; "
|
|
398
|
+
f"got {type(markers).__name__}"
|
|
399
|
+
)
|
|
400
|
+
return errors, warnings
|
|
401
|
+
for i, marker in enumerate(markers):
|
|
402
|
+
if not isinstance(marker, str) or not marker.strip():
|
|
403
|
+
errors.append(
|
|
404
|
+
f"plan.policy.triageHoldMarkers[{i}] must be a non-empty string"
|
|
405
|
+
)
|
|
406
|
+
return errors, warnings
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
# Resolve rules + hold markers from PROJECT-DEFINITION
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def project_definition_path(project_root: Path | None = None) -> Path:
|
|
415
|
+
root = project_root or Path.cwd()
|
|
416
|
+
return root / PROJECT_DEFINITION_REL_PATH
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _load_project_definition(project_root: Path | None = None) -> dict[str, Any] | None:
|
|
420
|
+
path = project_definition_path(project_root)
|
|
421
|
+
if not path.is_file():
|
|
422
|
+
return None
|
|
423
|
+
try:
|
|
424
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
425
|
+
except (json.JSONDecodeError, OSError):
|
|
426
|
+
return None
|
|
427
|
+
return data if isinstance(data, dict) else None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _consumer_rules_from_project(
|
|
431
|
+
data: dict[str, Any] | None,
|
|
432
|
+
) -> list[dict[str, Any]]:
|
|
433
|
+
if not isinstance(data, dict):
|
|
434
|
+
return []
|
|
435
|
+
plan = data.get("plan")
|
|
436
|
+
if not isinstance(plan, dict):
|
|
437
|
+
return []
|
|
438
|
+
policy = plan.get("policy")
|
|
439
|
+
if not isinstance(policy, dict):
|
|
440
|
+
return []
|
|
441
|
+
raw = policy.get("triageAutoClassify")
|
|
442
|
+
if not isinstance(raw, list):
|
|
443
|
+
return []
|
|
444
|
+
return [dict(r) for r in raw if isinstance(r, dict)]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _hold_markers_from_project(
|
|
448
|
+
data: dict[str, Any] | None,
|
|
449
|
+
) -> list[str] | None:
|
|
450
|
+
"""Return the raw hold-marker list from PROJECT-DEFINITION, or None
|
|
451
|
+
when unset / non-list. ``None`` means "use defaults"; an EMPTY list
|
|
452
|
+
means "silence the hold-marker rule" (Decision 3 explicit opt-out).
|
|
453
|
+
"""
|
|
454
|
+
if not isinstance(data, dict):
|
|
455
|
+
return None
|
|
456
|
+
plan = data.get("plan")
|
|
457
|
+
if not isinstance(plan, dict):
|
|
458
|
+
return None
|
|
459
|
+
policy = plan.get("policy")
|
|
460
|
+
if not isinstance(policy, dict):
|
|
461
|
+
return None
|
|
462
|
+
raw = policy.get("triageHoldMarkers")
|
|
463
|
+
if not isinstance(raw, list):
|
|
464
|
+
return None
|
|
465
|
+
return [m for m in raw if isinstance(m, str) and m.strip()]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def resolve_classify_rules(
|
|
469
|
+
project_root: Path | None = None,
|
|
470
|
+
*,
|
|
471
|
+
project_definition: dict[str, Any] | None = None,
|
|
472
|
+
) -> list[dict[str, Any]]:
|
|
473
|
+
"""Return ``UNIVERSAL_RULES`` followed by the consumer rules.
|
|
474
|
+
|
|
475
|
+
Order of evaluation (Decision 2): framework universal rules first,
|
|
476
|
+
then consumer rules in declared order. The returned list is a fresh
|
|
477
|
+
shallow copy so callers can mutate it without disturbing the
|
|
478
|
+
framework constants.
|
|
479
|
+
"""
|
|
480
|
+
data = (
|
|
481
|
+
project_definition
|
|
482
|
+
if project_definition is not None
|
|
483
|
+
else _load_project_definition(project_root)
|
|
484
|
+
)
|
|
485
|
+
consumer = _consumer_rules_from_project(data)
|
|
486
|
+
return [dict(r) for r in UNIVERSAL_RULES] + consumer
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def resolve_hold_markers(
|
|
490
|
+
project_root: Path | None = None,
|
|
491
|
+
*,
|
|
492
|
+
project_definition: dict[str, Any] | None = None,
|
|
493
|
+
) -> list[str]:
|
|
494
|
+
"""Return the effective hold-marker list (defaults + consumer override)."""
|
|
495
|
+
data = (
|
|
496
|
+
project_definition
|
|
497
|
+
if project_definition is not None
|
|
498
|
+
else _load_project_definition(project_root)
|
|
499
|
+
)
|
|
500
|
+
raw = _hold_markers_from_project(data)
|
|
501
|
+
if raw is None:
|
|
502
|
+
return list(DEFAULT_HOLD_MARKERS)
|
|
503
|
+
return list(raw)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
# Issue field accessors
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _issue_number(issue: dict[str, Any]) -> int:
|
|
512
|
+
n = issue.get("number")
|
|
513
|
+
return int(n) if isinstance(n, int) and not isinstance(n, bool) else 0
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _issue_state(issue: dict[str, Any]) -> str:
|
|
517
|
+
state = issue.get("state", "open")
|
|
518
|
+
return state if isinstance(state, str) else "open"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _issue_body(issue: dict[str, Any]) -> str:
|
|
522
|
+
body = issue.get("body")
|
|
523
|
+
if isinstance(body, str):
|
|
524
|
+
return body
|
|
525
|
+
return ""
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _issue_label_names(issue: dict[str, Any]) -> set[str]:
|
|
529
|
+
raw = issue.get("labels", [])
|
|
530
|
+
names: set[str] = set()
|
|
531
|
+
if not isinstance(raw, list):
|
|
532
|
+
return names
|
|
533
|
+
for item in raw:
|
|
534
|
+
if isinstance(item, dict):
|
|
535
|
+
name = item.get("name")
|
|
536
|
+
if isinstance(name, str):
|
|
537
|
+
names.add(name)
|
|
538
|
+
elif isinstance(item, str):
|
|
539
|
+
names.add(item)
|
|
540
|
+
return names
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _issue_updated_at(issue: dict[str, Any]) -> datetime | None:
|
|
544
|
+
return _ts_to_dt(issue.get("updated_at"))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _issue_created_at(issue: dict[str, Any]) -> datetime | None:
|
|
548
|
+
return _ts_to_dt(issue.get("created_at"))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ---------------------------------------------------------------------------
|
|
552
|
+
# Universal rule predicates
|
|
553
|
+
# ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _matches_hold_marker(
|
|
557
|
+
issue: dict[str, Any], hold_markers: Iterable[str]
|
|
558
|
+
) -> bool:
|
|
559
|
+
"""True when the issue body contains any hold-marker phrase.
|
|
560
|
+
|
|
561
|
+
Matching is case-INsensitive so an issue body that writes
|
|
562
|
+
``do not implement`` in any casing trips the rule. The default
|
|
563
|
+
markers include both an all-caps idiom (``BLOCKED``) and a sentence-
|
|
564
|
+
cased phrase (``Holding / capture only``) so consumers writing
|
|
565
|
+
in their natural style are still caught.
|
|
566
|
+
"""
|
|
567
|
+
body = _issue_body(issue)
|
|
568
|
+
if not body:
|
|
569
|
+
return False
|
|
570
|
+
haystack = body.casefold()
|
|
571
|
+
for marker in hold_markers:
|
|
572
|
+
if not marker:
|
|
573
|
+
continue
|
|
574
|
+
if marker.casefold() in haystack:
|
|
575
|
+
return True
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _matches_closed_never_triaged(
|
|
580
|
+
issue: dict[str, Any], *, has_triage_decision: bool
|
|
581
|
+
) -> bool:
|
|
582
|
+
return _issue_state(issue) == "closed" and not has_triage_decision
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _matches_dormant_thin_body(
|
|
586
|
+
issue: dict[str, Any], *, now: datetime, age_days: int = DORMANT_AGE_DAYS
|
|
587
|
+
) -> bool:
|
|
588
|
+
if _issue_state(issue) != "open":
|
|
589
|
+
return False
|
|
590
|
+
updated = _issue_updated_at(issue) or _issue_created_at(issue)
|
|
591
|
+
if updated is None:
|
|
592
|
+
return False
|
|
593
|
+
if (now - updated) <= timedelta(days=age_days):
|
|
594
|
+
return False
|
|
595
|
+
body = _issue_body(issue).strip()
|
|
596
|
+
return len(body) < THIN_BODY_THRESHOLD_CHARS
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _matches_vbrief_referenced(
|
|
600
|
+
issue: dict[str, Any], *, vbrief_referenced: set[int] | None
|
|
601
|
+
) -> bool:
|
|
602
|
+
if not vbrief_referenced:
|
|
603
|
+
return False
|
|
604
|
+
return _issue_number(issue) in vbrief_referenced
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
# Consumer rule predicate
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _consumer_rule_matches(
|
|
613
|
+
rule: dict[str, Any], issue: dict[str, Any], *, now: datetime
|
|
614
|
+
) -> bool:
|
|
615
|
+
match = rule.get("match")
|
|
616
|
+
if not isinstance(match, dict):
|
|
617
|
+
return False
|
|
618
|
+
|
|
619
|
+
if "state" in match:
|
|
620
|
+
wanted = match["state"]
|
|
621
|
+
if _issue_state(issue) != wanted:
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
if "labels" in match:
|
|
625
|
+
labels_pred = match["labels"]
|
|
626
|
+
names = _issue_label_names(issue)
|
|
627
|
+
any_of = labels_pred.get("any-of") if isinstance(labels_pred, dict) else None
|
|
628
|
+
all_of = labels_pred.get("all-of") if isinstance(labels_pred, dict) else None
|
|
629
|
+
if any_of is not None:
|
|
630
|
+
if not any(label in names for label in any_of):
|
|
631
|
+
return False
|
|
632
|
+
elif all_of is not None:
|
|
633
|
+
if not all(label in names for label in all_of):
|
|
634
|
+
return False
|
|
635
|
+
else:
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
if "body-text" in match:
|
|
639
|
+
body_pred = match["body-text"]
|
|
640
|
+
any_of = body_pred.get("any-of") if isinstance(body_pred, dict) else None
|
|
641
|
+
if not isinstance(any_of, list) or not any_of:
|
|
642
|
+
return False
|
|
643
|
+
body = _issue_body(issue).casefold()
|
|
644
|
+
if not any(
|
|
645
|
+
isinstance(n, str) and n and n.casefold() in body for n in any_of
|
|
646
|
+
):
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
if "age-days" in match:
|
|
650
|
+
pred = match["age-days"]
|
|
651
|
+
gt = pred.get("gt") if isinstance(pred, dict) else None
|
|
652
|
+
if not isinstance(gt, int) or isinstance(gt, bool):
|
|
653
|
+
return False
|
|
654
|
+
updated = _issue_updated_at(issue) or _issue_created_at(issue)
|
|
655
|
+
if updated is None:
|
|
656
|
+
return False
|
|
657
|
+
if (now - updated) <= timedelta(days=gt):
|
|
658
|
+
return False
|
|
659
|
+
|
|
660
|
+
return True
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
# classify_issue
|
|
665
|
+
# ---------------------------------------------------------------------------
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def classify_issue(
|
|
669
|
+
issue: dict[str, Any],
|
|
670
|
+
*,
|
|
671
|
+
rules: list[dict[str, Any]] | None = None,
|
|
672
|
+
hold_markers: list[str] | None = None,
|
|
673
|
+
vbrief_referenced: set[int] | None = None,
|
|
674
|
+
has_triage_decision: bool = False,
|
|
675
|
+
now: datetime | None = None,
|
|
676
|
+
) -> ClassificationResult | None:
|
|
677
|
+
"""Classify a single issue against the effective rule set.
|
|
678
|
+
|
|
679
|
+
Order of evaluation (Decision 2): framework universal rules first,
|
|
680
|
+
then consumer rules in declared order; the FIRST rule that matches
|
|
681
|
+
wins and the function returns its action / reason.
|
|
682
|
+
|
|
683
|
+
Arguments:
|
|
684
|
+
|
|
685
|
+
* ``issue`` -- a GitHub-issue-shaped dict (at minimum: ``number``,
|
|
686
|
+
``state``, ``body``, ``labels``, ``updated_at``).
|
|
687
|
+
* ``rules`` -- the rule list returned by :func:`resolve_classify_rules`.
|
|
688
|
+
Defaults to ``UNIVERSAL_RULES`` with no consumer additions.
|
|
689
|
+
* ``hold_markers`` -- the hold-marker phrases consumed by the first
|
|
690
|
+
universal rule. Defaults to :data:`DEFAULT_HOLD_MARKERS`. Pass an
|
|
691
|
+
empty list to silence the hold-marker rule entirely.
|
|
692
|
+
* ``vbrief_referenced`` -- issue numbers referenced by any pending/active
|
|
693
|
+
scope vBRIEF. Drives the fourth universal rule.
|
|
694
|
+
* ``has_triage_decision`` -- True iff the candidates audit log has
|
|
695
|
+
ANY decision for this ``(repo, issue)``. Drives the second
|
|
696
|
+
universal rule (closed AND never triaged -> archive).
|
|
697
|
+
* ``now`` -- evaluation clock; defaults to UTC now. Tests override.
|
|
698
|
+
|
|
699
|
+
Returns ``None`` when no rule matches (the issue is left for the
|
|
700
|
+
operator / queue ranking to handle in the next phase).
|
|
701
|
+
"""
|
|
702
|
+
if rules is None:
|
|
703
|
+
rules = [dict(r) for r in UNIVERSAL_RULES]
|
|
704
|
+
effective_markers = (
|
|
705
|
+
list(DEFAULT_HOLD_MARKERS) if hold_markers is None else list(hold_markers)
|
|
706
|
+
)
|
|
707
|
+
now_dt = now or _utc_now()
|
|
708
|
+
|
|
709
|
+
for index, rule in enumerate(rules):
|
|
710
|
+
kind = rule.get("rule") if isinstance(rule, dict) else None
|
|
711
|
+
matched = False
|
|
712
|
+
if kind == "universal:hold-marker":
|
|
713
|
+
matched = _matches_hold_marker(issue, effective_markers)
|
|
714
|
+
elif kind == "universal:closed-never-triaged":
|
|
715
|
+
matched = _matches_closed_never_triaged(
|
|
716
|
+
issue, has_triage_decision=has_triage_decision
|
|
717
|
+
)
|
|
718
|
+
elif kind == "universal:dormant-thin-body":
|
|
719
|
+
matched = _matches_dormant_thin_body(issue, now=now_dt)
|
|
720
|
+
elif kind == "universal:vbrief-referenced":
|
|
721
|
+
matched = _matches_vbrief_referenced(
|
|
722
|
+
issue, vbrief_referenced=vbrief_referenced
|
|
723
|
+
)
|
|
724
|
+
elif isinstance(rule, dict):
|
|
725
|
+
matched = _consumer_rule_matches(rule, issue, now=now_dt)
|
|
726
|
+
|
|
727
|
+
if not matched:
|
|
728
|
+
continue
|
|
729
|
+
|
|
730
|
+
source = "framework" if isinstance(kind, str) and kind.startswith(
|
|
731
|
+
"universal:"
|
|
732
|
+
) else "consumer"
|
|
733
|
+
return ClassificationResult(
|
|
734
|
+
action=str(rule.get("action", "")),
|
|
735
|
+
reason=str(rule.get("reason", "")),
|
|
736
|
+
rule_index=index,
|
|
737
|
+
rule_source=source,
|
|
738
|
+
rule_kind=str(kind) if isinstance(kind, str) else f"consumer[{index}]",
|
|
739
|
+
resume_on=(
|
|
740
|
+
str(rule["resume-on"])
|
|
741
|
+
if isinstance(rule.get("resume-on"), str) and rule["resume-on"]
|
|
742
|
+
else None
|
|
743
|
+
),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
# vBRIEF reference helper (mirror of triage_scope.extract_referenced_issues)
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def extract_referenced_issues(
|
|
755
|
+
project_root: Path | None = None,
|
|
756
|
+
*,
|
|
757
|
+
lifecycle_folders: tuple[str, ...] = ("pending", "active"),
|
|
758
|
+
) -> set[int]:
|
|
759
|
+
"""Return the union of issue numbers referenced by ``pending/`` or
|
|
760
|
+
``active/`` scope vBRIEFs.
|
|
761
|
+
|
|
762
|
+
Used to drive the fourth universal rule (already referenced -> accept).
|
|
763
|
+
Limited to pending/active by default because completed/cancelled
|
|
764
|
+
references shouldn't reactivate the upstream issue. The
|
|
765
|
+
``lifecycle_folders`` knob lets callers override for the rare cases
|
|
766
|
+
(e.g. cohort planning) where they want broader coverage.
|
|
767
|
+
"""
|
|
768
|
+
root = (project_root or Path.cwd()) / "vbrief"
|
|
769
|
+
referenced: set[int] = set()
|
|
770
|
+
if not root.is_dir():
|
|
771
|
+
return referenced
|
|
772
|
+
for folder in lifecycle_folders:
|
|
773
|
+
folder_path = root / folder
|
|
774
|
+
if not folder_path.is_dir():
|
|
775
|
+
continue
|
|
776
|
+
for vbrief_path in folder_path.glob("*.vbrief.json"):
|
|
777
|
+
try:
|
|
778
|
+
data = json.loads(vbrief_path.read_text(encoding="utf-8"))
|
|
779
|
+
except (json.JSONDecodeError, OSError):
|
|
780
|
+
continue
|
|
781
|
+
plan = data.get("plan") if isinstance(data, dict) else None
|
|
782
|
+
if not isinstance(plan, dict):
|
|
783
|
+
continue
|
|
784
|
+
refs = plan.get("references") or []
|
|
785
|
+
if not isinstance(refs, list):
|
|
786
|
+
continue
|
|
787
|
+
for ref in refs:
|
|
788
|
+
if not isinstance(ref, dict):
|
|
789
|
+
continue
|
|
790
|
+
if ref.get("type") != "x-vbrief/github-issue":
|
|
791
|
+
continue
|
|
792
|
+
uri = ref.get("uri", "")
|
|
793
|
+
if not isinstance(uri, str):
|
|
794
|
+
continue
|
|
795
|
+
tail = uri.rstrip("/").rsplit("/", 1)[-1]
|
|
796
|
+
if tail.isdigit():
|
|
797
|
+
referenced.add(int(tail))
|
|
798
|
+
return referenced
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# ---------------------------------------------------------------------------
|
|
802
|
+
# --list renderer
|
|
803
|
+
# ---------------------------------------------------------------------------
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def render_list(
|
|
807
|
+
rules: Iterable[dict[str, Any]],
|
|
808
|
+
*,
|
|
809
|
+
hold_markers: Iterable[str] | None = None,
|
|
810
|
+
) -> str:
|
|
811
|
+
"""Return a human-readable recap of the effective rule + marker set.
|
|
812
|
+
|
|
813
|
+
Format::
|
|
814
|
+
|
|
815
|
+
triage:classify effective rules (N) (framework + consumer):
|
|
816
|
+
1. universal:hold-marker -> defer (hold marker in body)
|
|
817
|
+
2. universal:closed-never-triaged -> archive (closed upstream...)
|
|
818
|
+
...
|
|
819
|
+
5. consumer rule [action=defer, labels.any-of=['foo']]
|
|
820
|
+
hold markers (M): ['do not implement', 'BLOCKED', ...]
|
|
821
|
+
"""
|
|
822
|
+
rule_list = list(rules)
|
|
823
|
+
marker_list = (
|
|
824
|
+
list(DEFAULT_HOLD_MARKERS) if hold_markers is None else list(hold_markers)
|
|
825
|
+
)
|
|
826
|
+
lines: list[str] = [
|
|
827
|
+
f"triage:classify effective rules ({len(rule_list)}) "
|
|
828
|
+
"(framework universal first, then consumer):"
|
|
829
|
+
]
|
|
830
|
+
for i, rule in enumerate(rule_list, start=1):
|
|
831
|
+
lines.extend(_render_rule(i, rule))
|
|
832
|
+
lines.append(f"hold markers ({len(marker_list)}): {marker_list}")
|
|
833
|
+
return "\n".join(lines)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _render_rule(idx: int, rule: dict[str, Any]) -> list[str]:
|
|
837
|
+
kind = rule.get("rule")
|
|
838
|
+
action = rule.get("action", "?")
|
|
839
|
+
reason = rule.get("reason", "")
|
|
840
|
+
if isinstance(kind, str) and kind.startswith("universal:"):
|
|
841
|
+
return [f" {idx}. {kind:32s} -> {action:8s} ({reason})"]
|
|
842
|
+
match = rule.get("match", {})
|
|
843
|
+
parts: list[str] = []
|
|
844
|
+
if isinstance(match, dict):
|
|
845
|
+
if "labels" in match:
|
|
846
|
+
labels = match["labels"]
|
|
847
|
+
if isinstance(labels, dict):
|
|
848
|
+
if "any-of" in labels:
|
|
849
|
+
parts.append(f"labels.any-of={sorted(labels['any-of'])}")
|
|
850
|
+
elif "all-of" in labels:
|
|
851
|
+
parts.append(f"labels.all-of={sorted(labels['all-of'])}")
|
|
852
|
+
if "body-text" in match:
|
|
853
|
+
body = match["body-text"]
|
|
854
|
+
if isinstance(body, dict) and "any-of" in body:
|
|
855
|
+
parts.append(f"body-text.any-of={sorted(body['any-of'])}")
|
|
856
|
+
if "state" in match:
|
|
857
|
+
parts.append(f"state={match['state']!r}")
|
|
858
|
+
if "age-days" in match:
|
|
859
|
+
age = match["age-days"]
|
|
860
|
+
if isinstance(age, dict) and "gt" in age:
|
|
861
|
+
parts.append(f"age-days.gt={age['gt']}")
|
|
862
|
+
head = (
|
|
863
|
+
f" {idx}. consumer rule "
|
|
864
|
+
f"-> {action:8s} ({reason})"
|
|
865
|
+
)
|
|
866
|
+
if parts:
|
|
867
|
+
head = f"{head} :: {', '.join(parts)}"
|
|
868
|
+
if isinstance(rule.get("resume-on"), str) and rule["resume-on"]:
|
|
869
|
+
head = f"{head} [resume-on: {rule['resume-on']}]"
|
|
870
|
+
return [head]
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# ---------------------------------------------------------------------------
|
|
874
|
+
# vbrief_validate hooks
|
|
875
|
+
# ---------------------------------------------------------------------------
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def validate_triage_auto_classify_on_plan(plan: Any, filepath: Any) -> list[str]:
|
|
879
|
+
"""vbrief_validate hook for ``plan.policy.triageAutoClassify`` (#1129).
|
|
880
|
+
|
|
881
|
+
Returns formatted error strings prefixed with ``<filepath>:`` so
|
|
882
|
+
``vbrief_validate.validate_project_definition`` can splice them into
|
|
883
|
+
its existing error list. Unset / missing -> no errors (default
|
|
884
|
+
behaviour per Decision 2).
|
|
885
|
+
"""
|
|
886
|
+
out: list[str] = []
|
|
887
|
+
policy = plan.get("policy") if isinstance(plan, dict) else None
|
|
888
|
+
raw = policy.get("triageAutoClassify") if isinstance(policy, dict) else None
|
|
889
|
+
if raw is None:
|
|
890
|
+
return out
|
|
891
|
+
errors, _warnings = validate_classify_rules(raw)
|
|
892
|
+
for err in errors:
|
|
893
|
+
out.append(f"{filepath}: {err} (#1129)")
|
|
894
|
+
return out
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def validate_triage_hold_markers_on_plan(plan: Any, filepath: Any) -> list[str]:
|
|
898
|
+
"""vbrief_validate hook for ``plan.policy.triageHoldMarkers`` (#1129)."""
|
|
899
|
+
out: list[str] = []
|
|
900
|
+
policy = plan.get("policy") if isinstance(plan, dict) else None
|
|
901
|
+
raw = policy.get("triageHoldMarkers") if isinstance(policy, dict) else None
|
|
902
|
+
if raw is None:
|
|
903
|
+
return out
|
|
904
|
+
errors, _warnings = validate_hold_markers(raw)
|
|
905
|
+
for err in errors:
|
|
906
|
+
out.append(f"{filepath}: {err} (#1129)")
|
|
907
|
+
return out
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
# ---------------------------------------------------------------------------
|
|
911
|
+
# CLI entry point (delegates to scripts/_triage_classify_cli.py)
|
|
912
|
+
# ---------------------------------------------------------------------------
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def main(argv: list[str] | None = None) -> int:
|
|
916
|
+
"""CLI entry point. Delegates to :mod:`_triage_classify_cli`."""
|
|
917
|
+
import sys as _sys
|
|
918
|
+
|
|
919
|
+
# N10 (#1150): structured --help via scripts/triage_help.REGISTRY.
|
|
920
|
+
from triage_help import intercept_help
|
|
921
|
+
|
|
922
|
+
rc = intercept_help("triage_classify", argv)
|
|
923
|
+
if rc is not None:
|
|
924
|
+
return rc
|
|
925
|
+
|
|
926
|
+
from _triage_classify_cli import run_cli # local import: 1000-line cap
|
|
927
|
+
|
|
928
|
+
return run_cli(argv, _sys.modules[__name__])
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
if __name__ == "__main__":
|
|
932
|
+
sys.exit(main())
|