@deftai/directive-content 0.55.2 → 0.56.0

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