@deftai/directive-content 0.59.0 → 0.61.0

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