@deftai/directive-content 0.55.2 → 0.56.1
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 +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- 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/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 +1 -1
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Milestone-rule helpers for ``scripts/triage_scope.py`` (D14b / #1181).
|
|
2
|
+
|
|
3
|
+
D14 (#1133) shipped the milestone rule with the v1 exact-match shape
|
|
4
|
+
``{rule: "milestone", name: "<exact-name>"}``. D14b (#1181) extends the
|
|
5
|
+
grammar with two additional, mutually-exclusive variants:
|
|
6
|
+
|
|
7
|
+
* ``{rule: "milestone", any-of: ["<n1>", "<n2>", ...]}`` -- issue matches
|
|
8
|
+
if its milestone title is in the list.
|
|
9
|
+
* ``{rule: "milestone", is-open: true}`` -- issue matches if its
|
|
10
|
+
milestone is currently open upstream.
|
|
11
|
+
|
|
12
|
+
Validation: exactly one of ``name`` / ``any-of`` / ``is-open`` MUST be
|
|
13
|
+
present per rule. ``is-open`` MUST be the literal ``true`` (``false`` is
|
|
14
|
+
meaningless; consumers wanting specific milestones use ``name`` or
|
|
15
|
+
``any-of``).
|
|
16
|
+
|
|
17
|
+
Evaluation: the ``is-open`` variant queries
|
|
18
|
+
``gh api repos/<o>/<r>/milestones?state=open`` exactly ONCE per
|
|
19
|
+
``evaluate_rules`` call (memoized snapshot, never per-issue).
|
|
20
|
+
|
|
21
|
+
Kept out of ``scripts/triage_scope.py`` to stay under the 1000-line MUST
|
|
22
|
+
cap from ``coding/coding.md``. Re-exported for back-compat where useful.
|
|
23
|
+
|
|
24
|
+
Refs #1181, #1119, #1131 (D12), #1133 (D14).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from collections.abc import Callable, Iterable
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
from urllib.parse import urlparse
|
|
37
|
+
|
|
38
|
+
#: Strict allow-list of GitHub hostnames accepted by
|
|
39
|
+
#: :func:`infer_repo_from_issues`. Substring / `in` matching is a CodeQL
|
|
40
|
+
#: ``py/incomplete-url-substring-sanitization`` finding: an attacker-controlled
|
|
41
|
+
#: ``html_url`` value of the form ``https://evil-github.com.attacker.com/...``
|
|
42
|
+
#: would satisfy a naive ``"github.com" in url`` check. Enforce strict host
|
|
43
|
+
#: equality via :func:`urllib.parse.urlparse` instead.
|
|
44
|
+
_GITHUB_HOSTNAMES: frozenset[str] = frozenset({"github.com", "api.github.com"})
|
|
45
|
+
|
|
46
|
+
#: The set of recognised keys on a ``milestone`` rule body. ``rule`` is
|
|
47
|
+
#: the discriminator itself; the rest are the three variant keys.
|
|
48
|
+
_MILESTONE_VARIANT_KEYS: tuple[str, ...] = ("name", "any-of", "is-open")
|
|
49
|
+
_MILESTONE_ALL_KEYS: frozenset[str] = frozenset(("rule", *_MILESTONE_VARIANT_KEYS))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_milestone_rule(
|
|
53
|
+
rule: dict[str, Any],
|
|
54
|
+
prefix: str,
|
|
55
|
+
errors: list[str],
|
|
56
|
+
warnings: list[str],
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Validate a single ``milestone`` rule body in place.
|
|
59
|
+
|
|
60
|
+
Mutates ``errors`` / ``warnings`` to mirror the existing
|
|
61
|
+
``_validate_rule_body`` contract in ``triage_scope.py`` so the parent
|
|
62
|
+
module can delegate without round-tripping return values.
|
|
63
|
+
|
|
64
|
+
Acceptance:
|
|
65
|
+
|
|
66
|
+
* Exactly one of ``name`` / ``any-of`` / ``is-open`` MUST be present.
|
|
67
|
+
* ``name`` (when set) MUST be a non-empty string.
|
|
68
|
+
* ``any-of`` (when set) MUST be a non-empty list of non-empty strings.
|
|
69
|
+
* ``is-open`` (when set) MUST be the literal boolean ``True``. The
|
|
70
|
+
literal ``False`` is rejected with a hint pointing at the other
|
|
71
|
+
two variants (``False`` is the do-nothing case the operator
|
|
72
|
+
almost certainly does NOT want, so a silent accept would be a
|
|
73
|
+
footgun).
|
|
74
|
+
|
|
75
|
+
Unknown sibling keys produce a warning (not an error) so a
|
|
76
|
+
forward-compat consumer who hand-edits a future shape gets a clear
|
|
77
|
+
hint rather than silent drift.
|
|
78
|
+
"""
|
|
79
|
+
has_name = "name" in rule
|
|
80
|
+
has_any = "any-of" in rule
|
|
81
|
+
has_open = "is-open" in rule
|
|
82
|
+
set_count = sum([has_name, has_any, has_open])
|
|
83
|
+
|
|
84
|
+
if set_count == 0:
|
|
85
|
+
errors.append(
|
|
86
|
+
f"{prefix}.milestone requires one of 'name' / 'any-of' / "
|
|
87
|
+
"'is-open: true' (D14b / #1181); see "
|
|
88
|
+
"scripts/triage_scope.py for the variant matrix"
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if set_count > 1:
|
|
93
|
+
present = [k for k in _MILESTONE_VARIANT_KEYS if k in rule]
|
|
94
|
+
errors.append(
|
|
95
|
+
f"{prefix}.milestone: {present} are mutually exclusive; "
|
|
96
|
+
"choose exactly one of name / any-of / is-open (#1181)"
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
if has_name:
|
|
101
|
+
name = rule.get("name")
|
|
102
|
+
if not isinstance(name, str) or not name.strip():
|
|
103
|
+
errors.append(
|
|
104
|
+
f"{prefix}.milestone.name must be a non-empty string"
|
|
105
|
+
)
|
|
106
|
+
return
|
|
107
|
+
elif has_any:
|
|
108
|
+
any_of = rule.get("any-of")
|
|
109
|
+
if not isinstance(any_of, list) or not any_of:
|
|
110
|
+
errors.append(
|
|
111
|
+
f"{prefix}.milestone.any-of must be a non-empty list of strings (#1181)"
|
|
112
|
+
)
|
|
113
|
+
return
|
|
114
|
+
for j, item in enumerate(any_of):
|
|
115
|
+
if not isinstance(item, str) or not item:
|
|
116
|
+
errors.append(
|
|
117
|
+
f"{prefix}.milestone.any-of[{j}] must be a non-empty string"
|
|
118
|
+
)
|
|
119
|
+
else: # has_open
|
|
120
|
+
is_open = rule.get("is-open")
|
|
121
|
+
if not isinstance(is_open, bool):
|
|
122
|
+
errors.append(
|
|
123
|
+
f"{prefix}.milestone.is-open must be a boolean literal `true`; "
|
|
124
|
+
f"got {type(is_open).__name__} (#1181)"
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
if is_open is False:
|
|
128
|
+
errors.append(
|
|
129
|
+
f"{prefix}.milestone.is-open: false is meaningless -- "
|
|
130
|
+
"to subscribe to specific milestones use `name` or "
|
|
131
|
+
"`any-of` (#1181)"
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
extra = sorted(k for k in rule if k not in _MILESTONE_ALL_KEYS)
|
|
136
|
+
if extra:
|
|
137
|
+
warnings.append(
|
|
138
|
+
f"{prefix}.milestone: ignoring unrecognised keys {extra}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def collect_milestone_subscribed_names(
|
|
143
|
+
rules: Iterable[dict[str, Any]],
|
|
144
|
+
) -> set[str]:
|
|
145
|
+
"""Return the set of milestone names covered by ``name`` / ``any-of``.
|
|
146
|
+
|
|
147
|
+
Used by the drift detector to suppress entries the operator already
|
|
148
|
+
knows about. The ``is-open: true`` variant is NOT consulted here --
|
|
149
|
+
that variant resolves against the live upstream snapshot, which the
|
|
150
|
+
caller adds separately when any rule requests ``is-open: true``.
|
|
151
|
+
"""
|
|
152
|
+
out: set[str] = set()
|
|
153
|
+
for rule in rules:
|
|
154
|
+
if not isinstance(rule, dict) or rule.get("rule") != "milestone":
|
|
155
|
+
continue
|
|
156
|
+
name = rule.get("name")
|
|
157
|
+
if isinstance(name, str) and name:
|
|
158
|
+
out.add(name)
|
|
159
|
+
any_of = rule.get("any-of")
|
|
160
|
+
if isinstance(any_of, list):
|
|
161
|
+
for item in any_of:
|
|
162
|
+
if isinstance(item, str) and item:
|
|
163
|
+
out.add(item)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def rules_request_is_open(rules: Iterable[dict[str, Any]]) -> bool:
|
|
168
|
+
"""True iff any milestone rule asks for ``is-open: true``."""
|
|
169
|
+
return any(
|
|
170
|
+
isinstance(r, dict)
|
|
171
|
+
and r.get("rule") == "milestone"
|
|
172
|
+
and r.get("is-open") is True
|
|
173
|
+
for r in rules
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# Open-milestones snapshot fetcher
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
#: Env-var override for the default fetcher's subprocess timeout. Bounded
|
|
183
|
+
#: so a hung ``gh`` invocation can't wedge a long evaluator call.
|
|
184
|
+
ENV_FETCH_TIMEOUT_S = "DEFT_MILESTONE_FETCH_TIMEOUT_S"
|
|
185
|
+
DEFAULT_FETCH_TIMEOUT_S: int = 30
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def infer_repo_from_issues(issues: Iterable[dict[str, Any]]) -> str | None:
|
|
189
|
+
"""Best-effort ``owner/name`` inference from the issue list.
|
|
190
|
+
|
|
191
|
+
Reads ``repository_url`` (canonical REST field, shape
|
|
192
|
+
``https://api.github.com/repos/<owner>/<name>``) and falls back to
|
|
193
|
+
``html_url``. Returns the first plausible match so a heterogeneous
|
|
194
|
+
issue list (cross-repo cohort) still resolves to a deterministic
|
|
195
|
+
repo for the upstream milestones call.
|
|
196
|
+
|
|
197
|
+
Strictly validates the URL's hostname via :func:`urllib.parse.urlparse`
|
|
198
|
+
against :data:`_GITHUB_HOSTNAMES` before extracting any path segment.
|
|
199
|
+
Substring / ``in`` matching on the URL string would be a CodeQL
|
|
200
|
+
``py/incomplete-url-substring-sanitization`` finding -- an attacker
|
|
201
|
+
controlling an issue payload could craft ``https://evil-github.com.attacker.com/owner/name/...``
|
|
202
|
+
that satisfies a naive ``"github.com" in url`` check.
|
|
203
|
+
"""
|
|
204
|
+
for issue in issues:
|
|
205
|
+
if not isinstance(issue, dict):
|
|
206
|
+
continue
|
|
207
|
+
for key in ("repository_url", "html_url"):
|
|
208
|
+
value = issue.get(key)
|
|
209
|
+
if not isinstance(value, str) or not value:
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
parsed = urlparse(value)
|
|
213
|
+
except (ValueError, TypeError):
|
|
214
|
+
continue
|
|
215
|
+
host = (parsed.hostname or "").lower()
|
|
216
|
+
if host not in _GITHUB_HOSTNAMES:
|
|
217
|
+
continue
|
|
218
|
+
segments = [s for s in parsed.path.split("/") if s]
|
|
219
|
+
# api.github.com canonical repository_url:
|
|
220
|
+
# path = "/repos/<owner>/<name>" -> segments[0]=="repos"
|
|
221
|
+
# github.com html_url:
|
|
222
|
+
# path = "/<owner>/<name>" or "/<owner>/<name>/issues/<n>"
|
|
223
|
+
if segments and segments[0] == "repos" and len(segments) >= 3:
|
|
224
|
+
owner, name = segments[1], segments[2]
|
|
225
|
+
elif len(segments) >= 2:
|
|
226
|
+
owner, name = segments[0], segments[1]
|
|
227
|
+
else:
|
|
228
|
+
continue
|
|
229
|
+
if owner and name:
|
|
230
|
+
return f"{owner}/{name}"
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def default_open_milestones_fetcher(repo: str | None) -> set[str]:
|
|
235
|
+
"""Invoke ``gh api`` to list currently-open milestones for ``repo``.
|
|
236
|
+
|
|
237
|
+
Returns the set of milestone titles. On any failure (missing repo,
|
|
238
|
+
non-zero exit, unparseable JSON, hung subprocess) returns an empty
|
|
239
|
+
set rather than raising -- callers consuming the result via
|
|
240
|
+
:func:`evaluate_rules` already tolerate empty snapshots (no
|
|
241
|
+
matches for that rule).
|
|
242
|
+
|
|
243
|
+
Production callers SHOULD pass an explicit ``open_milestones_fetcher``
|
|
244
|
+
closure that wraps a higher-level cache (``ghx`` / per-process
|
|
245
|
+
memoization). This default is the bottom of the ladder so an
|
|
246
|
+
out-of-the-box call still works.
|
|
247
|
+
"""
|
|
248
|
+
if not isinstance(repo, str) or "/" not in repo:
|
|
249
|
+
return set()
|
|
250
|
+
timeout = DEFAULT_FETCH_TIMEOUT_S
|
|
251
|
+
raw = os.environ.get(ENV_FETCH_TIMEOUT_S, "").strip()
|
|
252
|
+
if raw:
|
|
253
|
+
try:
|
|
254
|
+
timeout = max(1, int(raw))
|
|
255
|
+
except ValueError:
|
|
256
|
+
timeout = DEFAULT_FETCH_TIMEOUT_S
|
|
257
|
+
|
|
258
|
+
binary = _resolve_gh_binary()
|
|
259
|
+
if binary is None:
|
|
260
|
+
return set()
|
|
261
|
+
cmd = [
|
|
262
|
+
binary,
|
|
263
|
+
"api",
|
|
264
|
+
f"repos/{repo}/milestones?state=open&per_page=100",
|
|
265
|
+
"--paginate",
|
|
266
|
+
]
|
|
267
|
+
try:
|
|
268
|
+
result = subprocess.run( # noqa: S603 -- argv list, no shell
|
|
269
|
+
cmd,
|
|
270
|
+
capture_output=True,
|
|
271
|
+
text=True,
|
|
272
|
+
encoding="utf-8",
|
|
273
|
+
timeout=timeout,
|
|
274
|
+
check=False,
|
|
275
|
+
)
|
|
276
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
277
|
+
return set()
|
|
278
|
+
if result.returncode != 0:
|
|
279
|
+
return set()
|
|
280
|
+
return _parse_milestone_titles(result.stdout)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _resolve_gh_binary() -> str | None:
|
|
284
|
+
"""Return the gh binary path via ``scripts.scm.resolve_binary``.
|
|
285
|
+
|
|
286
|
+
Falls back to the literal ``"gh"`` (PATH lookup) if scm is not
|
|
287
|
+
importable, e.g. during a fresh-checkout test that pulls only this
|
|
288
|
+
module in isolation.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
292
|
+
import scm # type: ignore[import-not-found]
|
|
293
|
+
|
|
294
|
+
return scm.resolve_binary()
|
|
295
|
+
except (ImportError, AttributeError):
|
|
296
|
+
return "gh"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_milestone_titles(stdout: str) -> set[str]:
|
|
300
|
+
"""Parse ``gh api ... milestones`` JSON output.
|
|
301
|
+
|
|
302
|
+
``gh api --paginate`` concatenates JSON arrays per page; we accept
|
|
303
|
+
either a single top-level array OR a series of concatenated arrays
|
|
304
|
+
(paginate fallback). Bad / truncated output yields an empty set.
|
|
305
|
+
"""
|
|
306
|
+
text = (stdout or "").strip()
|
|
307
|
+
if not text:
|
|
308
|
+
return set()
|
|
309
|
+
titles: set[str] = set()
|
|
310
|
+
try:
|
|
311
|
+
data = json.loads(text)
|
|
312
|
+
return _extract_titles(data)
|
|
313
|
+
except json.JSONDecodeError:
|
|
314
|
+
# Paginate may concatenate arrays directly; split + retry.
|
|
315
|
+
pass
|
|
316
|
+
decoder = json.JSONDecoder()
|
|
317
|
+
idx = 0
|
|
318
|
+
while idx < len(text):
|
|
319
|
+
while idx < len(text) and text[idx].isspace():
|
|
320
|
+
idx += 1
|
|
321
|
+
if idx >= len(text):
|
|
322
|
+
break
|
|
323
|
+
try:
|
|
324
|
+
data, end = decoder.raw_decode(text, idx)
|
|
325
|
+
except json.JSONDecodeError:
|
|
326
|
+
break
|
|
327
|
+
titles |= _extract_titles(data)
|
|
328
|
+
idx = end
|
|
329
|
+
return titles
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _extract_titles(data: Any) -> set[str]:
|
|
333
|
+
out: set[str] = set()
|
|
334
|
+
if isinstance(data, list):
|
|
335
|
+
for entry in data:
|
|
336
|
+
if not isinstance(entry, dict):
|
|
337
|
+
continue
|
|
338
|
+
title = entry.get("title")
|
|
339
|
+
if isinstance(title, str) and title:
|
|
340
|
+
out.add(title)
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
def make_open_milestones_resolver(
|
|
344
|
+
open_milestones_fetcher: Callable[[], Any] | None,
|
|
345
|
+
issues: Iterable[dict[str, Any]],
|
|
346
|
+
repo: str | None,
|
|
347
|
+
) -> Callable[[], set[str]]:
|
|
348
|
+
"""Return a once-per-call memoized open-milestones resolver.
|
|
349
|
+
|
|
350
|
+
``triage_scope.evaluate_rules`` uses this to ensure the D14b
|
|
351
|
+
``milestone {is-open: true}`` variant fetches the upstream
|
|
352
|
+
open-milestones snapshot AT MOST ONCE per evaluator call, even when
|
|
353
|
+
multiple ``is-open`` rules are present.
|
|
354
|
+
"""
|
|
355
|
+
materialised = list(issues)
|
|
356
|
+
cache: dict[str, set[str] | None] = {"value": None}
|
|
357
|
+
|
|
358
|
+
def resolve() -> set[str]:
|
|
359
|
+
cached = cache["value"]
|
|
360
|
+
if cached is not None:
|
|
361
|
+
return cached
|
|
362
|
+
if open_milestones_fetcher is not None:
|
|
363
|
+
try:
|
|
364
|
+
raw = open_milestones_fetcher()
|
|
365
|
+
except Exception: # noqa: BLE001 -- defensive; empty snapshot = no matches
|
|
366
|
+
raw = set()
|
|
367
|
+
snapshot = (
|
|
368
|
+
set(raw)
|
|
369
|
+
if isinstance(raw, (set, frozenset, list, tuple))
|
|
370
|
+
else set()
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
resolved_repo = repo or infer_repo_from_issues(materialised)
|
|
374
|
+
snapshot = default_open_milestones_fetcher(resolved_repo)
|
|
375
|
+
cache["value"] = snapshot
|
|
376
|
+
return snapshot
|
|
377
|
+
|
|
378
|
+
return resolve
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Evaluator delegate
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def evaluate_milestone_rule_into(
|
|
387
|
+
rule: dict[str, Any],
|
|
388
|
+
issues: list[dict[str, Any]],
|
|
389
|
+
matched: dict[int, dict[str, Any]],
|
|
390
|
+
*,
|
|
391
|
+
get_open_milestones: Callable[[], set[str]],
|
|
392
|
+
is_open_issue: Callable[[dict[str, Any]], bool],
|
|
393
|
+
issue_number: Callable[[dict[str, Any]], int],
|
|
394
|
+
milestone_name: Callable[[dict[str, Any]], str],
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Apply a single ``milestone`` rule to ``issues`` and merge into ``matched``.
|
|
397
|
+
|
|
398
|
+
Delegated from ``triage_scope.evaluate_rules`` so the parent module
|
|
399
|
+
stays under the 1000-line MUST cap. The four predicates
|
|
400
|
+
(``is_open_issue`` / ``issue_number`` / ``milestone_name``) are
|
|
401
|
+
passed in so this helper doesn't need to import them back from
|
|
402
|
+
``triage_scope`` (avoids a circular import).
|
|
403
|
+
"""
|
|
404
|
+
if "name" in rule:
|
|
405
|
+
wanted = rule.get("name")
|
|
406
|
+
if not isinstance(wanted, str) or not wanted:
|
|
407
|
+
return
|
|
408
|
+
for issue in issues:
|
|
409
|
+
if is_open_issue(issue) and milestone_name(issue) == wanted:
|
|
410
|
+
matched.setdefault(issue_number(issue), issue)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
if "any-of" in rule:
|
|
414
|
+
raw = rule.get("any-of")
|
|
415
|
+
if not isinstance(raw, list) or not raw:
|
|
416
|
+
return
|
|
417
|
+
wanted_set = {w for w in raw if isinstance(w, str) and w}
|
|
418
|
+
if not wanted_set:
|
|
419
|
+
return
|
|
420
|
+
for issue in issues:
|
|
421
|
+
if is_open_issue(issue) and milestone_name(issue) in wanted_set:
|
|
422
|
+
matched.setdefault(issue_number(issue), issue)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
if rule.get("is-open") is True:
|
|
426
|
+
open_set = get_open_milestones()
|
|
427
|
+
if not open_set:
|
|
428
|
+
return
|
|
429
|
+
for issue in issues:
|
|
430
|
+
if is_open_issue(issue) and milestone_name(issue) in open_set:
|
|
431
|
+
matched.setdefault(issue_number(issue), issue)
|
|
432
|
+
return
|