@deftai/directive-content 0.58.0 → 0.60.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.
Files changed (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,432 +0,0 @@
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