@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,882 +0,0 @@
1
- #!/usr/bin/env python3
2
- """scripts/gh_rest.py -- REST-fallback helpers for gh mutations and reads (#961).
3
-
4
- Why this module exists
5
- ----------------------
6
- Mid-session 2026-05-07 the ``graphql`` bucket exhausted (`gh api rate_limit`
7
- reported `graphql: 0/5000`, `core: 4996/5000`). `gh issue create`, `gh issue
8
- close`, `gh issue comment`, `gh pr ready`, `gh pr merge`, `gh pr view --json`,
9
- and `gh issue view --json` all routed through GraphQL and failed hard, even
10
- though every operation has a working REST equivalent. The session worked
11
- around it by inlining `gh api repos/.../<endpoint> --method POST/PATCH/PUT
12
- --input <payload.json>` calls per call site. That ad-hoc pattern is
13
- documented as prose in ``meta/lessons.md`` (`## gh CLI GraphQL Bucket
14
- Exhaustion + REST Fallback + UTF-8 Payload Pattern (2026-05)`) but lived
15
- nowhere in code.
16
-
17
- This module reifies the pattern as eight typed Python helpers (seven from
18
- #961 plus :func:`rest_issue_list` from #976) so skills, swarm, triage, and
19
- ad-hoc scripts can call structured functions instead of inlining the
20
- JSON-payload incantation per call site. The REST routing also fixes the
21
- recurring PowerShell 5.1 mojibake hazard (#236 / #240 / #283 / PR #795 /
22
- #798) at one site rather than N sites: every helper builds the JSON
23
- wrapper via Python ``pathlib`` UTF-8.
24
-
25
- Public surface
26
- --------------
27
- Mutations (5):
28
- rest_create_issue(repo, title, body, labels=()) -> dict
29
- POST /repos/{owner}/{repo}/issues
30
- rest_post_comment(repo, n, body) -> dict
31
- POST /repos/{owner}/{repo}/issues/{n}/comments
32
- rest_close_issue(repo, n, *, reason="completed") -> dict
33
- PATCH /repos/{owner}/{repo}/issues/{n}
34
- rest_open_pr(repo, head, base, title, body, *, draft=False) -> dict
35
- POST /repos/{owner}/{repo}/pulls
36
- rest_merge_pr(repo, n, *, method="squash", commit_title=None,
37
- commit_message=None) -> dict
38
- PUT /repos/{owner}/{repo}/pulls/{n}/merge
39
-
40
- Reads (3):
41
- rest_issue_view(repo, n) -> dict
42
- GET /repos/{owner}/{repo}/issues/{n}
43
- rest_pr_view(repo, n) -> dict
44
- GET /repos/{owner}/{repo}/pulls/{n}
45
- rest_issue_list(repo, *, state, labels, per_page) -> list[dict]
46
- GET /repos/{owner}/{repo}/issues -- list issues (#976 SCM REST migration)
47
-
48
- Each helper returns the raw GitHub REST response dict (parsed JSON). On
49
- non-zero ``gh`` exit, every helper raises :class:`GhRestError` carrying
50
- ``stderr``, ``exit_code``, ``endpoint``, ``payload``, and a human-readable
51
- ``hint``. ``InvalidRepoError`` is raised when the ``"owner/repo"`` argument
52
- is malformed.
53
-
54
- Design notes
55
- ------------
56
- - **Repo string format**: ``"owner/repo"`` (matches gh CLI ergonomics).
57
- Helpers split internally for the REST URL template via ``_split_repo``.
58
- - **Binary routing**: helpers invoke ``<binary> api ...`` where
59
- ``<binary>`` comes from ``scripts.scm.resolve_binary`` (ghx -> gh ladder
60
- per #884). For mutations, ``ghx`` is semantically a no-op (it forwards
61
- mutations and invalidates cache; no benefit) but routing through the
62
- ladder anyway preserves consistency with the existing
63
- ``_BINARY_PREFERENCE`` chain. For reads, ``ghx`` provides genuine
64
- within-session dedup benefit per the lessons.md
65
- ``## ghx Within-Session Cache vs deft-cache Cross-Session Persistence
66
- (2026-05)`` entry.
67
- - **JSON payload UTF-8 safety**: every mutation payload is built via
68
- Python ``pathlib.Path.write_text(text, encoding="utf-8")`` then passed
69
- to ``gh api --input <path>``. No PowerShell 5.1 inline-string operations
70
- anywhere in this module. Closes the recurring mojibake hazard chain
71
- (#236 / #240 / #283 / PR #795 / #798) at the gh-mutation call sites.
72
- - **Return shape**: each helper returns the raw GitHub REST response dict.
73
- It does NOT mimic ``gh ...``'s GraphQL-augmented shape -- ``gh issue
74
- view --json closingIssuesReferences`` returns fields that REST
75
- ``GET /issues/{n}`` does not have. Callers needing those fields compose
76
- explicitly.
77
- - **Test seam**: the module-level ``_run_gh_api`` indirection is the
78
- single subprocess seam. Tests monkeypatch this one function rather than
79
- ``subprocess.run`` for each helper.
80
-
81
- Out of scope (per issue #961, by design)
82
- ----------------------------------------
83
- - **Releases** (``POST /releases``, ``PATCH /releases/<id>``). Different
84
- concern -- ``task release`` (#74) owns release creation via
85
- ``gh release create``. The companion ``scripts/release_publish.py`` (#716)
86
- uses inline ``gh api`` REST calls directly for its draft->public flip;
87
- releases are intentionally NOT wrapped by this module.
88
- - **Branch operations** (delete, protect, etc.). Existing direct
89
- ``gh api`` invocations in ``scripts/release.py`` and ``scripts/policy.py``
90
- remain.
91
- - **Label / assignee / reviewer mutations**. Add when first call site
92
- needs them.
93
- - **rest_pr_checks** (CI check-runs polling). Candidate for v2.
94
-
95
- Known limitations (REST-impossible mutations)
96
- ---------------------------------------------
97
- Two mutations CANNOT be REST-fallback'd because they are GraphQL-only on
98
- the GitHub side:
99
-
100
- - ``gh pr ready`` (mark draft -> ready). GitHub's GraphQL
101
- ``markPullRequestReadyForReview`` has no REST equivalent. When GraphQL
102
- is exhausted, draft PRs CANNOT be promoted to ready without waiting for
103
- the bucket reset. Workaround: open PRs non-draft when possible.
104
- - ``gh pr review --approve`` / ``--request-changes``. GraphQL-only
105
- mutation ``addPullRequestReview``. Workaround: post a comment via
106
- :func:`rest_post_comment` (no approval semantics, but unblocks
107
- conversation).
108
-
109
- Cross-references
110
- ----------------
111
- - meta/lessons.md ``## gh CLI GraphQL Bucket Exhaustion + REST Fallback
112
- + UTF-8 Payload Pattern (2026-05)``
113
- - meta/lessons.md ``## REST-fallback module surface (2026-05)``
114
- (deterministic-tier follow-up cross-reference for this module)
115
- - templates/agent-prompt-preamble.md S5 (REST-by-default rule)
116
- - AGENTS.md ``## Multi-agent orchestration discipline (#954)``
117
- - scripts/scm.py::resolve_binary (binary ladder)
118
-
119
- Refs #961, #884, #74, #798.
120
- """
121
-
122
- from __future__ import annotations
123
-
124
- import contextlib
125
- import json
126
- import os
127
- import subprocess
128
- import sys
129
- import tempfile
130
- from dataclasses import dataclass
131
- from pathlib import Path
132
- from typing import Any
133
-
134
- # Make sibling scripts importable so we can re-use scm.resolve_binary
135
- # without duplicating the ghx -> gh ladder.
136
- sys.path.insert(0, str(Path(__file__).resolve().parent))
137
-
138
- import scm # noqa: E402
139
-
140
- #: Default subprocess timeout. Mirrors scripts/release_publish.py and
141
- #: scripts/release.py (60s) so a hung gh process never wedges the caller.
142
- DEFAULT_TIMEOUT_S: int = 60
143
-
144
- #: Public surface -- the nine helpers exported by this module (seven
145
- #: from #961, :func:`rest_issue_list` from #976, plus the paginating
146
- #: :func:`rest_issue_list_paginated` from #1239). The module-level
147
- #: test TestPublicSurfaceContract pins this set; adding a helper requires
148
- #: updating the test in lockstep.
149
- PUBLIC_HELPERS: tuple[str, ...] = (
150
- "rest_create_issue",
151
- "rest_post_comment",
152
- "rest_close_issue",
153
- "rest_open_pr",
154
- "rest_merge_pr",
155
- "rest_issue_view",
156
- "rest_pr_view",
157
- "rest_issue_list",
158
- "rest_issue_list_paginated",
159
- )
160
-
161
- #: Maximum ``per_page`` permitted by the GitHub REST API. Hardcoded by
162
- #: the upstream contract; documented at
163
- #: https://docs.github.com/en/rest/issues/issues#list-repository-issues.
164
- REST_MAX_PER_PAGE: int = 100
165
-
166
- #: Hard safety cap on the number of pages :func:`rest_issue_list_paginated`
167
- #: will fetch before raising. 100 pages * 100 per page = 10,000 issues;
168
- #: any cohort larger than that is a runaway and should be sliced by the
169
- #: caller via ``limit`` rather than silently consuming the REST core
170
- #: bucket.
171
- REST_PAGINATION_MAX_PAGES: int = 100
172
-
173
-
174
- # ---------------------------------------------------------------------------
175
- # Errors
176
- # ---------------------------------------------------------------------------
177
-
178
-
179
- class InvalidRepoError(ValueError):
180
- """Raised when the ``"owner/repo"`` argument is malformed.
181
-
182
- Examples that raise:
183
- ``""``, ``"owner"``, ``"owner/"``, ``"/repo"``,
184
- ``"owner/repo/extra"``, non-string arguments.
185
- """
186
-
187
-
188
- @dataclass
189
- class GhRestError(RuntimeError):
190
- """Raised on non-zero ``gh api`` exit or non-JSON success response.
191
-
192
- Attributes:
193
- stderr: Captured stderr from the ``gh api`` invocation, stripped.
194
- exit_code: Process exit code (0 for non-JSON success cases).
195
- endpoint: REST endpoint path (e.g. ``"repos/owner/name/issues"``).
196
- payload: Mutation payload that was POSTed/PATCHed/PUT, or ``None``
197
- for read operations.
198
- hint: Actionable recovery hint (auth, permissions, rate-limit, etc.).
199
-
200
- The dataclass form gives callers a structured error surface (test
201
- assertions can introspect ``exc.endpoint``, ``exc.exit_code``, etc.)
202
- without parsing the message string.
203
- """
204
-
205
- stderr: str
206
- exit_code: int
207
- endpoint: str
208
- payload: dict[str, Any] | None
209
- hint: str = ""
210
-
211
- def __post_init__(self) -> None:
212
- # Build the human-readable message once so callers can either
213
- # inspect the structured attributes OR fall back to str(exc).
214
- msg = (
215
- f"gh api failed: endpoint={self.endpoint!r} "
216
- f"exit={self.exit_code} stderr={self.stderr!r}"
217
- )
218
- if self.hint:
219
- msg += f"; hint: {self.hint}"
220
- # RuntimeError.__init__ takes *args; pass the assembled message.
221
- super().__init__(msg)
222
-
223
-
224
- # ---------------------------------------------------------------------------
225
- # Internals
226
- # ---------------------------------------------------------------------------
227
-
228
-
229
- def _split_repo(repo: str) -> tuple[str, str]:
230
- """Split a ``"owner/repo"`` string into ``(owner, repo)`` components.
231
-
232
- Raises:
233
- InvalidRepoError: On any malformed input -- empty string, missing
234
- slash, multiple slashes, empty owner/repo segments, non-string
235
- arguments. The error message echoes the offending value so
236
- operators can correlate it to the call site.
237
- """
238
- if not isinstance(repo, str) or not repo:
239
- raise InvalidRepoError(
240
- f"repo must be a non-empty string of the form 'owner/repo'; "
241
- f"got {repo!r}"
242
- )
243
- parts = repo.split("/")
244
- if len(parts) != 2 or not parts[0] or not parts[1]:
245
- raise InvalidRepoError(
246
- f"repo must match 'owner/repo' (single slash, both segments "
247
- f"non-empty); got {repo!r}"
248
- )
249
- return parts[0], parts[1]
250
-
251
-
252
- def _run_gh_api(
253
- args: list[str], *, timeout: int = DEFAULT_TIMEOUT_S
254
- ) -> subprocess.CompletedProcess[str]:
255
- """Single subprocess seam invoked by every helper.
256
-
257
- Tests monkeypatch this function (``gh_rest._run_gh_api``) instead of
258
- patching ``subprocess.run`` for each helper -- one seam, hermetic
259
- coverage of every helper that flows through ``_exec``.
260
-
261
- The binary is resolved via ``scm.resolve_binary`` (ghx -> gh ladder
262
- per #884). The argv passed in is ``["api", *args]`` -- callers do NOT
263
- include the binary name.
264
- """
265
- binary = scm.resolve_binary()
266
- cmd = [binary, "api", *args]
267
- return subprocess.run(
268
- cmd,
269
- capture_output=True,
270
- text=True,
271
- # Pin UTF-8 explicitly so issue bodies / comments containing
272
- # non-ASCII bytes (em dashes, smart quotes, emoji) round-trip
273
- # cleanly on every platform. Without this, Python on Windows
274
- # falls back to cp1252 which raises ``UnicodeDecodeError`` on
275
- # bytes >= 0x80 inside the subprocess reader thread, leaving
276
- # ``stdout`` empty and the helper to return ``{}`` silently --
277
- # a mode that breaks the live smoke against any GitHub issue
278
- # containing UTF-8 glyphs (Greptile P1 #998 review at 367748e
279
- # surfaced this when the per-test skip-marker change exposed
280
- # the latent Windows-only failure).
281
- encoding="utf-8",
282
- errors="replace",
283
- timeout=timeout,
284
- check=False,
285
- env=os.environ.copy(),
286
- )
287
-
288
-
289
- def _write_json_payload(payload: dict[str, Any]) -> Path:
290
- """Serialise ``payload`` to a tempfile via Python pathlib UTF-8.
291
-
292
- The two-step (write_text + utf-8 encoding) approach is the
293
- PowerShell-5.1-safe canonical form documented in
294
- ``meta/lessons.md`` ``## gh CLI GraphQL Bucket Exhaustion + REST
295
- Fallback + UTF-8 Payload Pattern (2026-05)``. ``ensure_ascii=False``
296
- preserves non-ASCII glyphs (em dashes, arrows, smart quotes) as
297
- canonical UTF-8 bytes -- the alternative escapes them to ``\\uXXXX``
298
- which round-trips correctly but bloats the payload and obscures the
299
- bytes operators see when debugging.
300
-
301
- Caller is responsible for unlinking the file after the gh call
302
- completes (mutations always do this in a ``try/finally``).
303
- """
304
- fd, name = tempfile.mkstemp(suffix=".json", prefix="gh_rest_payload_")
305
- os.close(fd)
306
- path = Path(name)
307
- path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
308
- return path
309
-
310
-
311
- def _exec(
312
- args: list[str],
313
- *,
314
- endpoint: str,
315
- payload: dict[str, Any] | None,
316
- hint: str = "",
317
- expect_list: bool = False,
318
- ) -> Any:
319
- """Run ``gh api`` and parse the JSON response, raising on failure.
320
-
321
- All helpers funnel through this one function so the error-path
322
- semantics (typed exception with structured attributes) are uniform.
323
-
324
- Args:
325
- expect_list: When ``True`` the top-level JSON response must be a
326
- list (for collection endpoints like ``GET /repos/.../issues``).
327
- When ``False`` (default) the response must be a dict (single-
328
- resource endpoints). The check guards against gh / endpoint
329
- mismatches that would otherwise silently mishandle results.
330
- """
331
- result = _run_gh_api(args)
332
- if result.returncode != 0:
333
- raise GhRestError(
334
- stderr=(result.stderr or "").strip(),
335
- exit_code=int(result.returncode),
336
- endpoint=endpoint,
337
- payload=payload,
338
- hint=hint,
339
- )
340
- stdout = (result.stdout or "").strip()
341
- if not stdout:
342
- # Some PUT/PATCH responses may return 204 No Content; ``gh api``
343
- # surfaces this as empty stdout + zero exit. Treat as success
344
- # with an empty dict (or empty list for collection endpoints) so
345
- # callers do not need to special-case.
346
- return [] if expect_list else {}
347
- try:
348
- parsed = json.loads(stdout)
349
- except json.JSONDecodeError as exc:
350
- raise GhRestError(
351
- stderr=f"non-JSON response: {exc}; raw={stdout!r}",
352
- exit_code=0,
353
- endpoint=endpoint,
354
- payload=payload,
355
- hint="REST endpoint returned non-JSON; check gh / ghx version",
356
- ) from exc
357
- expected_type = list if expect_list else dict
358
- if not isinstance(parsed, expected_type):
359
- # The endpoints used by this module return either a top-level
360
- # object (single-resource) or a list (collection). A mismatch
361
- # would indicate a bug (wrong endpoint) or a gh version mismatch;
362
- # raise so callers do not silently mishandle.
363
- raise GhRestError(
364
- stderr=f"unexpected top-level type {type(parsed).__name__}",
365
- exit_code=0,
366
- endpoint=endpoint,
367
- payload=payload,
368
- hint=(
369
- f"REST endpoint returned non-{expected_type.__name__}; "
370
- f"expected {expected_type.__name__}"
371
- ),
372
- )
373
- return parsed
374
-
375
-
376
- # ---------------------------------------------------------------------------
377
- # Mutations
378
- # ---------------------------------------------------------------------------
379
-
380
-
381
- def rest_create_issue(
382
- repo: str,
383
- title: str,
384
- body: str,
385
- labels: tuple[str, ...] = (),
386
- ) -> dict[str, Any]:
387
- """``POST /repos/{owner}/{repo}/issues`` -- create a new issue.
388
-
389
- Args:
390
- repo: ``"owner/repo"`` slug.
391
- title: Issue title.
392
- body: Issue body (markdown). UTF-8 round-trip safe via
393
- ``_write_json_payload``.
394
- labels: Optional iterable of label names to apply on creation.
395
- Empty tuple (default) creates the issue with no labels.
396
-
397
- Returns:
398
- Parsed JSON response dict (the GitHub REST issue object: number,
399
- title, body, state, html_url, user, labels, ...).
400
-
401
- Raises:
402
- InvalidRepoError: Malformed ``repo`` argument.
403
- GhRestError: Non-zero ``gh api`` exit (auth, permissions,
404
- label-not-found, rate-limit, ...).
405
- """
406
- owner, name = _split_repo(repo)
407
- payload: dict[str, Any] = {"title": title, "body": body}
408
- if labels:
409
- payload["labels"] = list(labels)
410
- payload_path = _write_json_payload(payload)
411
- try:
412
- endpoint = f"repos/{owner}/{name}/issues"
413
- return _exec(
414
- [endpoint, "--method", "POST", "--input", str(payload_path)],
415
- endpoint=endpoint,
416
- payload=payload,
417
- hint=(
418
- "verify repo permissions, label existence, and that the "
419
- "core REST bucket has remaining quota"
420
- ),
421
- )
422
- finally:
423
- with contextlib.suppress(OSError):
424
- payload_path.unlink()
425
-
426
-
427
- def rest_post_comment(repo: str, n: int, body: str) -> dict[str, Any]:
428
- """``POST /repos/{owner}/{repo}/issues/{n}/comments`` -- post a comment.
429
-
430
- Works for both issues AND pull requests (PRs are issues in the GitHub
431
- REST data model; ``/issues/{n}/comments`` is the canonical comment
432
- endpoint for both).
433
-
434
- Args:
435
- repo: ``"owner/repo"`` slug.
436
- n: Issue or PR number.
437
- body: Comment body (markdown). UTF-8 round-trip safe.
438
-
439
- Returns:
440
- Parsed REST comment object (id, body, html_url, user, ...).
441
-
442
- Raises:
443
- InvalidRepoError: Malformed ``repo``.
444
- GhRestError: Non-zero ``gh api`` exit.
445
- """
446
- owner, name = _split_repo(repo)
447
- payload: dict[str, Any] = {"body": body}
448
- payload_path = _write_json_payload(payload)
449
- try:
450
- endpoint = f"repos/{owner}/{name}/issues/{n}/comments"
451
- return _exec(
452
- [endpoint, "--method", "POST", "--input", str(payload_path)],
453
- endpoint=endpoint,
454
- payload=payload,
455
- hint=(
456
- "verify repo permissions, that the issue/PR is open or "
457
- "lockable, and core REST bucket quota"
458
- ),
459
- )
460
- finally:
461
- with contextlib.suppress(OSError):
462
- payload_path.unlink()
463
-
464
-
465
- def rest_close_issue(
466
- repo: str, n: int, *, reason: str | None = "completed"
467
- ) -> dict[str, Any]:
468
- """``PATCH /repos/{owner}/{repo}/issues/{n}`` -- close an issue.
469
-
470
- Args:
471
- repo: ``"owner/repo"`` slug.
472
- n: Issue number.
473
- reason: ``state_reason`` per the GitHub REST API. Allowed values
474
- are ``"completed"`` (default), ``"not_planned"``,
475
- ``"reopened"``, or ``None`` for unset. The default mirrors
476
- ``gh issue close --reason completed``. Greptile P2-3 (#961):
477
- the type annotation is ``str | None`` because the docstring
478
- documents ``None`` as a supported value (the GitHub REST
479
- API accepts ``"state_reason": null`` to clear it); the
480
- annotation now matches that contract.
481
-
482
- Returns:
483
- Parsed REST issue object reflecting the post-close state.
484
-
485
- Raises:
486
- InvalidRepoError: Malformed ``repo``.
487
- GhRestError: Non-zero ``gh api`` exit.
488
- """
489
- owner, name = _split_repo(repo)
490
- payload: dict[str, Any] = {"state": "closed", "state_reason": reason}
491
- payload_path = _write_json_payload(payload)
492
- try:
493
- endpoint = f"repos/{owner}/{name}/issues/{n}"
494
- return _exec(
495
- [endpoint, "--method", "PATCH", "--input", str(payload_path)],
496
- endpoint=endpoint,
497
- payload=payload,
498
- hint=(
499
- "verify repo permissions and that the issue is open "
500
- "(closing a closed issue is idempotent server-side)"
501
- ),
502
- )
503
- finally:
504
- with contextlib.suppress(OSError):
505
- payload_path.unlink()
506
-
507
-
508
- def rest_open_pr(
509
- repo: str,
510
- head: str,
511
- base: str,
512
- title: str,
513
- body: str,
514
- *,
515
- draft: bool = False,
516
- ) -> dict[str, Any]:
517
- """``POST /repos/{owner}/{repo}/pulls`` -- open a pull request.
518
-
519
- Args:
520
- repo: ``"owner/repo"`` slug.
521
- head: Source branch (``"feature/..."``); for cross-fork PRs use
522
- ``"forkowner:branch"``.
523
- base: Target branch (typically ``"master"`` or ``"main"``).
524
- title: PR title.
525
- body: PR description (markdown). UTF-8 round-trip safe.
526
- draft: When ``True``, creates the PR in draft state. The
527
- companion ``gh pr ready`` (mark-ready-for-review) mutation
528
- is GraphQL-only -- see module docstring known limitations.
529
-
530
- Returns:
531
- Parsed REST pull request object (number, html_url, head, base,
532
- draft, user, ...).
533
-
534
- Raises:
535
- InvalidRepoError: Malformed ``repo``.
536
- GhRestError: Non-zero ``gh api`` exit (no diff between head and
537
- base, branch missing, repo permissions, ...).
538
- """
539
- owner, name = _split_repo(repo)
540
- payload: dict[str, Any] = {
541
- "title": title,
542
- "head": head,
543
- "base": base,
544
- "body": body,
545
- "draft": draft,
546
- }
547
- payload_path = _write_json_payload(payload)
548
- try:
549
- endpoint = f"repos/{owner}/{name}/pulls"
550
- return _exec(
551
- [endpoint, "--method", "POST", "--input", str(payload_path)],
552
- endpoint=endpoint,
553
- payload=payload,
554
- hint=(
555
- "verify branch exists on origin, head/base differ, repo "
556
- "permissions, and core REST bucket quota"
557
- ),
558
- )
559
- finally:
560
- with contextlib.suppress(OSError):
561
- payload_path.unlink()
562
-
563
-
564
- def rest_merge_pr(
565
- repo: str,
566
- n: int,
567
- *,
568
- method: str = "squash",
569
- commit_title: str | None = None,
570
- commit_message: str | None = None,
571
- ) -> dict[str, Any]:
572
- """``PUT /repos/{owner}/{repo}/pulls/{n}/merge`` -- merge a pull request.
573
-
574
- Args:
575
- repo: ``"owner/repo"`` slug.
576
- n: PR number.
577
- method: One of ``"squash"`` (default), ``"merge"``, ``"rebase"``.
578
- Mirrors the GitHub REST ``merge_method`` field.
579
- commit_title: Optional override for the merge commit title.
580
- commit_message: Optional override for the merge commit body.
581
-
582
- Returns:
583
- Parsed REST merge response (sha, merged, message).
584
-
585
- Raises:
586
- InvalidRepoError: Malformed ``repo``.
587
- GhRestError: Non-zero ``gh api`` exit (PR not mergeable, branch
588
- protection refusal, draft PR, ...).
589
- """
590
- owner, name = _split_repo(repo)
591
- payload: dict[str, Any] = {"merge_method": method}
592
- if commit_title is not None:
593
- payload["commit_title"] = commit_title
594
- if commit_message is not None:
595
- payload["commit_message"] = commit_message
596
- payload_path = _write_json_payload(payload)
597
- try:
598
- endpoint = f"repos/{owner}/{name}/pulls/{n}/merge"
599
- return _exec(
600
- [endpoint, "--method", "PUT", "--input", str(payload_path)],
601
- endpoint=endpoint,
602
- payload=payload,
603
- hint=(
604
- "verify PR is non-draft, mergeable, branch-protection "
605
- "checks pass, and required reviews are satisfied"
606
- ),
607
- )
608
- finally:
609
- with contextlib.suppress(OSError):
610
- payload_path.unlink()
611
-
612
-
613
- # ---------------------------------------------------------------------------
614
- # Reads
615
- # ---------------------------------------------------------------------------
616
-
617
-
618
- def rest_issue_view(repo: str, n: int) -> dict[str, Any]:
619
- """``GET /repos/{owner}/{repo}/issues/{n}`` -- read a single issue.
620
-
621
- Note: REST does NOT return the ``closingIssuesReferences`` /
622
- ``timelineItems`` fields that ``gh issue view --json`` (GraphQL)
623
- does. Callers needing those fields must use a separate path.
624
-
625
- Args:
626
- repo: ``"owner/repo"`` slug.
627
- n: Issue number.
628
-
629
- Returns:
630
- Parsed REST issue object.
631
-
632
- Raises:
633
- InvalidRepoError: Malformed ``repo``.
634
- GhRestError: Non-zero ``gh api`` exit (404 not found, 403 auth,
635
- ...).
636
- """
637
- owner, name = _split_repo(repo)
638
- endpoint = f"repos/{owner}/{name}/issues/{n}"
639
- return _exec(
640
- [endpoint],
641
- endpoint=endpoint,
642
- payload=None,
643
- hint="verify repo and issue number; check gh auth status",
644
- )
645
-
646
-
647
- def rest_issue_list(
648
- repo: str,
649
- *,
650
- state: str = "open",
651
- labels: tuple[str, ...] = (),
652
- author: str | None = None,
653
- per_page: int = 30,
654
- ) -> list[dict[str, Any]]:
655
- """``GET /repos/{owner}/{repo}/issues`` -- list issues (REST collection).
656
-
657
- Added in #976 to give the SCM stub a REST-backed list path so the
658
- Story 2 ``cache:fetch-all`` enumeration step (and the live SCM smoke)
659
- no longer have to drain the GraphQL bucket via ``gh issue list``.
660
-
661
- Note: GitHub's REST ``GET /issues`` endpoint returns BOTH issues and
662
- pull requests (PRs are issues in the REST data model). Each item in
663
- the response carries a ``pull_request`` key when it is a PR; callers
664
- that want issues only must filter on ``"pull_request" not in item``.
665
- The deliberate non-filtering here mirrors GitHub's REST contract --
666
- callers compose the filter explicitly so the helper stays a thin
667
- wrapper over the endpoint.
668
-
669
- Args:
670
- repo: ``"owner/repo"`` slug.
671
- state: One of ``"open"`` (default), ``"closed"``, ``"all"``.
672
- Mirrors gh CLI's ``--state`` flag and the REST ``state``
673
- query param.
674
- labels: Optional iterable of label names to filter by. Joined
675
- with ``,`` per the REST contract. Empty tuple (default)
676
- applies no label filter.
677
- author: Optional issue-creator login to filter by (#1055). Maps
678
- to the REST ``creator`` query param (``gh issue list``'s
679
- ``--author`` equivalent). ``None`` (default) applies no
680
- author filter. Composes with ``labels`` via AND semantics:
681
- GitHub applies each query param independently, so the result
682
- is the intersection -- issues with the given label(s) AND
683
- created by the given login.
684
- per_page: Max items per page. GitHub caps this at 100; the
685
- default of 30 mirrors the REST API's own default. This
686
- helper does NOT auto-paginate -- callers needing more than
687
- ``per_page`` items must paginate explicitly via the
688
- ``page`` REST param (add to gh_rest if a call site needs it).
689
-
690
- Returns:
691
- Parsed REST issues list (each entry is a REST issue object:
692
- number, title, state, user, labels, created_at, updated_at,
693
- pull_request (when applicable), ...).
694
-
695
- Raises:
696
- InvalidRepoError: Malformed ``repo``.
697
- GhRestError: Non-zero ``gh api`` exit (404 not found, 403 auth,
698
- non-list response shape, ...).
699
- """
700
- owner, name = _split_repo(repo)
701
- endpoint = f"repos/{owner}/{name}/issues"
702
- # gh api accepts repeated -F / --raw-field for query-string params;
703
- # we use --raw-field uniformly (string-typed) for state / per_page /
704
- # labels / creator per the REST contract. The labels filter is joined
705
- # comma-separated per GitHub's documented multi-label query
706
- # convention. The ``creator`` param (#1055) is the REST spelling of
707
- # gh's ``--author``; labels + creator compose as AND server-side.
708
- # SLizard P3 (#998 review): the prior comment claimed `-F for labels`
709
- # but the implementation has always used --raw-field; comment
710
- # corrected to match.
711
- args: list[str] = [endpoint, "--method", "GET"]
712
- args.extend(["--raw-field", f"state={state}"])
713
- args.extend(["--raw-field", f"per_page={per_page}"])
714
- if labels:
715
- args.extend(["--raw-field", f"labels={','.join(labels)}"])
716
- if author:
717
- args.extend(["--raw-field", f"creator={author}"])
718
- return _exec(
719
- args,
720
- endpoint=endpoint,
721
- payload=None,
722
- hint=(
723
- "verify repo, state value (open|closed|all), labels exist, "
724
- "and core REST bucket has remaining quota"
725
- ),
726
- expect_list=True,
727
- )
728
-
729
-
730
- def rest_pr_view(repo: str, n: int) -> dict[str, Any]:
731
- """``GET /repos/{owner}/{repo}/pulls/{n}`` -- read a single pull request.
732
-
733
- Note: REST does NOT return ``mergeStateStatus``, ``reviewDecision``,
734
- or ``isDraft`` field naming that the GraphQL ``gh pr view --json``
735
- surface uses. The REST ``draft`` field is the canonical equivalent
736
- of the GraphQL ``isDraft`` field; ``mergeable_state`` is the
737
- closest REST equivalent of the GraphQL ``mergeStateStatus``.
738
-
739
- Args:
740
- repo: ``"owner/repo"`` slug.
741
- n: PR number.
742
-
743
- Returns:
744
- Parsed REST pull request object.
745
-
746
- Raises:
747
- InvalidRepoError: Malformed ``repo``.
748
- GhRestError: Non-zero ``gh api`` exit.
749
- """
750
- owner, name = _split_repo(repo)
751
- endpoint = f"repos/{owner}/{name}/pulls/{n}"
752
- return _exec(
753
- [endpoint],
754
- endpoint=endpoint,
755
- payload=None,
756
- hint="verify repo and PR number; check gh auth status",
757
- )
758
-
759
-
760
- def rest_issue_list_paginated(
761
- repo: str,
762
- *,
763
- state: str = "open",
764
- labels: tuple[str, ...] = (),
765
- author: str | None = None,
766
- per_page: int = REST_MAX_PER_PAGE,
767
- limit: int | None = None,
768
- exclude_pulls: bool = True,
769
- ) -> list[dict[str, Any]]:
770
- """Paginated ``GET /repos/{owner}/{repo}/issues`` -- list ALL issues.
771
-
772
- Added in #1239 to give the Story 2 ``cache:fetch-all`` enumeration
773
- step a single REST surface that auto-paginates through the full
774
- issue cohort (vs the prior GraphQL ``gh issue list`` path that
775
- drained the GraphQL bucket and the per-issue ``gh issue view``
776
- cascade that imposed N round trips for an N-issue cohort).
777
-
778
- A 396-issue cohort at ``per_page=100`` is 4 round trips end-to-end;
779
- a 1000-issue cohort is 10. This is the load-bearing performance
780
- fix for the #1239 acceptance criterion ("target: < 2 minutes" for
781
- the 396-issue bootstrap, vs the ~8.5 minute GraphQL baseline).
782
-
783
- Args:
784
- repo: ``"owner/repo"`` slug.
785
- state: Forwarded to :func:`rest_issue_list` per-page.
786
- labels: Forwarded to :func:`rest_issue_list` per-page.
787
- author: Optional issue-creator login forwarded per-page as the
788
- REST ``creator`` param (#1055). ``None`` (default) applies
789
- no author filter. Composes with ``labels`` via AND.
790
- per_page: Items per page. Clamped to
791
- :data:`REST_MAX_PER_PAGE` (100). Smaller values produce
792
- more round trips; larger values are silently capped.
793
- limit: Optional global cap on returned items. When set,
794
- pagination stops as soon as ``len(out) >= limit`` (the
795
- list is truncated to exactly ``limit`` entries before
796
- return).
797
- exclude_pulls: When ``True`` (default), drops entries that
798
- carry a ``pull_request`` key (REST returns PRs alongside
799
- issues; the cache layer's source enum is ``github-issue``
800
- so PRs are out of scope). Pass ``False`` for callers that
801
- want the full REST shape.
802
-
803
- Returns:
804
- Flat list of REST issue payloads. Empty list when the repo
805
- has no matching issues.
806
-
807
- Raises:
808
- InvalidRepoError: Malformed ``repo`` argument.
809
- GhRestError: Non-zero ``gh api`` exit on any page, or
810
- ``REST_PAGINATION_MAX_PAGES`` exceeded without exhausting
811
- the cohort (caller should slice via ``limit`` or open a
812
- follow-up to add explicit ``page`` cursor support).
813
- """
814
- capped_per_page = min(max(1, int(per_page)), REST_MAX_PER_PAGE)
815
- owner, name = _split_repo(repo)
816
- endpoint = f"repos/{owner}/{name}/issues"
817
- out: list[dict[str, Any]] = []
818
- for page in range(1, REST_PAGINATION_MAX_PAGES + 1):
819
- args: list[str] = [endpoint, "--method", "GET"]
820
- args.extend(["--raw-field", f"state={state}"])
821
- args.extend(["--raw-field", f"per_page={capped_per_page}"])
822
- args.extend(["--raw-field", f"page={page}"])
823
- if labels:
824
- args.extend(["--raw-field", f"labels={','.join(labels)}"])
825
- if author:
826
- args.extend(["--raw-field", f"creator={author}"])
827
- page_payload = _exec(
828
- args,
829
- endpoint=endpoint,
830
- payload=None,
831
- hint=(
832
- "verify repo, state value (open|closed|all), labels exist, "
833
- "and core REST bucket has remaining quota"
834
- ),
835
- expect_list=True,
836
- )
837
- if not isinstance(page_payload, list) or not page_payload:
838
- return out
839
- for item in page_payload:
840
- if not isinstance(item, dict):
841
- continue
842
- if exclude_pulls and "pull_request" in item:
843
- continue
844
- out.append(item)
845
- if limit is not None and len(out) >= limit:
846
- return out[:limit]
847
- if len(page_payload) < capped_per_page:
848
- # Short page -- by REST contract this is the last page.
849
- return out
850
- raise GhRestError(
851
- stderr=(
852
- f"pagination exceeded REST_PAGINATION_MAX_PAGES={REST_PAGINATION_MAX_PAGES} "
853
- f"({REST_PAGINATION_MAX_PAGES * capped_per_page} items collected; "
854
- "the cohort is larger than this safety cap)"
855
- ),
856
- exit_code=0,
857
- endpoint=endpoint,
858
- payload=None,
859
- hint=(
860
- "pass an explicit `limit` to bound the run, or open a follow-up "
861
- "to add explicit `page` cursor support to rest_issue_list_paginated"
862
- ),
863
- )
864
-
865
-
866
- __all__ = [
867
- "DEFAULT_TIMEOUT_S",
868
- "GhRestError",
869
- "InvalidRepoError",
870
- "PUBLIC_HELPERS",
871
- "REST_MAX_PER_PAGE",
872
- "REST_PAGINATION_MAX_PAGES",
873
- "rest_close_issue",
874
- "rest_create_issue",
875
- "rest_issue_list",
876
- "rest_issue_list_paginated",
877
- "rest_issue_view",
878
- "rest_merge_pr",
879
- "rest_open_pr",
880
- "rest_post_comment",
881
- "rest_pr_view",
882
- ]