@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.
- package/.githooks/pre-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
package/scripts/gh_rest.py
DELETED
|
@@ -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
|
-
]
|