autoforge-cli 0.1.0a0__py3-none-any.whl

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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/github.py ADDED
@@ -0,0 +1,3402 @@
1
+ """GitHub abstraction over the `gh` CLI (verification reads + controller-owned writes).
2
+
3
+ Business logic must use these typed objects, never parse raw `gh` JSON
4
+ inline. The controller reads GitHub through this client to verify what an
5
+ agent claims, and it performs every GitHub write itself through it:
6
+
7
+ - the typed effect writes of ADR 0004 (D12.2), one method per GitHub effect
8
+ kind: :meth:`GitHubClient.create_pull_request` (K2, K7),
9
+ :meth:`GitHubClient.write_pr_body` (K3),
10
+ :meth:`GitHubClient.create_pr_comment` (K4),
11
+ :meth:`GitHubClient.create_issue` (K5),
12
+ :meth:`GitHubClient.write_issue_body` (K6) and
13
+ :meth:`GitHubClient.create_issue_comment` (K8). Each is issued exactly
14
+ once, outside the transient-retry loop that reads keep (D4.3): an
15
+ ambiguous outcome is never re-sent, the caller reconciles it by reading
16
+ the target back by its identity. The payload travels on stdin, never in
17
+ argv, and the repository is named in the API path (D7.4). What a write
18
+ returns is informational; the caller's read-back is the evidence;
19
+ - the writes that keep their own mechanisms (D5.4):
20
+ :meth:`GitHubClient.merge_pr`, always behind the safety gate and bound to
21
+ the reviewed HEAD via ``--match-head-commit``, and the merged PR's remote
22
+ head branch deletion behind ``merge.delete_branch``,
23
+ :meth:`GitHubClient.delete_branch_ref` (#283);
24
+ :meth:`GitHubClient.disable_auto_merge`; :meth:`GitHubClient.close_pr`,
25
+ its receipt and the compensating :meth:`GitHubClient.reopen_pr` for a
26
+ verified replacement lifecycle, never deleting a branch; and the EPIC
27
+ roadmap splice, :meth:`GitHubClient.edit_issue_body`.
28
+
29
+ Every call goes through `gh` except the exact reads (#327):
30
+ :meth:`GitHubClient.get_pr_exact`, :meth:`GitHubClient.get_issue_exact` and
31
+ :meth:`GitHubClient.get_comment_exact` read one object from GitHub's REST
32
+ API directly (:func:`https_get`), with the credential `gh auth token`
33
+ prints, because `gh` rewrites the text it prints and an effect's read-back
34
+ must compare GitHub's own bytes. They are read-only and bounded.
35
+
36
+ Every `gh` process starts in an empty directory of its own, never in the
37
+ operator's checkout (:func:`gh_workdir`, #283): `gh` runs git where it is
38
+ started, and nothing it is asked here needs a local repository.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import functools
44
+ import http.client
45
+ import io
46
+ import json
47
+ import os
48
+ import re
49
+ import shutil
50
+ import ssl
51
+ import tempfile
52
+ import time
53
+ import urllib.error
54
+ import urllib.request
55
+ from collections.abc import Callable, Iterator, Sequence
56
+ from contextlib import contextmanager
57
+ from dataclasses import dataclass, field
58
+ from datetime import datetime
59
+ from typing import NoReturn
60
+ from urllib.parse import quote, urlencode
61
+
62
+ from .errors import (
63
+ ConfigurationError,
64
+ GitHubChecksAccessError,
65
+ GitHubError,
66
+ GitHubNotFoundError,
67
+ GitHubPlanLimitedError,
68
+ GitHubUnavailableError,
69
+ GitHubWrongTargetError,
70
+ )
71
+ from .executor import ExecutionRequest, ExecutionResult, execute
72
+ from .locking import assert_shared_git_lock_free
73
+ from .redaction import redact
74
+ from .validation import (
75
+ GitHubCommentRef,
76
+ GitHubIssueRef,
77
+ GitHubPullRequestRef,
78
+ GitHubRef,
79
+ parse_api_issue_url,
80
+ parse_api_repository_url,
81
+ parse_comment_url,
82
+ parse_github_url,
83
+ parse_issue_url,
84
+ parse_pr_url,
85
+ )
86
+
87
+ # Transient `gh` failures: the same read may well succeed later, so the engine
88
+ # treats them as *inconclusive* (bounded re-checks) instead of conclusive.
89
+ # Phrase markers cover network-level errors (as the Go runtime behind `gh`
90
+ # reports them: ``dial tcp ...: connect: network is unreachable``, ``lookup
91
+ # api.github.com: temporary failure in name resolution``, ...) and the reason
92
+ # phrases GitHub attaches to server-side / throttling statuses; the HTTP
93
+ # status itself is matched as a whole class (every 5xx, plus 429) rather than
94
+ # an enumerated list, so e.g. ``HTTP 500`` is not silently conclusive. `gh`
95
+ # prints the status as ``HTTP 502: Bad Gateway`` (GraphQL) or ``gh: Bad
96
+ # Gateway (HTTP 502)`` (REST); both shapes are matched, bare numbers elsewhere
97
+ # in the message (PR numbers, SHAs) are not.
98
+ _TRANSIENT_MARKERS = (
99
+ # -- connection / socket level (OS errno strings as surfaced by Go's net package)
100
+ "timeout",
101
+ "timed out",
102
+ "connection reset",
103
+ "connection refused",
104
+ "connection aborted",
105
+ "broken pipe",
106
+ "unexpected eof",
107
+ "network is unreachable",
108
+ "network is down",
109
+ "no route to host",
110
+ "host is down",
111
+ # Every `dial tcp ...` error is a failure to *open* the connection, never an
112
+ # answer from GitHub, so the whole family is transient even when the
113
+ # trailing errno phrase is one not listed here.
114
+ "dial tcp",
115
+ "temporarily unavailable",
116
+ "tls handshake",
117
+ # -- name resolution (Go resolver / glibc phrases)
118
+ "no such host",
119
+ "temporary failure in name resolution",
120
+ "server misbehaving",
121
+ # `gh` wraps any connection-level error (whatever host is configured) as
122
+ # ``error connecting to <host>\ncheck your internet connection or ...``.
123
+ "error connecting to",
124
+ # -- GitHub-side throttling / server errors
125
+ "rate limit",
126
+ "too many requests",
127
+ "internal server error",
128
+ "bad gateway",
129
+ "service unavailable",
130
+ "gateway timeout",
131
+ "gateway time-out",
132
+ )
133
+ _TRANSIENT_HTTP_STATUS = re.compile(r"\bhttp\s+(?:5\d\d|429)\b")
134
+
135
+
136
+ def is_transient_gh_failure(stderr: str) -> bool:
137
+ """Whether a failed `gh` invocation's stderr describes a transient failure."""
138
+ text = stderr.lower()
139
+ return any(m in text for m in _TRANSIENT_MARKERS) or bool(_TRANSIENT_HTTP_STATUS.search(text))
140
+
141
+
142
+ # Conclusive "does not exist" answers. GraphQL (`gh issue view`, `gh pr view`)
143
+ # says ``Could not resolve to an Issue with the number of 999.``; REST says
144
+ # ``HTTP 404: Not Found``; `gh pr` commands say ``could not find pull request``.
145
+ # A private object the token cannot see is reported the same way by GitHub.
146
+ _NOT_FOUND_MARKERS = (
147
+ "could not resolve to a",
148
+ "could not find",
149
+ )
150
+ _NOT_FOUND_HTTP_STATUS = re.compile(r"\bhttp\s+404\b")
151
+
152
+
153
+ def is_not_found_gh_failure(stderr: str) -> bool:
154
+ """Whether a failed `gh` invocation's stderr says the object does not exist."""
155
+ text = stderr.lower()
156
+ return any(m in text for m in _NOT_FOUND_MARKERS) or bool(_NOT_FOUND_HTTP_STATUS.search(text))
157
+
158
+
159
+ # Conclusive "you may not read this" answers: no credentials at all (`gh`
160
+ # prints ``To get started with GitHub CLI, please run: gh auth login``),
161
+ # rejected credentials (``HTTP 401: Bad credentials``) or a token that cannot
162
+ # see the object (``HTTP 403``, ``Must have admin rights``, ``Resource not
163
+ # accessible by integration``). A read-only diagnostic distinguishes these
164
+ # from a *negative answer*: they say nothing about the repository, only about
165
+ # what this token may see of it. GitHub's plan-entitlement 403 is not one of
166
+ # them (#289, :func:`is_plan_limited_gh_failure`): it is about the plan, and
167
+ # no token permission changes it.
168
+ _ACCESS_DENIED_MARKERS = (
169
+ "gh auth login",
170
+ "bad credentials",
171
+ "requires authentication",
172
+ "must have admin",
173
+ "resource not accessible",
174
+ "forbidden",
175
+ "unauthorized",
176
+ )
177
+ _ACCESS_DENIED_HTTP_STATUS = re.compile(r"\bhttp\s+40[13]\b")
178
+
179
+
180
+ def is_access_denied_gh_failure(stderr: str) -> bool:
181
+ """Whether a failed `gh` invocation's stderr says the token may not read the object.
182
+
183
+ A plan limitation is not (#289): its HTTP 403 names a paid plan, and
184
+ no token permission changes it.
185
+ """
186
+ if is_plan_limited_gh_failure(stderr):
187
+ return False
188
+ text = stderr.lower()
189
+ return any(m in text for m in _ACCESS_DENIED_MARKERS) or bool(
190
+ _ACCESS_DENIED_HTTP_STATUS.search(text)
191
+ )
192
+
193
+
194
+ # GitHub refusing a credential the check results of a PR (#291). GraphQL
195
+ # answers the CheckRun (or StatusContext) node inside ``statusCheckRollup``
196
+ # with ``Resource not accessible by personal access token (<path>)`` -- a
197
+ # fine-grained token on a private repository, which cannot be given Checks:
198
+ # read -- and `gh pr view` then fails the whole read. A GitHub App
199
+ # installation token without Checks: read gets the same refusal worded "by
200
+ # integration": the same fact (this credential may not read these check
201
+ # results, the PR itself is readable), so it is the same class. The match
202
+ # is narrow: the phrase must name one of those two credentials and the
203
+ # GraphQL path in parentheses after it must run through
204
+ # ``statusCheckRollup``. The same phrase on any other path (a comment, a
205
+ # label), or a bare ``HTTP 403``, is not a checks refusal and keeps its
206
+ # ordinary classification.
207
+ _CHECKS_ACCESS_DENIED = re.compile(
208
+ r"resource not accessible by (personal access token|integration)\s*"
209
+ r"\(([^()\n]*\bstatuscheckrollup\b[^()\n]*)\)",
210
+ re.IGNORECASE,
211
+ )
212
+
213
+
214
+ def checks_access_denial(stderr: str) -> str:
215
+ """The credential GitHub refused a PR's check results, or "" (#291).
216
+
217
+ ``personal access token`` or ``integration``, read from GitHub's own
218
+ refusal of a ``statusCheckRollup`` path; anything else is "".
219
+ """
220
+ match = _CHECKS_ACCESS_DENIED.search(stderr or "")
221
+ return match.group(1).lower() if match else ""
222
+
223
+
224
+ def is_checks_access_denied_gh_failure(stderr: str) -> bool:
225
+ """Whether a failed `gh` invocation's stderr is GitHub refusing a PR's check results."""
226
+ return bool(checks_access_denial(stderr))
227
+
228
+
229
+ def checks_access_remedy(credential: str) -> str:
230
+ """What lets the controller read check results, for the credential GitHub refused (#291).
231
+
232
+ The engine's BLOCKED reason and `doctor`'s checks row say the same.
233
+ """
234
+ if credential == "integration":
235
+ return (
236
+ "a GitHub App installation token needs Checks: read on this repository: grant "
237
+ "it, or run AutoForge with `gh auth login` (an OAuth login) or a classic token "
238
+ "with the `repo` scope"
239
+ )
240
+ return (
241
+ "a fine-grained personal access token cannot be given Checks: read, so on a "
242
+ "private repository whose pull requests have check runs run AutoForge with "
243
+ "`gh auth login` (an OAuth login) or a classic token with the `repo` scope"
244
+ )
245
+
246
+
247
+ # GitHub's plan does not offer the feature (#289): on GitHub Free a private
248
+ # repository has no rulesets and no classic branch protection, and GitHub
249
+ # answers those endpoints with ``HTTP 403 "Upgrade to GitHub Pro or make
250
+ # this repository public to enable this feature."`` to every caller, the
251
+ # owner's admin login included. That is about the plan, not about the
252
+ # token, and no permission changes it. Matched narrowly: GitHub's own
253
+ # sentence naming a paid plan (Pro, Team or Enterprise) *and* an HTTP 403;
254
+ # any other 403 is an ordinary access refusal.
255
+ _PLAN_LIMITED = re.compile(
256
+ r"\bupgrade to github (?:pro|team|enterprise)\b[^.\n]{0,80}?\bto enable this feature\b",
257
+ re.IGNORECASE,
258
+ )
259
+ _HTTP_403 = re.compile(r"\bhttp\s+403\b", re.IGNORECASE)
260
+
261
+
262
+ def is_plan_limited_gh_failure(stderr: str) -> bool:
263
+ """Whether a failed `gh` invocation's stderr is GitHub's plan-entitlement 403."""
264
+ text = stderr or ""
265
+ return bool(_PLAN_LIMITED.search(text)) and bool(_HTTP_403.search(text))
266
+
267
+
268
+ # The oldest `gh` the client works with. `gh api --paginate --slurp` (the
269
+ # page-of-pages listing behind every paginated read: branch rules in `doctor`,
270
+ # a PR's changed files at the merge gate) arrived in 2.48.0; an older `gh`
271
+ # rejects the flag, so those reads fail conclusively instead of paginating.
272
+ GH_MIN_VERSION = (2, 48, 0)
273
+ _GH_VERSION_RE = re.compile(r"^gh version (\d+)\.(\d+)\.(\d+)\b")
274
+
275
+
276
+ def parse_gh_version(text: str) -> tuple[int, int, int] | None:
277
+ """The ``X.Y.Z`` of a `gh --version` output's first line, or None if unparsable.
278
+
279
+ Only the shape `gh` prints (``gh version 2.48.0 (2024-04-09)``) is read;
280
+ anything else is "unknown", never a guess in either direction.
281
+ """
282
+ first = (text or "").strip().splitlines()
283
+ match = _GH_VERSION_RE.match(first[0]) if first else None
284
+ if match is None:
285
+ return None
286
+ major, minor, patch = (int(part) for part in match.groups())
287
+ return (major, minor, patch)
288
+
289
+
290
+ @dataclass
291
+ class RepoInfo:
292
+ name_with_owner: str
293
+ default_branch: str = ""
294
+
295
+
296
+ @dataclass
297
+ class IssueInfo:
298
+ url: str
299
+ number: int
300
+ title: str
301
+ state: str # OPEN | CLOSED
302
+ body: str = ""
303
+ repository: str = ""
304
+
305
+ @property
306
+ def is_open(self) -> bool:
307
+ return self.state == "OPEN"
308
+
309
+
310
+ _CHECK_OK_CONCLUSIONS = frozenset({"SUCCESS", "NEUTRAL", "SKIPPED"})
311
+ _CHECK_PENDING_STATES = frozenset(
312
+ {"QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED", "EXPECTED"}
313
+ )
314
+
315
+
316
+ @dataclass
317
+ class CheckInfo:
318
+ name: str
319
+ state: str # CheckRun: COMPLETED | IN_PROGRESS | ... ; StatusContext: SUCCESS | PENDING | ...
320
+ conclusion: str = "" # CheckRun only: SUCCESS | FAILURE | ...
321
+ # Where GitHub says the result came from. For a GitHub Actions check run
322
+ # this is ``https://github.com/<owner>/<repo>/actions/runs/<id>/job/<job>``
323
+ # and names the exact workflow run; the merge gate uses it to verify the
324
+ # *definition* that produced a green required check (see
325
+ # :func:`parse_actions_run_url`). Empty for a legacy commit status.
326
+ details_url: str = ""
327
+ # GitHub's ``__typename`` of the rollup entry: ``CheckRun`` (a check run,
328
+ # what a fine-grained token is refused on a private repository, #291) or
329
+ # ``StatusContext`` (a legacy commit status); "" when GitHub named none.
330
+ kind: str = ""
331
+
332
+ @property
333
+ def actions_run(self) -> ActionsRunRef | None:
334
+ return parse_actions_run_url(self.details_url)
335
+
336
+ @property
337
+ def outcome(self) -> str:
338
+ """``success`` | ``pending`` | ``failure`` | ``unknown`` (fail closed).
339
+
340
+ Covers both GitHub check runs (``status``/``conclusion``) and legacy
341
+ commit statuses (``state`` only). Anything unrecognised is ``unknown``
342
+ and callers must treat it as not passing.
343
+ """
344
+ state = (self.state or "").upper()
345
+ conclusion = (self.conclusion or "").upper()
346
+ if state == "COMPLETED":
347
+ if conclusion in _CHECK_OK_CONCLUSIONS:
348
+ return "success"
349
+ return "failure" if conclusion else "unknown"
350
+ if state in _CHECK_PENDING_STATES:
351
+ return "pending"
352
+ if state == "SUCCESS" and not conclusion:
353
+ return "success"
354
+ if state in ("FAILURE", "ERROR") and not conclusion:
355
+ return "failure"
356
+ return "unknown"
357
+
358
+ @property
359
+ def succeeded(self) -> bool:
360
+ """Whether this entry reports an actual success (#289).
361
+
362
+ A check run that completed with ``SUCCESS``, or a commit status in
363
+ state ``SUCCESS``. ``NEUTRAL`` and ``SKIPPED`` are an ``outcome`` of
364
+ ``success`` (they fail nothing) but not a success: the merge gate
365
+ needs at least one entry that is.
366
+ """
367
+ state = (self.state or "").upper()
368
+ conclusion = (self.conclusion or "").upper()
369
+ if state == "COMPLETED":
370
+ return conclusion == "SUCCESS"
371
+ return state == "SUCCESS" and not conclusion
372
+
373
+
374
+ @dataclass(frozen=True)
375
+ class ActionsRunRef:
376
+ """A GitHub Actions workflow run named by a check's details URL."""
377
+
378
+ repository: str # owner/repo, as written in the URL
379
+ run_id: int
380
+
381
+
382
+ _ACTIONS_RUN_URL = re.compile(
383
+ r"^https://github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/actions/runs/([1-9][0-9]*)(?:/.*)?$"
384
+ )
385
+
386
+
387
+ def parse_actions_run_url(url: str) -> ActionsRunRef | None:
388
+ """``https://github.com/<owner>/<repo>/actions/runs/<id>[/job/<n>...]`` or None.
389
+
390
+ Anything else (a legacy status target, an external CI dashboard, an
391
+ empty URL) is ``None``: the check did not come from a GitHub Actions run
392
+ the controller can read back, which is a fact for the caller to refuse
393
+ on, not a shape to guess around.
394
+ """
395
+ m = _ACTIONS_RUN_URL.match(url or "")
396
+ if not m:
397
+ return None
398
+ return ActionsRunRef(repository=f"{m.group(1)}/{m.group(2)}", run_id=int(m.group(3)))
399
+
400
+
401
+ @dataclass(frozen=True)
402
+ class WorkflowRunInfo:
403
+ """One GitHub Actions workflow run, as the REST API reports it."""
404
+
405
+ id: int
406
+ repository: str # owner/repo the run belongs to (where its workflow file lives)
407
+ path: str # workflow file, e.g. ``.github/workflows/ci.yml``
408
+ workflow_id: int
409
+ event: str # ``push`` | ``pull_request`` | ...
410
+ head_sha: str # lower-case; for ``pull_request`` runs the PR head, not the merge commit
411
+ head_branch: str
412
+ status: str # ``completed`` | ``in_progress`` | ``queued`` | ...
413
+ conclusion: str # ``success`` | ``failure`` | ``cancelled`` | ... ; "" while not completed
414
+ run_attempt: int = 1
415
+
416
+ @property
417
+ def completed(self) -> bool:
418
+ return self.status == "completed"
419
+
420
+
421
+ @dataclass(frozen=True)
422
+ class WorkflowJob:
423
+ """A job of a workflow run: its name and its step names in order.
424
+
425
+ Step names are what GitHub shows: a ``name:`` when the step has one,
426
+ otherwise ``Run <command>`` / ``Run <action>``, so an unnamed ``run:``
427
+ whose command changed is visible as a different step name.
428
+ """
429
+
430
+ name: str
431
+ steps: tuple[str, ...]
432
+
433
+
434
+ @dataclass(frozen=True)
435
+ class WorkflowRunJobs:
436
+ """The jobs of one run and GitHub's own count, so a short listing shows."""
437
+
438
+ jobs: tuple[WorkflowJob, ...]
439
+ total: int
440
+
441
+ @property
442
+ def complete(self) -> bool:
443
+ return len(self.jobs) == self.total
444
+
445
+ def structure(self) -> tuple[tuple[str, tuple[str, ...]], ...]:
446
+ """``((job name, step names), ...)`` sorted by job name.
447
+
448
+ GitHub lists jobs in no fixed order (matrix legs start in parallel),
449
+ so two runs of one definition compare equal only on the sorted shape.
450
+ """
451
+ return tuple(sorted((job.name, job.steps) for job in self.jobs))
452
+
453
+
454
+ @dataclass(frozen=True)
455
+ class WorkflowRuns:
456
+ """The runs one listing matched and GitHub's own count, so a short listing shows."""
457
+
458
+ runs: tuple[WorkflowRunInfo, ...]
459
+ total: int
460
+
461
+ @property
462
+ def complete(self) -> bool:
463
+ return len(self.runs) == self.total
464
+
465
+
466
+ @dataclass
467
+ class CommentInfo:
468
+ id: int
469
+ url: str
470
+ body: str
471
+ author: str = ""
472
+ created_at: str = ""
473
+ parent_url: str = "" # canonical issue/PR URL this comment belongs to
474
+
475
+
476
+ @dataclass
477
+ class PRInfo:
478
+ url: str
479
+ number: int
480
+ title: str
481
+ state: str # OPEN | CLOSED | MERGED
482
+ head_sha: str
483
+ base_ref: str = ""
484
+ head_ref: str = ""
485
+ mergeable: str = "" # MERGEABLE | CONFLICTING | UNKNOWN
486
+ # CLEAN | HAS_HOOKS | UNSTABLE | BLOCKED | BEHIND | DIRTY | DRAFT | UNKNOWN
487
+ merge_state_status: str = ""
488
+ auto_merge_enabled: bool = False # GitHub auto-merge is armed on this PR
489
+ is_draft: bool = False
490
+ body: str = ""
491
+ repository: str = ""
492
+ head_repository: str = ""
493
+ linked_issue_numbers: list[int] = field(default_factory=list)
494
+ # The check results of the PR's head as GitHub reported them, or None
495
+ # when this read did not request them (#291). Only
496
+ # :meth:`GitHubClient.get_pr_with_checks` fills it: ``[]`` is "read, and
497
+ # GitHub reports no check", a list is what was read. An identity read
498
+ # (:meth:`GitHubClient.get_pr`, every listing) leaves it None, so a
499
+ # caller that needs checks can never mistake "not read" (or "refused",
500
+ # which raises :class:`GitHubChecksAccessError`) for "no checks".
501
+ checks: list[CheckInfo] | None = None
502
+
503
+ @property
504
+ def is_open(self) -> bool:
505
+ return self.state == "OPEN"
506
+
507
+ @property
508
+ def checks_read(self) -> bool:
509
+ """Whether this PRInfo carries the check results GitHub reported (possibly none)."""
510
+ return self.checks is not None
511
+
512
+
513
+ @dataclass(frozen=True)
514
+ class CreatedObject:
515
+ """What GitHub's reply to a controller write says it wrote (ADR 0004 D12.2).
516
+
517
+ Informational only: a caller never treats it as evidence. The effect's
518
+ completion is the caller's read-back of the target by its identity
519
+ (D2.1), exactly as when the reply was lost.
520
+
521
+ ``url`` is canonical: an issue or PR URL, or for a comment the
522
+ ``...#issuecomment-<id>`` URL. ``number`` is the issue or PR number, and
523
+ ``None`` for a comment (its id is in the URL).
524
+ """
525
+
526
+ url: str
527
+ number: int | None
528
+
529
+
530
+ @dataclass(frozen=True)
531
+ class ChangedFile:
532
+ """One file a PR changes, with the path it had before a rename or copy.
533
+
534
+ A rename is reported by GitHub as a single changed file carrying both
535
+ ends, not as a delete plus an add, so ``previous_path`` is the only place
536
+ the path the PR *removed* appears.
537
+
538
+ A copy carries a former path too, and is deliberately not distinguished:
539
+ treating both ends as touched can at worst refuse something harmless,
540
+ while reading ``status`` to tell them apart would make a caller that
541
+ refuses depend on GitHub classifying the change the way it expects.
542
+ """
543
+
544
+ path: str
545
+ previous_path: str = ""
546
+
547
+ @property
548
+ def paths(self) -> tuple[str, ...]:
549
+ """Every path this change touches -- both ends of a rename."""
550
+ if self.previous_path and self.previous_path != self.path:
551
+ return (self.path, self.previous_path)
552
+ return (self.path,)
553
+
554
+
555
+ @dataclass(frozen=True)
556
+ class ChangedFiles:
557
+ """The files a PR touches, and whether that listing is provably complete.
558
+
559
+ Every page of the listing is read, but GitHub serves at most 3000 files
560
+ of a PR through the files endpoint, so a listing shorter than
561
+ ``total`` says nothing about the files it does not contain. A caller that
562
+ uses this to *refuse* something must fail closed when :attr:`complete` is
563
+ false rather than read absence as proof.
564
+ """
565
+
566
+ files: tuple[ChangedFile, ...]
567
+ total: int
568
+
569
+ @property
570
+ def paths(self) -> tuple[str, ...]:
571
+ """Every path the PR touches, the former path of a rename included."""
572
+ return tuple(path for file in self.files for path in file.paths)
573
+
574
+ @property
575
+ def complete(self) -> bool:
576
+ # Counted in *files*, never in paths: a rename is one file and two
577
+ # paths, and GitHub's ``changedFiles`` counts files.
578
+ return len(self.files) >= self.total
579
+
580
+
581
+ @dataclass(frozen=True)
582
+ class MergeQueueStatus:
583
+ """Merge-queue facts for a PR (GraphQL only; not exposed by ``gh pr view``)."""
584
+
585
+ enabled: bool # the base branch requires a merge queue
586
+ in_queue: bool # the PR is currently enqueued
587
+
588
+
589
+ @dataclass(frozen=True)
590
+ class RequiredStatusChecksRule:
591
+ """One ``required_status_checks`` rule in effect on a branch, and where it came from.
592
+
593
+ Read from ``GET /repos/{owner}/{repo}/rules/branches/{branch}``, which
594
+ lists the rules of every *active* ruleset that applies to the branch --
595
+ repository and organization rulesets alike -- and nothing from a
596
+ disabled or evaluate-only ruleset, nor from classic branch protection.
597
+ """
598
+
599
+ contexts: tuple[str, ...]
600
+ ruleset_id: int
601
+ ruleset_source: str = "" # "owner/repo" or the organization name
602
+ ruleset_source_type: str = "" # Repository | Organization
603
+ strict: bool = False # strict_required_status_checks_policy
604
+
605
+
606
+ @dataclass(frozen=True)
607
+ class RulesetInfo:
608
+ """A ruleset as GitHub describes it (``GET /repos/{owner}/{repo}/rulesets[/{id}]``).
609
+
610
+ ``bypass_actors`` is ``None`` when GitHub did not return the field, which
611
+ it withholds from any token without write access to the ruleset -- a
612
+ read-only token gets a 200 with the field absent -- and which the listing
613
+ endpoint never includes. Absent is not empty: ``()`` is GitHub saying
614
+ nobody may bypass, ``None`` is GitHub declining to say.
615
+ ``current_user_can_bypass`` is returned to every caller of the
616
+ single-ruleset read.
617
+ """
618
+
619
+ id: int
620
+ name: str
621
+ enforcement: str # active | evaluate | disabled
622
+ target: str = "" # branch | tag | push
623
+ source: str = ""
624
+ source_type: str = ""
625
+ bypass_actors: tuple[str, ...] | None = None # "<actor_type> <actor_id> (<bypass_mode>)"
626
+ current_user_can_bypass: str = "" # always | pull_requests_only | never
627
+ html_url: str = ""
628
+
629
+ @property
630
+ def is_active(self) -> bool:
631
+ return self.enforcement == "active"
632
+
633
+
634
+ @dataclass(frozen=True)
635
+ class ClassicBranchProtection:
636
+ """Classic branch protection (``GET /repos/{owner}/{repo}/branches/{branch}/protection``).
637
+
638
+ The pre-ruleset mechanism. ``required_contexts`` is empty when the
639
+ protection exists but requires no status check; ``enforce_admins`` is the
640
+ classic analogue of "no bypass actors".
641
+ """
642
+
643
+ required_status_checks: bool
644
+ required_contexts: tuple[str, ...]
645
+ enforce_admins: bool
646
+
647
+
648
+ Runner = Callable[[ExecutionRequest], ExecutionResult]
649
+
650
+ # -- where `gh` runs (#283) -------------------------------------------------------
651
+ # `gh` runs git in its own working directory for some commands. gh 2.103.2's
652
+ # `gh pr merge` asks git whether the PR's head branch exists locally on every
653
+ # merge, and with `--delete-branch` it may check out the base branch, pull,
654
+ # prune or remove a worktree, and then deletes the local branch
655
+ # (pkg/cmd/pr/merge/merge.go; its git client sets no repository directory, so
656
+ # git runs where `gh` was started). Started in the operator's checkout, that
657
+ # git reads the shared repository's configuration and runs its hooks, which an
658
+ # agent can write, with the controller's credential in the environment. So
659
+ # every `gh` the controller starts runs in a fresh, empty directory of its
660
+ # own, in the temporary directory, and with ``GIT_DIR`` naming that same
661
+ # directory: it is no git directory, so a git that `gh` starts fails instead
662
+ # of finding a repository, whatever the controller's own environment says (an
663
+ # inherited ``GIT_DIR``, a ``TMPDIR`` inside a checkout). Nothing the
664
+ # controller asks of `gh` needs a local repository: every call names its
665
+ # repository, PR or issue explicitly.
666
+ GH_WORKDIR_PREFIX = "autoforge-gh-"
667
+
668
+
669
+ @contextmanager
670
+ def gh_workdir() -> Iterator[str]:
671
+ """A fresh empty directory for one `gh` process, removed once it has exited (#283)."""
672
+ try:
673
+ path = tempfile.mkdtemp(prefix=GH_WORKDIR_PREFIX)
674
+ except OSError as exc:
675
+ raise GitHubError(f"cannot create an empty working directory for gh: {exc}") from exc
676
+ try:
677
+ yield path
678
+ finally:
679
+ shutil.rmtree(path, ignore_errors=True)
680
+
681
+
682
+ def gh_request(
683
+ command: Sequence[str],
684
+ workdir: str,
685
+ *,
686
+ timeout_seconds: int,
687
+ stdin: bytes | None = None,
688
+ ) -> ExecutionRequest:
689
+ """The request that runs ``command``, a `gh` argv, in ``workdir`` (:func:`gh_workdir`).
690
+
691
+ ``command`` still names what it named from the controller's own directory,
692
+ now that the child starts elsewhere: a relative path is made absolute, and
693
+ so is each relative ``PATH`` entry (:func:`_anchored_search_path`), where a
694
+ bare name, and every program `gh` itself starts, is looked up.
695
+ """
696
+ executable = command[0]
697
+ if os.sep in executable and not os.path.isabs(executable):
698
+ executable = os.path.join(os.getcwd(), executable)
699
+ env = {"GIT_DIR": workdir}
700
+ search_path = _anchored_search_path(os.environ.get("PATH"))
701
+ if search_path is not None:
702
+ env["PATH"] = search_path
703
+ return ExecutionRequest(
704
+ command=[executable, *command[1:]],
705
+ cwd=workdir,
706
+ env=env,
707
+ timeout_seconds=timeout_seconds,
708
+ stdin_data=stdin,
709
+ )
710
+
711
+
712
+ def _anchored_search_path(path: str | None) -> str | None:
713
+ """``path``, a ``PATH`` value, each relative entry made absolute from this directory.
714
+
715
+ The empty entry, like ``.``, is the current directory, as exec's lookup
716
+ reads it. Left relative, an entry would be looked up from the empty
717
+ directory a `gh` starts in: ``bin`` would no longer find what it found,
718
+ and ``..`` would reach the shared temporary directory that one was made
719
+ in. None when ``PATH`` is unset or every entry is absolute, since the
720
+ child's lookup then does not depend on its directory. Do not normalize
721
+ ``..``: the kernel must follow any preceding symlink before resolving it.
722
+ """
723
+ if path is None:
724
+ return None
725
+ entries = path.split(os.pathsep)
726
+ if all(os.path.isabs(entry) for entry in entries):
727
+ return None
728
+ cwd = os.getcwd()
729
+ return os.pathsep.join(
730
+ entry if os.path.isabs(entry) else os.path.join(cwd, entry) if entry else cwd
731
+ for entry in entries
732
+ )
733
+
734
+
735
+ # -- the exact read -------------------------------------------------------------
736
+ # `gh` rewrites every JSON reply before printing it (go-gh's asciisanitizer on
737
+ # the HTTP transport, which no flag turns off): a C0 or C1 control character
738
+ # and its JSON escape become caret notation, and so does the *literal text* of
739
+ # an escape, the six characters `\u001b` written in a body, which the reply
740
+ # carries as `\\u001b` and `gh` prints as `\^[`. The rewrite is not reversible
741
+ # (`\^[` is also what the literal text `\^[` reads as), so no comparison of
742
+ # what `gh` printed can prove that a body is a given text. An effect's
743
+ # completion read-back needs that proof; the exact read gives it, by reading
744
+ # one object with GitHub's REST API directly, under the credential `gh` holds.
745
+ # What it reads is compared, never printed.
746
+ EXACT_READ_ROOT = "https://api.github.com/"
747
+ # A PR, issue or comment object: 65536 characters of body, each at most 12
748
+ # bytes as a JSON escape, plus the rest of the object, well inside this.
749
+ EXACT_READ_MAX_BYTES = 1 << 21
750
+ _EXACT_READ_HEADERS = (
751
+ ("Accept", "application/vnd.github+json"),
752
+ ("X-GitHub-Api-Version", "2022-11-28"),
753
+ ("User-Agent", "autoforge"),
754
+ )
755
+ _JSON_CONTENT_TYPE_RE = re.compile(r"^application/json\s*(?:;|$)", re.IGNORECASE)
756
+ # What `gh auth token` may print: visible ASCII, so it can never end the
757
+ # Authorization header and start another.
758
+ _API_TOKEN_RE = re.compile(r"[\x21-\x7e]{1,4096}")
759
+
760
+
761
+ @dataclass(frozen=True)
762
+ class HttpsRequest:
763
+ """One GET of the exact read. ``headers`` carries the credential: never in a repr."""
764
+
765
+ url: str
766
+ headers: tuple[tuple[str, str], ...] = field(repr=False)
767
+ timeout_seconds: float
768
+ max_bytes: int
769
+
770
+
771
+ @dataclass(frozen=True)
772
+ class HttpsReply:
773
+ """What GitHub answered: the status, the headers (names lower-cased) and the body.
774
+
775
+ ``body`` is at most ``max_bytes + 1`` bytes, so a longer one is seen as
776
+ too long without being read whole. For a status that is not 2xx it is at
777
+ most :data:`EXACT_READ_ERROR_BYTES`, read only to tell a rate limit from
778
+ a refusal and never quoted.
779
+ """
780
+
781
+ status: int
782
+ headers: dict[str, str]
783
+ body: bytes = field(repr=False)
784
+
785
+
786
+ HttpsGet = Callable[[HttpsRequest], HttpsReply]
787
+ # What is read of an error reply: enough for GitHub's JSON message.
788
+ EXACT_READ_ERROR_BYTES = 4096
789
+
790
+
791
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
792
+ """A 3xx is the answer, never followed: a redirect would carry the credential elsewhere."""
793
+
794
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
795
+ return None
796
+
797
+
798
+ class _DeadlineReader(io.RawIOBase):
799
+ """The raw reader of one reply: no receive on ``sock`` outlasts ``deadline``.
800
+
801
+ ``http.client`` reads everything of a reply through one buffered reader
802
+ over this: the status line, the headers, a chunked body's framing and its
803
+ trailers, and the body. One of its calls can take many receives (a line,
804
+ a chunk's size, the trailers after the last chunk), and a socket timeout
805
+ bounds each receive alone, so a server sending a byte at a time inside it
806
+ would never trip it. Each receive here waits at most until the deadline,
807
+ and none starts after it, whatever call it is part of.
808
+ """
809
+
810
+ def __init__(self, sock, deadline: float, timeout: float) -> None:
811
+ super().__init__()
812
+ self._sock = sock
813
+ self._raw = sock.makefile("rb", buffering=0)
814
+ self._deadline = deadline
815
+ self._timeout = timeout
816
+
817
+ def readable(self) -> bool:
818
+ return True
819
+
820
+ def readinto(self, buffer):
821
+ remaining = self._deadline - time.monotonic()
822
+ try:
823
+ if remaining <= 0:
824
+ raise TimeoutError
825
+ self._sock.settimeout(remaining)
826
+ return self._raw.readinto(buffer)
827
+ except TimeoutError: # the deadline has passed, or this receive reached it
828
+ raise TimeoutError(f"the reply took longer than {self._timeout:g}s") from None
829
+
830
+ def close(self) -> None:
831
+ if not self.closed:
832
+ self._raw.close()
833
+ super().close()
834
+
835
+
836
+ class _DeadlineResponse(http.client.HTTPResponse):
837
+ """A reply read through a :class:`_DeadlineReader` instead of the socket's own file."""
838
+
839
+ def __init__(self, sock, *args, deadline, timeout, **kwargs):
840
+ super().__init__(sock, *args, **kwargs)
841
+ self.fp.close() # nothing has been read from it
842
+ self.fp = io.BufferedReader(_DeadlineReader(sock, deadline, timeout))
843
+
844
+
845
+ class _DeadlineHandler(urllib.request.HTTPHandler, urllib.request.HTTPSHandler):
846
+ """Opens the request so that every reply on its connection is read by one deadline.
847
+
848
+ That is the request's reply, and a proxy's answer to the tunnel opened
849
+ for it. One handler serves both schemes, so the transport's tests on a
850
+ loopback ``http`` server read a reply as the read of GitHub does.
851
+ """
852
+
853
+ def __init__(self, context: ssl.SSLContext, deadline: float, timeout: float) -> None:
854
+ super().__init__(context=context)
855
+ self._response = functools.partial(_DeadlineResponse, deadline=deadline, timeout=timeout)
856
+
857
+ def do_open(self, http_class, req, **http_conn_args):
858
+ def connection(*args, **kwargs):
859
+ opened = http_class(*args, **kwargs)
860
+ opened.response_class = self._response
861
+ return opened
862
+
863
+ return super().do_open(connection, req, **http_conn_args)
864
+
865
+
866
+ def _read_bounded(stream, limit: int) -> bytes:
867
+ """Up to ``limit`` bytes of ``stream``."""
868
+ body = bytearray()
869
+ while len(body) < limit:
870
+ chunk = stream.read1(min(1 << 16, limit - len(body)))
871
+ if not chunk:
872
+ break
873
+ body += chunk
874
+ return bytes(body)
875
+
876
+
877
+ def _tls_context() -> ssl.SSLContext:
878
+ """A client context that verifies the certificate chain and the host name.
879
+
880
+ ``ssl.create_default_context`` with the system's trust store, without its
881
+ ``SSLKEYLOGFILE`` handling: that would write the session secrets of the
882
+ connection carrying the credential to a file (and fail every read when
883
+ the variable names an unwritable path). ``PROTOCOL_TLS_CLIENT`` sets
884
+ ``CERT_REQUIRED`` and ``check_hostname``.
885
+ """
886
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
887
+ context.load_default_certs(ssl.Purpose.SERVER_AUTH)
888
+ return context
889
+
890
+
891
+ def https_get(request: HttpsRequest) -> HttpsReply:
892
+ """The exact read's transport: one GET, the certificate verified, no redirect followed.
893
+
894
+ Connecting (the TCP connection, the TLS handshake, sending the request)
895
+ is bounded by ``timeout_seconds`` on each socket operation. The reply is
896
+ read in all within ``timeout_seconds`` of the call: its status line,
897
+ headers, a chunked body's framing and trailers, and its body
898
+ (:class:`_DeadlineReader`), so a server that trickles any of it cannot
899
+ hold the controller. The body is read up to ``max_bytes + 1`` bytes. A
900
+ status that is not 2xx is returned for the client to classify, with at
901
+ most :data:`EXACT_READ_ERROR_BYTES` of its body. Connection and TLS
902
+ failures, and the deadline, raise as ``urllib`` raises them (an
903
+ ``OSError``). The TLS context verifies the chain and the host name
904
+ (:func:`_tls_context`). The proxy variables
905
+ apply, as they do to `gh`; HTTPS through a proxy is a tunnel, so the
906
+ certificate verified is still GitHub's.
907
+ """
908
+ timeout = request.timeout_seconds
909
+ deadline = time.monotonic() + timeout
910
+ opener = urllib.request.build_opener(
911
+ _NoRedirect(), _DeadlineHandler(_tls_context(), deadline, timeout)
912
+ )
913
+ req = urllib.request.Request(request.url, headers=dict(request.headers), method="GET")
914
+ try:
915
+ with opener.open(req, timeout=timeout) as resp:
916
+ headers = {name.lower(): value for name, value in resp.headers.items()}
917
+ body = _read_bounded(resp, request.max_bytes + 1)
918
+ return HttpsReply(resp.status, headers, body)
919
+ except urllib.error.HTTPError as exc:
920
+ with exc:
921
+ headers = {name.lower(): value for name, value in (exc.headers or {}).items()}
922
+ limit = min(EXACT_READ_ERROR_BYTES, request.max_bytes)
923
+ body = _read_bounded(exc, limit) if exc.fp is not None else b""
924
+ return HttpsReply(exc.code, headers, body)
925
+
926
+
927
+ def _exact_transport_failure(exc: Exception, what: str) -> GitHubError:
928
+ """What a failure the exact read's transport raised means; no reply was read.
929
+
930
+ A certificate that does not verify is conclusive (whoever answered is
931
+ not known to be GitHub); a connection that failed or broke is
932
+ inconclusive, as `gh`'s own are; anything else is conclusive.
933
+ """
934
+ reason = exc.reason if isinstance(exc, urllib.error.URLError) else exc
935
+ if isinstance(reason, ssl.SSLCertVerificationError):
936
+ return GitHubError(
937
+ f"{what}: the TLS certificate did not verify ({redact(str(reason)):.200})"
938
+ )
939
+ detail = f"{type(exc).__name__}: {redact(str(exc)):.200}"
940
+ if isinstance(exc, (OSError, http.client.HTTPException)):
941
+ return GitHubUnavailableError(f"{what}: GitHub could not be read ({detail})")
942
+ return GitHubError(f"{what}: the exact read failed ({detail})")
943
+
944
+
945
+ # The PR identity read (:meth:`GitHubClient.get_pr`): everything a caller
946
+ # binds a PR by, and no check results. GitHub refuses a fine-grained token
947
+ # the check runs of a private PR and `gh` then fails the *whole* view
948
+ # (#291), so a read that does not need checks never asks for them.
949
+ _PR_FIELDS = (
950
+ "url,number,title,state,headRefOid,baseRefName,headRefName,mergeable,mergeStateStatus,"
951
+ "autoMergeRequest,isDraft,body,headRepository,headRepositoryOwner,"
952
+ "closingIssuesReferences"
953
+ )
954
+ # The checks read (:meth:`GitHubClient.get_pr_with_checks`): the same fields
955
+ # and the head's check results, in one GraphQL answer, so the checks a
956
+ # caller judges are those of the HEAD it judged them with.
957
+ _PR_CHECKS_FIELDS = f"{_PR_FIELDS},statusCheckRollup"
958
+ # The open PRs `doctor` may probe the checks read on (#291): the most
959
+ # recently updated ones, each with the number of check results on its head
960
+ # commit. ``contexts.totalCount`` is returned even to a token GitHub
961
+ # refuses the check-run nodes themselves, so it selects a candidate without
962
+ # being the read it tests.
963
+ _CHECKS_PROBE_PAGE_SIZE = 20
964
+ _CHECKS_PROBE_QUERY = (
965
+ "query($owner: String!, $name: String!) {"
966
+ " repository(owner: $owner, name: $name) {"
967
+ f" pullRequests(first: {_CHECKS_PROBE_PAGE_SIZE}, states: [OPEN],"
968
+ " orderBy: {field: UPDATED_AT, direction: DESC}) {"
969
+ " nodes { url number commits(last: 1) { nodes { commit { oid"
970
+ " statusCheckRollup { contexts { totalCount } } } } } } } } }"
971
+ )
972
+ # Page size of a PR's changed-files listing; every page is read. GitHub stops
973
+ # the endpoint at 3000 files whatever the paging, so a listing that does not
974
+ # reach GitHub's own ``changedFiles`` count is reported as incomplete and the
975
+ # merge gate fails closed on it, which is the honest answer for a PR too
976
+ # large for the controller to prove anything about.
977
+ _PR_FILES_PAGE_SIZE = 100
978
+ # Page size of the issue-event listing behind the replan close watermark.
979
+ _EVENTS_PAGE_SIZE = 100
980
+ # Page sizes of the Actions listings behind the check-definition gate.
981
+ _JOBS_PAGE_SIZE = 100
982
+ _RUNS_PAGE_SIZE = 100
983
+ _MERGE_QUEUE_QUERY = (
984
+ "query($owner: String!, $name: String!, $number: Int!) {"
985
+ " repository(owner: $owner, name: $name) {"
986
+ " pullRequest(number: $number) { isMergeQueueEnabled isInMergeQueue } } }"
987
+ )
988
+ # The highest PR number is read as a proven numeric maximum over every PR
989
+ # state, never inferred from one CREATED_AT-ordered node: creation-time
990
+ # ordering has no numeric tie-breaker, so same-second PRs can return a lower
991
+ # number first and let a higher-numbered pre-existing PR slip past the
992
+ # watermark. Listing `--state all` and taking the maximum covers open,
993
+ # closed and merged PRs alike; a listing that reaches the ceiling is refused
994
+ # rather than reported as a maximum.
995
+ _PR_NUMBER_LIST_FIELDS = "number"
996
+ _PR_LIST_FIELDS = (
997
+ "url,number,title,state,headRefOid,baseRefName,headRefName,isDraft,body,"
998
+ "headRepository,headRepositoryOwner,closingIssuesReferences"
999
+ )
1000
+ # One page of the open-PR listing, read by cursor until GitHub reports no
1001
+ # further page (issue #20). `gh pr list --limit N` reads at most N rows and
1002
+ # says nothing about the rest, so a strict caller had to refuse a listing
1003
+ # that reached its ceiling; a cursor walk has no ceiling to reach. The node
1004
+ # fields are the ones `gh pr list --json` would return under the same names,
1005
+ # so the same decoder reads both; ``closingIssuesReferences`` is the one
1006
+ # connection `gh` flattens, and :meth:`GitHubClient.list_open_prs` flattens
1007
+ # it the same way. Creation order, ascending, keeps the walk stable: a PR
1008
+ # opened while it runs lands at the end, not inside a page already read.
1009
+ _PR_PAGE_SIZE = 100
1010
+ _OPEN_PR_PAGE_QUERY = (
1011
+ "query($owner: String!, $name: String!, $after: String) {"
1012
+ " repository(owner: $owner, name: $name) {"
1013
+ f" pullRequests(first: {_PR_PAGE_SIZE}, states: [OPEN], after: $after,"
1014
+ " orderBy: {field: CREATED_AT, direction: ASC}) {"
1015
+ " pageInfo { hasNextPage endCursor }"
1016
+ " nodes { url number title state headRefOid baseRefName headRefName isDraft body"
1017
+ " headRepository { name } headRepositoryOwner { login }"
1018
+ " closingIssuesReferences(first: 100) { nodes { number } } } } } }"
1019
+ )
1020
+ # One page of an issue's or a PR's conversation comments, read by cursor until
1021
+ # GitHub reports no further page. `gh issue view --json comments` and `gh pr
1022
+ # view --json comments` read `comments(first: 100)` and never a second page,
1023
+ # so a comment past the hundredth was reported as absent to every identity
1024
+ # read built on them (K4, K8, the replan close receipt). The node fields are
1025
+ # the ones those views returned under the same names, so the same decoder
1026
+ # reads both. The connection is in creation order and a cursor names a
1027
+ # comment (its database id), not an offset: a comment posted while the walk
1028
+ # runs lands at the end, and one deleted does not shift the pages after it.
1029
+ _COMMENT_PAGE_SIZE = 100
1030
+
1031
+
1032
+ def _comment_page_query(parent: str) -> str:
1033
+ """The comment-page query of ``parent``, ``issue`` or ``pullRequest``."""
1034
+ return (
1035
+ "query($owner: String!, $name: String!, $number: Int!, $after: String) {"
1036
+ " repository(owner: $owner, name: $name) {"
1037
+ f" {parent}(number: $number) {{"
1038
+ f" comments(first: {_COMMENT_PAGE_SIZE}, after: $after) {{"
1039
+ " pageInfo { hasNextPage endCursor }"
1040
+ " nodes { url body createdAt author { login } } } } } }"
1041
+ )
1042
+
1043
+
1044
+ _ISSUE_COMMENT_PAGE_QUERY = _comment_page_query("issue")
1045
+ _PR_COMMENT_PAGE_QUERY = _comment_page_query("pullRequest")
1046
+
1047
+
1048
+ # -- strict row decoding ----------------------------------------------------------
1049
+ #
1050
+ # One decoder per object kind, used by every view and every listing. A row
1051
+ # that is not the object we asked for is a conclusive GitHubError, never an
1052
+ # object with a manufactured identity (``url=""``, ``number=0``, ``id=0``):
1053
+ # such an object matches nothing and is invisible to every later comparison,
1054
+ # so a listing containing it would prove "absent" for something that exists.
1055
+ # The identity of a row is its ``url`` parsed with the typed parser; a
1056
+ # ``number`` the row also carries must agree with it.
1057
+
1058
+
1059
+ def _row_object(row: object, what: str) -> dict:
1060
+ if not isinstance(row, dict):
1061
+ raise GitHubError(f"{what}: `gh` returned a non-object entry ({type(row).__name__})")
1062
+ return row
1063
+
1064
+
1065
+ def _linked_issue_nodes(connection: object, what: str) -> list[dict]:
1066
+ """The ``nodes`` of a GraphQL ``closingIssuesReferences`` connection.
1067
+
1068
+ Read like every other field: ``null``/absent is "no linked issue", any
1069
+ other type than the connection object, its ``nodes`` list and a node
1070
+ carrying an issue ``number`` is a conclusive GitHubError. Silently
1071
+ dropping such a node would make a PR that closes an issue look unlinked
1072
+ to it. (GitHub nulls the connection only alongside ``errors``, which the
1073
+ page reader rejects first.)
1074
+ """
1075
+ if connection is None:
1076
+ return []
1077
+ if not isinstance(connection, dict):
1078
+ raise GitHubError(
1079
+ f"{what}: closingIssuesReferences is not a connection: {connection!r:.200}"
1080
+ )
1081
+ nodes = connection.get("nodes")
1082
+ if nodes is None:
1083
+ return []
1084
+ if not isinstance(nodes, list):
1085
+ raise GitHubError(f"{what}: closingIssuesReferences.nodes is not a list: {nodes!r:.200}")
1086
+ for node in nodes:
1087
+ number = node.get("number") if isinstance(node, dict) else None
1088
+ if isinstance(number, bool) or not isinstance(number, int) or number <= 0:
1089
+ raise GitHubError(f"{what}: a linked issue has no issue number: {node!r:.200}")
1090
+ return nodes
1091
+
1092
+
1093
+ def _rollup_checks(row: dict, what: str) -> list[CheckInfo] | None:
1094
+ """The check results of a PR row, or None when the row did not carry them (#291).
1095
+
1096
+ Absent: the read did not request ``statusCheckRollup`` (an identity read
1097
+ or a listing), which says nothing about the checks. ``null`` or ``[]``:
1098
+ read, and GitHub reports none. Any other value than a list of objects is
1099
+ a conclusive GitHubError: an entry dropped as unreadable could be the one
1100
+ failing check, so a malformed rollup is never read as a shorter one.
1101
+ """
1102
+ if "statusCheckRollup" not in row:
1103
+ return None
1104
+ rollup = row.get("statusCheckRollup")
1105
+ if rollup is None:
1106
+ return []
1107
+ if not isinstance(rollup, list):
1108
+ raise GitHubError(f"{what}: statusCheckRollup is not a list: {rollup!r:.200}")
1109
+ checks: list[CheckInfo] = []
1110
+ for c in rollup:
1111
+ if not isinstance(c, dict):
1112
+ raise GitHubError(f"{what}: a status check is not an object: {c!r:.200}")
1113
+ details = c.get("detailsUrl") or c.get("targetUrl") or ""
1114
+ kind = c.get("__typename")
1115
+ checks.append(
1116
+ CheckInfo(
1117
+ name=str(c.get("name", "") or c.get("context", "")),
1118
+ state=str(c.get("status", "") or c.get("state", "")).upper(),
1119
+ conclusion=str(c.get("conclusion", "")).upper(),
1120
+ details_url=details if isinstance(details, str) else "",
1121
+ kind=kind if isinstance(kind, str) else "",
1122
+ )
1123
+ )
1124
+ return checks
1125
+
1126
+
1127
+ def _text_field(row: dict, key: str, what: str) -> str:
1128
+ """A string field, ``null``/absent read as empty; any other type is malformed."""
1129
+ value = row.get(key)
1130
+ if value is None:
1131
+ return ""
1132
+ if not isinstance(value, str):
1133
+ raise GitHubError(f"{what}: field {key!r} is {type(value).__name__}, expected a string")
1134
+ return value
1135
+
1136
+
1137
+ def _number_field(row: dict, key: str, what: str, expected: int) -> int:
1138
+ value = row.get(key)
1139
+ if not isinstance(value, int) or isinstance(value, bool):
1140
+ raise GitHubError(f"{what}: field {key!r} is not an integer")
1141
+ if value != expected:
1142
+ raise GitHubError(f"{what}: field {key!r} is {value} but the url names #{expected}")
1143
+ return value
1144
+
1145
+
1146
+ def _decode_issue(row: object, what: str) -> IssueInfo:
1147
+ data = _row_object(row, what)
1148
+ url = _text_field(data, "url", what)
1149
+ try:
1150
+ ref = parse_issue_url(url)
1151
+ except ConfigurationError as exc:
1152
+ raise GitHubError(f"{what}: url {url!r} is not a GitHub issue URL ({exc})") from exc
1153
+ return IssueInfo(
1154
+ url=ref.canonical,
1155
+ number=_number_field(data, "number", what, ref.number),
1156
+ title=_text_field(data, "title", what),
1157
+ state=_text_field(data, "state", what).upper(),
1158
+ body=_text_field(data, "body", what),
1159
+ repository=ref.repository,
1160
+ )
1161
+
1162
+
1163
+ def _decode_rest_comment(row: object, requested: GitHubCommentRef) -> CommentInfo:
1164
+ """One issue-style comment as the REST API returns it (``gh api``).
1165
+
1166
+ The REST row spells its fields differently from the ``gh pr view``
1167
+ rows (``html_url``, ``user.login``, ``created_at``) but is held to the
1168
+ same rule: the URL must parse as a comment URL and its ``id`` must be the
1169
+ comment the URL names. The merge gate re-reads the round's review
1170
+ comment through this, so a row whose identity cannot be read is a
1171
+ conclusive GitHubError, never a comment with a borrowed URL that the
1172
+ gate could mistake for the one it asked about.
1173
+ """
1174
+ what = f"comment {requested.canonical}"
1175
+ data = _row_object(row, what)
1176
+ url = _text_field(data, "html_url", what)
1177
+ try:
1178
+ ref = parse_comment_url(url)
1179
+ except ConfigurationError as exc:
1180
+ raise GitHubError(f"{what}: html_url {url!r} is not a GitHub comment URL ({exc})") from exc
1181
+ user = data.get("user")
1182
+ login = _text_field(user, "login", what) if isinstance(user, dict) else ""
1183
+ return CommentInfo(
1184
+ id=_number_field(data, "id", what, ref.comment_id),
1185
+ url=ref.canonical,
1186
+ body=_text_field(data, "body", what),
1187
+ author=login,
1188
+ created_at=_text_field(data, "created_at", what),
1189
+ parent_url=ref.parent.canonical,
1190
+ )
1191
+
1192
+
1193
+ def _decode_comment(row: object, parent_url: str) -> CommentInfo:
1194
+ what = f"comment on {parent_url}"
1195
+ data = _row_object(row, what)
1196
+ url = _text_field(data, "url", what)
1197
+ try:
1198
+ ref = parse_comment_url(url)
1199
+ except ConfigurationError as exc:
1200
+ raise GitHubError(f"{what}: url {url!r} is not a GitHub comment URL ({exc})") from exc
1201
+ author = data.get("author")
1202
+ login = _text_field(author, "login", what) if isinstance(author, dict) else ""
1203
+ return CommentInfo(
1204
+ id=ref.comment_id,
1205
+ url=ref.canonical,
1206
+ body=_text_field(data, "body", what),
1207
+ author=login,
1208
+ created_at=_text_field(data, "createdAt", what),
1209
+ parent_url=parent_url,
1210
+ )
1211
+
1212
+
1213
+ # `gh pr list` paginates internally to satisfy --limit; this is the ceiling a
1214
+ # strict caller is willing to read before declaring the set unknowable. The
1215
+ # open-PR listing walks cursors instead and has no ceiling (issue #20); the
1216
+ # all-states listing and the PR-number watermark still read up to this many.
1217
+ STRICT_PR_LIST_LIMIT = 1000
1218
+ # The same ceiling for `gh issue list`: a FIX entry reads every open issue to
1219
+ # find the follow-up issues an earlier fixer created.
1220
+ STRICT_ISSUE_LIST_LIMIT = 1000
1221
+ _ISSUE_LIST_FIELDS = "url,number,title,state,body"
1222
+
1223
+ MERGE_METHODS = ("squash", "merge", "rebase")
1224
+ _SHA40_RE = re.compile(r"^[0-9a-f]{40}$")
1225
+ # A full object id in either object format (SHA-1 or SHA-256), lower case.
1226
+ _FULL_SHA_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
1227
+
1228
+ # GitHub's own limits on what a typed write may publish (ADR 0004 D2.4): an
1229
+ # issue or PR body and a comment are refused above 65,536 characters (HTTP
1230
+ # 422, "body is too long"), a title above 256. A payload past either is
1231
+ # refused before any process runs, never sent to collect the 422.
1232
+ MAX_BODY_CHARS = 65536
1233
+ MAX_TITLE_CHARS = 256
1234
+
1235
+ # The newest-first all-states issue listing read down to the K5 number
1236
+ # watermark (:meth:`GitHubClient.list_issues_above`): one page per read, and a
1237
+ # bound on how many pages one walk may read, so a listing that cannot be read
1238
+ # down to the watermark is refused rather than read forever.
1239
+ _ISSUE_PAGE_SIZE = 100
1240
+ _ISSUE_WALK_MAX_PAGES = 100
1241
+ # Page size of the head-filtered PR listing (:meth:`GitHubClient.list_prs_for_head`).
1242
+ _HEAD_PR_PAGE_SIZE = 100
1243
+
1244
+ # The answers of the compare endpoint (base...commit) that put the commit in
1245
+ # the base's history: it is ahead of the base by no commit (D7.5).
1246
+ _IN_HISTORY_STATUSES = frozenset({"behind", "identical"})
1247
+ _NOT_IN_HISTORY_STATUSES = frozenset({"ahead", "diverged"})
1248
+
1249
+ _GITHUB_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
1250
+
1251
+
1252
+ def _repository_path(repository: str) -> str:
1253
+ """``owner/repo`` as it goes into a REST path (ADR 0004 D7.4).
1254
+
1255
+ Exactly two GitHub name segments, neither made of dots alone: the path
1256
+ must name the repository and nothing else, so no ``..`` segment, no
1257
+ third segment and no ``{owner}`` placeholder `gh` would fill in from a
1258
+ git remote.
1259
+ """
1260
+ owner, sep, name = (repository if isinstance(repository, str) else "").partition("/")
1261
+ for part in (owner, name):
1262
+ if not sep or not _GITHUB_NAME_RE.fullmatch(part) or not part.strip("."):
1263
+ raise GitHubError(f"{repository!r} is not an owner/repo repository name")
1264
+ return f"{owner}/{name}"
1265
+
1266
+
1267
+ # What ``git check-ref-format`` refuses in a branch name, plus whitespace: a
1268
+ # branch the controller deletes or reads by name is one exact ref, never a
1269
+ # path that could reach another (``..``) or a pattern.
1270
+ _BRANCH_FORBIDDEN_RE = re.compile(r"[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//|/\.|\.lock(?:/|\Z)")
1271
+
1272
+
1273
+ def _branch_path(branch: str, what: str) -> str:
1274
+ """``branch`` as it goes into a ``git/ref/heads/`` REST path: one branch, percent-encoded."""
1275
+ if (
1276
+ not isinstance(branch, str)
1277
+ or not branch
1278
+ or branch == "@"
1279
+ or branch.startswith(("/", ".", "-"))
1280
+ or branch.endswith(("/", "."))
1281
+ or _BRANCH_FORBIDDEN_RE.search(branch)
1282
+ ):
1283
+ raise GitHubError(f"{what}: {branch!r:.120} is not a branch name")
1284
+ return quote(branch, safe="/")
1285
+
1286
+
1287
+ def _check_write_bounds(what: str, *, body: str, title: str | None = None) -> None:
1288
+ """Refuse a payload GitHub would refuse, before any process runs."""
1289
+ if not isinstance(body, str):
1290
+ raise GitHubError(f"{what}: the body is {type(body).__name__}, not text")
1291
+ if len(body) > MAX_BODY_CHARS:
1292
+ raise GitHubError(
1293
+ f"{what}: the body is {len(body)} characters, over GitHub's limit of "
1294
+ f"{MAX_BODY_CHARS}; nothing was sent"
1295
+ )
1296
+ if title is None:
1297
+ return
1298
+ if not isinstance(title, str):
1299
+ raise GitHubError(f"{what}: the title is {type(title).__name__}, not text")
1300
+ if len(title) > MAX_TITLE_CHARS:
1301
+ raise GitHubError(
1302
+ f"{what}: the title is {len(title)} characters, over GitHub's limit of "
1303
+ f"{MAX_TITLE_CHARS}; nothing was sent"
1304
+ )
1305
+
1306
+
1307
+ def _full_sha(value: str, role: str, what: str) -> str:
1308
+ sha = value.lower() if isinstance(value, str) else ""
1309
+ if not _FULL_SHA_RE.fullmatch(sha):
1310
+ raise GitHubError(f"{what}: {role} {value!r:.80} is not a full commit SHA")
1311
+ return sha
1312
+
1313
+
1314
+ def _write_unknown(what: str, problem: str) -> NoReturn:
1315
+ """A write `gh` reported as done whose reply cannot be read (D4.3).
1316
+
1317
+ The write may well have landed; only a read-back by the effect's
1318
+ identity can tell, so this is an ambiguous outcome, never a refusal.
1319
+ The reply itself is not quoted: it echoes the payload.
1320
+ """
1321
+ raise GitHubUnavailableError(f"{what}: {problem}; the outcome is unknown")
1322
+
1323
+
1324
+ def _created_comment(reply: dict, target: GitHubRef, what: str) -> CreatedObject:
1325
+ """The comment a create reply names, which must be on ``target``."""
1326
+ try:
1327
+ ref = parse_comment_url(str(reply.get("html_url") or ""))
1328
+ except ConfigurationError:
1329
+ _write_unknown(what, "the reply's html_url is not a GitHub comment URL")
1330
+ comment_id = reply.get("id")
1331
+ if isinstance(comment_id, bool) or comment_id != ref.comment_id:
1332
+ _write_unknown(what, "the reply's id does not match its html_url")
1333
+ if not ref.on(target):
1334
+ _write_unknown(what, f"the reply names a comment on another object ({ref.canonical})")
1335
+ return CreatedObject(url=ref.canonical, number=None)
1336
+
1337
+
1338
+ def _replied_object(
1339
+ reply: dict, what: str, *, repository: str, expect: str | None, number: int | None = None
1340
+ ) -> CreatedObject:
1341
+ """The issue or PR a write reply names: in ``repository``, of kind ``expect``."""
1342
+ try:
1343
+ ref = parse_github_url(str(reply.get("html_url") or ""), expect=expect)
1344
+ except ConfigurationError:
1345
+ _write_unknown(what, f"the reply's html_url is not a GitHub {expect or 'issue/PR'} URL")
1346
+ replied = reply.get("number")
1347
+ if isinstance(replied, bool) or replied != ref.number:
1348
+ _write_unknown(what, "the reply's number does not match its html_url")
1349
+ if not ref.same_repository(repository) or (number is not None and ref.number != number):
1350
+ _write_unknown(what, f"the reply names another object ({ref.canonical})")
1351
+ return CreatedObject(url=ref.canonical, number=ref.number)
1352
+
1353
+
1354
+ @dataclass(frozen=True)
1355
+ class _ListedIssue:
1356
+ """One row of the newest-first REST issue listing (issues and PRs alike)."""
1357
+
1358
+ number: int
1359
+ created_at: datetime
1360
+ is_pull_request: bool
1361
+ row: dict
1362
+
1363
+
1364
+ def _listed_issue(row: object, what: str) -> _ListedIssue:
1365
+ """The number, creation time and kind of one REST issue-listing row, strictly.
1366
+
1367
+ The REST issues listing returns pull requests too (a PR is an issue
1368
+ there), marked by a ``pull_request`` key; they share one number
1369
+ sequence with issues. A row without a usable number or creation time
1370
+ is a conclusive GitHubError, never skipped: the watermark walk decides
1371
+ where to stop from these two values.
1372
+ """
1373
+ data = _row_object(row, what)
1374
+ number = data.get("number")
1375
+ if isinstance(number, bool) or not isinstance(number, int) or number < 1:
1376
+ raise GitHubError(f"{what}: a row has no usable number: {number!r:.40}")
1377
+ text = data.get("created_at")
1378
+ try:
1379
+ created = datetime.fromisoformat(text) if isinstance(text, str) else None
1380
+ except ValueError:
1381
+ created = None
1382
+ if created is None or created.tzinfo is None:
1383
+ raise GitHubError(f"{what}: #{number} has no usable created_at: {text!r:.40}")
1384
+ return _ListedIssue(
1385
+ number=number,
1386
+ created_at=created,
1387
+ is_pull_request="pull_request" in data,
1388
+ row=data,
1389
+ )
1390
+
1391
+
1392
+ def _rest_issue_row(data: dict) -> dict:
1393
+ """A REST issue row in the ``gh issue view --json`` shape :func:`_decode_issue` reads.
1394
+
1395
+ REST names the HTML URL ``html_url`` (its ``url`` is the API URL) and
1396
+ spells the state in lower case; the decoder upper-cases it.
1397
+ """
1398
+ return {
1399
+ "url": data.get("html_url"),
1400
+ "number": data.get("number"),
1401
+ "title": data.get("title"),
1402
+ "state": data.get("state"),
1403
+ "body": data.get("body"),
1404
+ }
1405
+
1406
+
1407
+ def _rest_pr_row(row: object, what: str) -> dict:
1408
+ """A REST pull-request row in the ``gh pr view --json`` shape ``_pr_from_data`` reads.
1409
+
1410
+ One decoder reads every PR, so the REST row is translated, never decoded
1411
+ separately: ``html_url`` is the PR URL, ``head``/``base`` carry the refs
1412
+ and the head commit, a closed PR with a ``merged_at`` is ``MERGED`` (REST
1413
+ has no merged state), and ``auto_merge`` is the armed auto-merge.
1414
+ Closing-issue references, mergeability and checks are not in the REST
1415
+ listing (``checks`` is None, not read); a caller that needs them reads
1416
+ the PR with ``get_pr``, or ``get_pr_with_checks`` for the checks.
1417
+ """
1418
+ data = _row_object(row, what)
1419
+ head = data.get("head")
1420
+ base = data.get("base")
1421
+ if not isinstance(head, dict) or not isinstance(base, dict):
1422
+ raise GitHubError(f"{what}: a row has no head or base object")
1423
+ state = data.get("state")
1424
+ if isinstance(state, str) and state.lower() == "closed" and data.get("merged_at"):
1425
+ state = "MERGED"
1426
+ head_repo = head.get("repo")
1427
+ repo_name: object = None
1428
+ owner_login: object = None
1429
+ if isinstance(head_repo, dict):
1430
+ repo_name = head_repo.get("name")
1431
+ owner = head_repo.get("owner")
1432
+ owner_login = owner.get("login") if isinstance(owner, dict) else None
1433
+ return {
1434
+ "url": data.get("html_url"),
1435
+ "number": data.get("number"),
1436
+ "title": data.get("title"),
1437
+ "state": state,
1438
+ "headRefOid": head.get("sha"),
1439
+ "headRefName": head.get("ref"),
1440
+ "baseRefName": base.get("ref"),
1441
+ "isDraft": data.get("draft", False),
1442
+ "body": data.get("body"),
1443
+ "headRepository": {"name": repo_name} if repo_name else None,
1444
+ "headRepositoryOwner": {"login": owner_login} if owner_login else None,
1445
+ "autoMergeRequest": data.get("auto_merge"),
1446
+ }
1447
+
1448
+
1449
+ def build_merge_argv(pr_url: str, method: str = "squash", match_head_sha: str = "") -> list[str]:
1450
+ """Pure argv builder for ``gh pr merge`` (no ``gh`` prefix, no execution).
1451
+
1452
+ Shared by :meth:`GitHubClient.merge_pr` and the engine's dry-run plan so
1453
+ the command shown in dry-run is exactly the one that would run.
1454
+ ``match_head_sha`` must be the full 40-hex reviewed HEAD; GitHub then
1455
+ refuses the merge server-side if the PR HEAD moved. It never carries
1456
+ ``--delete-branch``, which deletes the local branch too (#283):
1457
+ ``merge.delete_branch`` deletes the remote head branch only, through
1458
+ :meth:`GitHubClient.delete_branch_ref`.
1459
+ """
1460
+ if method not in MERGE_METHODS:
1461
+ raise ConfigurationError(f"merge.method must be one of {MERGE_METHODS}, got {method!r}")
1462
+ if not match_head_sha or not _SHA40_RE.match(match_head_sha.lower()):
1463
+ raise ConfigurationError(
1464
+ "merge requires the full 40-hex reviewed HEAD SHA for --match-head-commit, "
1465
+ f"got {match_head_sha!r}"
1466
+ )
1467
+ ref = parse_pr_url(pr_url)
1468
+ return ["pr", "merge", ref.canonical, f"--{method}", "--match-head-commit", match_head_sha]
1469
+
1470
+
1471
+ def build_disable_auto_merge_argv(pr_url: str) -> list[str]:
1472
+ """``gh pr merge <pr> --disable-auto``: disarm GitHub auto-merge on a PR."""
1473
+ return ["pr", "merge", parse_pr_url(pr_url).canonical, "--disable-auto"]
1474
+
1475
+
1476
+ def build_edit_issue_body_argv(issue_url: str, body_file: str) -> list[str]:
1477
+ """``gh issue edit <issue> --body-file <file>``: replace an issue's body.
1478
+
1479
+ The body travels through a file, never through ``--body``: an issue body
1480
+ is arbitrary multi-line text that the executor's argv would otherwise
1481
+ expose in process listings, and the executor gives ``gh`` no stdin.
1482
+ """
1483
+ return ["issue", "edit", parse_issue_url(issue_url).canonical, "--body-file", body_file]
1484
+
1485
+
1486
+ def _as_int(value: object, what: str) -> int:
1487
+ if isinstance(value, bool) or not isinstance(value, int):
1488
+ raise GitHubError(f"`gh` returned a non-integer {what}: {value!r}")
1489
+ return value
1490
+
1491
+
1492
+ def _ruleset_from_data(data: dict) -> RulesetInfo:
1493
+ # Presence is the evidence: GitHub omits `bypass_actors` for a token
1494
+ # without write access to the ruleset, so an absent field must not read
1495
+ # as an empty list.
1496
+ actors: list[str] | None = None
1497
+ if "bypass_actors" in data:
1498
+ raw = data["bypass_actors"]
1499
+ if not isinstance(raw, list):
1500
+ raise GitHubError("ruleset bypass_actors is not an array")
1501
+ actors = []
1502
+ for actor in raw:
1503
+ if not isinstance(actor, dict):
1504
+ raise GitHubError("ruleset bypass_actors entry is not an object")
1505
+ actor_type = actor.get("actor_type") or "?"
1506
+ actor_id = actor.get("actor_id")
1507
+ mode = actor.get("bypass_mode") or "always"
1508
+ label = f"{actor_type} {actor_id}" if actor_id is not None else str(actor_type)
1509
+ actors.append(f"{label} ({mode})")
1510
+ links = data.get("_links") or {}
1511
+ html = links.get("html") if isinstance(links, dict) else None
1512
+ return RulesetInfo(
1513
+ id=_as_int(data.get("id"), "ruleset id"),
1514
+ name=str(data.get("name") or ""),
1515
+ enforcement=str(data.get("enforcement") or ""),
1516
+ target=str(data.get("target") or ""),
1517
+ source=str(data.get("source") or ""),
1518
+ source_type=str(data.get("source_type") or ""),
1519
+ bypass_actors=None if actors is None else tuple(actors),
1520
+ current_user_can_bypass=str(data.get("current_user_can_bypass") or ""),
1521
+ html_url=str(html.get("href") or "") if isinstance(html, dict) else "",
1522
+ )
1523
+
1524
+
1525
+ # -- the native EPIC dependency graph (ADR 0008 D3, #179) ----------------------------
1526
+ #
1527
+ # Read-only: the parallel EPIC scheduler's snapshot reads an EPIC's direct
1528
+ # native sub-issues and each child's native ``blocked_by`` list, nothing
1529
+ # else (``blocking`` is never read, prose never parsed). Every row is
1530
+ # decoded strictly into the eligibility facts of D3.5; a row that is not
1531
+ # the object asked for is a conclusive GitHubError, never a node with a
1532
+ # manufactured identity.
1533
+
1534
+ # GitHub's documented limits on the native graph: "You can add up to 100
1535
+ # sub-issues per parent issue" (docs, "Adding sub-issues"), and "You can
1536
+ # link up to 50 issues for each relationship type" (changelog 2025-08-21,
1537
+ # "Dependencies on issues"). The snapshot refuses a graph past either
1538
+ # (D3.4) instead of reading it partially.
1539
+ MAX_SUB_ISSUES = 100
1540
+ MAX_BLOCKED_BY = 50
1541
+ # Both listings are read with the REST maximum page size, every page.
1542
+ _GRAPH_PAGE_SIZE = 100
1543
+ ISSUE_STATES = ("open", "closed")
1544
+ # A ``state_reason`` is GitHub's word (``completed``, ``not_planned``,
1545
+ # ``duplicate``, ``reopened``, or one it adds later). An unknown word is kept
1546
+ # and shown; anything that is not a short word is a malformed answer.
1547
+ _STATE_REASON_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
1548
+ # The fields that name a blocker beyond its ``id``. A row carrying none of
1549
+ # them names a blocker the token may not read (see :func:`_decode_blocker`).
1550
+ _BLOCKER_IDENTITY_FIELDS = ("html_url", "number", "state", "repository_url")
1551
+
1552
+
1553
+ @dataclass(frozen=True)
1554
+ class IssueNode:
1555
+ """One issue as the dependency graph reads it: its eligibility facts (ADR 0008 D3.5).
1556
+
1557
+ Decoded from a REST issue object (``GET .../issues/<n>`` or a
1558
+ ``/sub_issues`` row). ``id`` is GitHub's numeric issue id; ``url`` the
1559
+ issue's canonical HTML URL and ``repository`` the ``owner/repo`` it
1560
+ names; ``state`` is ``open`` or ``closed``; ``state_reason`` is GitHub's
1561
+ word or None; ``parent`` is the issue ``parent_issue_url`` names (None
1562
+ when it has none); ``sub_issues_total`` is ``sub_issues_summary.total``;
1563
+ ``blocked_by_total`` and ``blocked_by_open`` are
1564
+ ``issue_dependencies_summary.total_blocked_by`` and ``.blocked_by``.
1565
+ Titles, bodies, labels, assignees, milestones and timestamps are never
1566
+ read into a node.
1567
+ """
1568
+
1569
+ id: int
1570
+ number: int
1571
+ url: str
1572
+ repository: str
1573
+ state: str
1574
+ state_reason: str | None
1575
+ parent: GitHubIssueRef | None
1576
+ sub_issues_total: int
1577
+ blocked_by_total: int
1578
+ blocked_by_open: int
1579
+
1580
+ @property
1581
+ def ref(self) -> GitHubIssueRef:
1582
+ return parse_issue_url(self.url)
1583
+
1584
+ @property
1585
+ def is_open(self) -> bool:
1586
+ return self.state == "open"
1587
+
1588
+
1589
+ @dataclass(frozen=True)
1590
+ class BlockerNode:
1591
+ """One row of an issue's native ``blocked_by`` listing (ADR 0008 D3.2, D3.6).
1592
+
1593
+ A readable blocker carries its ``number``, ``url``, ``repository``,
1594
+ ``state`` and ``state_reason``, wherever it lives. ``readable`` False is
1595
+ a blocker GitHub names by its ``id`` alone, with none of the fields that
1596
+ would identify it: the token may not read it. Its other fields are None
1597
+ rather than a made-up identity, and it blocks its child (fail closed).
1598
+ """
1599
+
1600
+ id: int
1601
+ readable: bool
1602
+ number: int | None = None
1603
+ url: str | None = None
1604
+ repository: str | None = None
1605
+ state: str | None = None
1606
+ state_reason: str | None = None
1607
+
1608
+
1609
+ def _positive_id(data: dict, key: str, what: str) -> int:
1610
+ value = data.get(key)
1611
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
1612
+ raise GitHubError(f"{what}: field {key!r} is not a positive integer: {value!r:.40}")
1613
+ return value
1614
+
1615
+
1616
+ def _rest_state(data: dict, what: str) -> str:
1617
+ state = data.get("state")
1618
+ if not isinstance(state, str) or state.lower() not in ISSUE_STATES:
1619
+ raise GitHubError(f"{what}: state {state!r:.40} is neither open nor closed")
1620
+ return state.lower()
1621
+
1622
+
1623
+ def _rest_state_reason(data: dict, what: str) -> str | None:
1624
+ reason = data.get("state_reason")
1625
+ if reason is None:
1626
+ return None
1627
+ if not isinstance(reason, str) or not _STATE_REASON_RE.fullmatch(reason):
1628
+ raise GitHubError(f"{what}: state_reason {reason!r:.80} is not a reason word")
1629
+ return reason.lower()
1630
+
1631
+
1632
+ def _summary_count(data: dict, summary: str, key: str, what: str) -> int:
1633
+ block = data.get(summary)
1634
+ if not isinstance(block, dict):
1635
+ raise GitHubError(f"{what}: {summary} is not an object")
1636
+ value = block.get(key)
1637
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
1638
+ raise GitHubError(f"{what}: {summary}.{key} is not a count: {value!r:.40}")
1639
+ return value
1640
+
1641
+
1642
+ def _rest_issue_ref(data: dict, what: str) -> GitHubIssueRef:
1643
+ """The issue a REST row is: its ``html_url``, which ``number`` and ``repository_url`` match.
1644
+
1645
+ A pull request is an issue to the REST API, but its ``html_url`` is a
1646
+ ``/pull/`` URL, so it is refused here: no graph edge names one.
1647
+ """
1648
+ url = data.get("html_url")
1649
+ try:
1650
+ ref = parse_issue_url(url if isinstance(url, str) else "")
1651
+ except ConfigurationError as exc:
1652
+ raise GitHubError(f"{what}: html_url {url!r:.200} is not a GitHub issue URL") from exc
1653
+ _number_field(data, "number", what, ref.number)
1654
+ api_repository = data.get("repository_url")
1655
+ try:
1656
+ repository = parse_api_repository_url(
1657
+ api_repository if isinstance(api_repository, str) else ""
1658
+ )
1659
+ except ConfigurationError as exc:
1660
+ raise GitHubError(
1661
+ f"{what}: repository_url {api_repository!r:.200} is not a GitHub repository"
1662
+ ) from exc
1663
+ if not ref.same_repository(repository):
1664
+ raise GitHubError(f"{what}: repository_url names {repository}, html_url {ref.repository}")
1665
+ return ref
1666
+
1667
+
1668
+ def _decode_issue_node(row: object, what: str) -> IssueNode:
1669
+ """One REST issue object as an :class:`IssueNode`, strictly.
1670
+
1671
+ GitHub leaves ``parent_issue_url`` out of an issue that has no parent
1672
+ (observed live for #179, ``tests/fixtures/github_graph/README.md``), so
1673
+ an absent field reads as no parent, like a null one. That is never the
1674
+ more permissive outcome: a ``/sub_issues`` row without a parent is
1675
+ refused by the snapshot, and a re-read child without one is stale. An
1676
+ answer from an API without native sub-issues still fails here, on the
1677
+ two summaries it lacks.
1678
+ """
1679
+ data = _row_object(row, what)
1680
+ ref = _rest_issue_ref(data, what)
1681
+ what = f"{what}: {ref.canonical}"
1682
+ parent_url = data.get("parent_issue_url")
1683
+ parent: GitHubIssueRef | None = None
1684
+ if parent_url is not None:
1685
+ try:
1686
+ parent = parse_api_issue_url(parent_url if isinstance(parent_url, str) else "")
1687
+ except ConfigurationError as exc:
1688
+ raise GitHubError(
1689
+ f"{what}: parent_issue_url {parent_url!r:.200} is not a GitHub issue"
1690
+ ) from exc
1691
+ blocked_total = _summary_count(data, "issue_dependencies_summary", "total_blocked_by", what)
1692
+ blocked_open = _summary_count(data, "issue_dependencies_summary", "blocked_by", what)
1693
+ if blocked_open > blocked_total:
1694
+ raise GitHubError(
1695
+ f"{what}: {blocked_open} open blockers but {blocked_total} blockers in total"
1696
+ )
1697
+ return IssueNode(
1698
+ id=_positive_id(data, "id", what),
1699
+ number=ref.number,
1700
+ url=ref.canonical,
1701
+ repository=ref.repository,
1702
+ state=_rest_state(data, what),
1703
+ state_reason=_rest_state_reason(data, what),
1704
+ parent=parent,
1705
+ sub_issues_total=_summary_count(data, "sub_issues_summary", "total", what),
1706
+ blocked_by_total=blocked_total,
1707
+ blocked_by_open=blocked_open,
1708
+ )
1709
+
1710
+
1711
+ def _decode_blocker(row: object, what: str) -> BlockerNode:
1712
+ """One ``blocked_by`` row as a :class:`BlockerNode`, strictly.
1713
+
1714
+ GitHub documents no shape for a blocker the token may not read. A row
1715
+ that carries a positive ``id`` and none of the fields naming the issue
1716
+ (``html_url``, ``number``, ``state``, ``repository_url``) is read as such
1717
+ a blocker: ``readable`` False, which blocks its child. A row with some
1718
+ of them and not the others is malformed, as is every other defect. A
1719
+ blocker GitHub leaves out of the listing altogether makes the listing
1720
+ shorter than the child's ``total_blocked_by``, which refuses the whole
1721
+ snapshot instead (ADR 0008 D3.3), the stricter of the two outcomes.
1722
+ """
1723
+ data = _row_object(row, what)
1724
+ issue_id = _positive_id(data, "id", what)
1725
+ if all(data.get(key) is None for key in _BLOCKER_IDENTITY_FIELDS):
1726
+ return BlockerNode(id=issue_id, readable=False)
1727
+ ref = _rest_issue_ref(data, what)
1728
+ what = f"{what}: {ref.canonical}"
1729
+ return BlockerNode(
1730
+ id=issue_id,
1731
+ readable=True,
1732
+ number=ref.number,
1733
+ url=ref.canonical,
1734
+ repository=ref.repository,
1735
+ state=_rest_state(data, what),
1736
+ state_reason=_rest_state_reason(data, what),
1737
+ )
1738
+
1739
+
1740
+ class GitHubClient:
1741
+ """Thin typed wrapper around `gh`. Inject ``runner`` (and ``https``) for tests."""
1742
+
1743
+ def __init__(
1744
+ self,
1745
+ gh_command: str = "gh",
1746
+ timeout_seconds: int = 120,
1747
+ runner: Runner | None = None,
1748
+ transient_retries: int = 1,
1749
+ retry_delay_seconds: float = 2.0,
1750
+ https: HttpsGet | None = None,
1751
+ ) -> None:
1752
+ self.gh_command = gh_command
1753
+ self.timeout_seconds = timeout_seconds
1754
+ self._runner: Runner = runner or execute
1755
+ self._https: HttpsGet = https or https_get
1756
+ self.transient_retries = max(0, transient_retries)
1757
+ self.retry_delay_seconds = retry_delay_seconds
1758
+
1759
+ # -- low level ------------------------------------------------------
1760
+ def _invoke(self, args: list[str], stdin: bytes | None = None) -> ExecutionResult:
1761
+ # No GitHub call while this process holds the shared-Git lock (ADR
1762
+ # 0008 D11.2): its holder must never wait on anything remote.
1763
+ assert_shared_git_lock_free("call GitHub")
1764
+ with gh_workdir() as workdir:
1765
+ return self._runner(
1766
+ gh_request(
1767
+ [self.gh_command, *args],
1768
+ workdir,
1769
+ timeout_seconds=self.timeout_seconds,
1770
+ stdin=stdin,
1771
+ )
1772
+ )
1773
+
1774
+ def _timeout_message(self, args: list[str]) -> str:
1775
+ return f"`gh {' '.join(args)}` timed out after {self.timeout_seconds}s"
1776
+
1777
+ @staticmethod
1778
+ def _exit_failure(args: list[str], res: ExecutionResult) -> tuple[str, bool]:
1779
+ """The message of a non-zero `gh` exit, and whether it is transient.
1780
+
1781
+ `gh` stderr can echo the request it made, including an
1782
+ ``Authorization`` header or a token in a URL, and this message ends
1783
+ up in `block_reason`, `verification_failures` and the run log.
1784
+ Classify the raw tail, quote it redacted.
1785
+ """
1786
+ tail = res.stderr.strip()[-1000:]
1787
+ message = f"`gh {' '.join(args)}` failed (exit {res.exit_code}): {redact(tail)}"
1788
+ # GitHub refusing the check results is an answer, never a reason to
1789
+ # retry, whatever else the tail says (#291). It is looked for in the
1790
+ # whole stderr, where _conclusive classifies it, so a refusal before
1791
+ # the tail is never retried and then reported as unavailable.
1792
+ transient = is_transient_gh_failure(tail) and not checks_access_denial(res.stderr)
1793
+ return message, transient
1794
+
1795
+ @staticmethod
1796
+ def _conclusive(message: str, stderr: str | None = None) -> GitHubError:
1797
+ """A non-transient failure: a refused checks read, a plan limit, not found, or other.
1798
+
1799
+ ``stderr`` is the raw tail `gh` printed (``message`` quotes it
1800
+ redacted, after the command line); the checks refusal is read from
1801
+ it, never from the command line, which names ``statusCheckRollup``
1802
+ itself. A checks refusal is classified first: the PR exists, so it
1803
+ must never be read as not-found (#291).
1804
+ """
1805
+ answer = message if stderr is None else stderr
1806
+ credential = checks_access_denial(answer)
1807
+ if credential:
1808
+ return GitHubChecksAccessError(message, credential=credential)
1809
+ # The plan, not the credential (#289): never access denied, never absence.
1810
+ if is_plan_limited_gh_failure(answer):
1811
+ return GitHubPlanLimitedError(message)
1812
+ if is_not_found_gh_failure(message):
1813
+ return GitHubNotFoundError(message)
1814
+ return GitHubError(message)
1815
+
1816
+ def _run_gh(self, args: list[str], allow_fail: bool = False) -> ExecutionResult:
1817
+ attempts = self.transient_retries + 1
1818
+ last_error = ""
1819
+ last_stderr: str | None = None
1820
+ transient = False
1821
+ for i in range(attempts):
1822
+ res = self._invoke(args)
1823
+ last_stderr = None
1824
+ if res.timed_out:
1825
+ last_error = self._timeout_message(args)
1826
+ transient = True
1827
+ elif res.truncated:
1828
+ # Head + marker + tail of a reply is not the reply: parsing it
1829
+ # would read a document `gh` never returned. Not transient,
1830
+ # and not something `allow_fail` may hand back as output.
1831
+ raise GitHubError(
1832
+ f"`gh {' '.join(args)}` output was truncated at the executor's capture "
1833
+ "bound and cannot be parsed"
1834
+ )
1835
+ elif res.exit_code != 0:
1836
+ last_error, transient = self._exit_failure(args, res)
1837
+ last_stderr = res.stderr
1838
+ else:
1839
+ return res
1840
+ if allow_fail and not transient:
1841
+ return res
1842
+ if not transient or i == attempts - 1:
1843
+ break
1844
+ time.sleep(self.retry_delay_seconds)
1845
+ if transient:
1846
+ raise GitHubUnavailableError(last_error)
1847
+ raise self._conclusive(last_error, last_stderr)
1848
+
1849
+ def _run_gh_once(self, args: list[str], *, stdin: bytes | None = None) -> ExecutionResult:
1850
+ """Run `gh` exactly once: the transport of a write (ADR 0004 D4.3).
1851
+
1852
+ A write is never re-sent on an ambiguous outcome, because the first
1853
+ attempt may have landed: a timeout, a transient failure and a reply
1854
+ that cannot be read are GitHubUnavailableError, "outcome unknown",
1855
+ and the caller reconciles by reading the target back. A 404 is
1856
+ GitHubNotFoundError and any other conclusive failure (a 422, a 403)
1857
+ GitHubError, classified exactly as :meth:`_run_gh` classifies them.
1858
+ ``stdin`` is the payload, so it never appears in argv, a process
1859
+ listing or an error message.
1860
+ """
1861
+ res = self._invoke(args, stdin)
1862
+ if res.timed_out:
1863
+ raise GitHubUnavailableError(f"{self._timeout_message(args)}; the outcome is unknown")
1864
+ if res.exit_code != 0:
1865
+ message, transient = self._exit_failure(args, res)
1866
+ if transient:
1867
+ raise GitHubUnavailableError(f"{message}; the outcome is unknown")
1868
+ raise self._conclusive(message, res.stderr)
1869
+ if res.truncated:
1870
+ # The write was accepted (exit 0) but its reply is not readable:
1871
+ # unlike a read, this is not a conclusive failure, the write may
1872
+ # have landed.
1873
+ raise GitHubUnavailableError(
1874
+ f"`gh {' '.join(args)}` reply was truncated at the executor's capture "
1875
+ "bound; the outcome is unknown"
1876
+ )
1877
+ return res
1878
+
1879
+ def _write(self, method: str, path: str, payload: dict[str, str], what: str) -> dict:
1880
+ """One REST write: ``gh api --method <method> <path> --input -``, payload on stdin.
1881
+
1882
+ The reply is parsed whole in Python (no ``--jq``): a reply that is
1883
+ not a JSON object is an unknown outcome, not a refusal.
1884
+ """
1885
+ res = self._run_gh_once(
1886
+ ["api", "--method", method, path, "--input", "-"],
1887
+ stdin=json.dumps(payload).encode("utf-8"),
1888
+ )
1889
+ try:
1890
+ reply = json.loads(res.stdout)
1891
+ except json.JSONDecodeError:
1892
+ _write_unknown(what, "`gh` returned a reply that is not JSON")
1893
+ if not isinstance(reply, dict):
1894
+ _write_unknown(what, "`gh` returned a reply that is not a JSON object")
1895
+ return reply
1896
+
1897
+ def _json(self, args: list[str]) -> object:
1898
+ res = self._run_gh(args)
1899
+ try:
1900
+ return json.loads(res.stdout)
1901
+ except json.JSONDecodeError as exc:
1902
+ raise GitHubError(f"`gh` returned invalid JSON: {exc}") from exc
1903
+
1904
+ def _api_json(self, args: list[str]) -> dict:
1905
+ data = self._json(args)
1906
+ if not isinstance(data, dict):
1907
+ raise GitHubError("`gh` returned a non-object JSON payload")
1908
+ return data
1909
+
1910
+ def _api_list(self, args: list[str]) -> list:
1911
+ data = self._json(args)
1912
+ if not isinstance(data, list):
1913
+ raise GitHubError("`gh` returned a non-array JSON payload")
1914
+ return data
1915
+
1916
+ def _graphql_data(self, args: list[str], what: str) -> dict:
1917
+ """The ``data`` object of one GraphQL response, only when it is complete.
1918
+
1919
+ GraphQL answers a partially failed query with ``data`` *and*
1920
+ ``errors``: the fields it could not resolve are ``null`` or missing
1921
+ and the rest is present. Such a response must never be consumed as a
1922
+ complete answer (a missing PR node would prove "no PR exists"), so
1923
+ any ``errors`` entry is a conclusive GitHubError, whatever ``gh``'s
1924
+ exit status was, and ``data`` must be an object.
1925
+ """
1926
+ page = self._api_json(args)
1927
+ if "errors" in page:
1928
+ raise GitHubError(f"{what}: GraphQL reported errors: {page.get('errors')!r:.500}")
1929
+ data = page.get("data")
1930
+ if not isinstance(data, dict):
1931
+ raise GitHubError(f"{what}: GraphQL response has no data object: {page!r:.200}")
1932
+ return data
1933
+
1934
+ def _api_pages(self, endpoint: str) -> list:
1935
+ """Every item of a paginated REST array endpoint, all pages read.
1936
+
1937
+ ``gh api --paginate --slurp`` wraps the pages in one outer array, so
1938
+ the result is a JSON array of pages and each page a JSON array; any
1939
+ other shape is a malformed answer, not an empty one.
1940
+ """
1941
+ data = self._json(["api", "--paginate", "--slurp", endpoint])
1942
+ if not isinstance(data, list) or not all(isinstance(page, list) for page in data):
1943
+ raise GitHubError(
1944
+ f"`gh api {endpoint}` returned a non-paginated JSON payload: {data!r:.200}"
1945
+ )
1946
+ return [item for page in data for item in page]
1947
+
1948
+ def _api_full_pages(self, endpoint: str, page_size: int, what: str) -> list:
1949
+ """:meth:`_api_pages`, and every page before the last holds ``page_size`` rows.
1950
+
1951
+ ``endpoint`` asks for ``per_page=page_size``. A page short of it
1952
+ before the last means rows went missing between page reads, or were
1953
+ held back from a page: the listing is not known to be complete, so
1954
+ it is a conclusive GitHubError, never a shorter answer.
1955
+ """
1956
+ data = self._json(["api", "--paginate", "--slurp", endpoint])
1957
+ if not isinstance(data, list) or not all(isinstance(page, list) for page in data):
1958
+ raise GitHubError(f"{what}: `gh api` returned a non-paginated JSON payload")
1959
+ for index, page in enumerate(data[:-1]):
1960
+ if len(page) != page_size:
1961
+ raise GitHubError(
1962
+ f"{what}: page {index + 1} of {len(data)} holds {len(page)} rows, "
1963
+ f"not {page_size}; the listing may be incomplete"
1964
+ )
1965
+ return [item for page in data for item in page]
1966
+
1967
+ def _api_token(self, what: str) -> str:
1968
+ """The credential `gh` holds for github.com, for one exact read.
1969
+
1970
+ ``gh auth token`` prints the token `gh` itself would send (``GH_TOKEN``
1971
+ or ``GITHUB_TOKEN`` when set, else the login's). It stays in memory for
1972
+ the one request: never in argv, a file or a message. A failure names
1973
+ the command and `gh`'s redacted stderr, never what it printed.
1974
+ """
1975
+ args = ["auth", "token", "--hostname", "github.com"]
1976
+ res = self._invoke(args)
1977
+ if res.timed_out:
1978
+ raise GitHubUnavailableError(f"{what}: {self._timeout_message(args)}")
1979
+ if res.exit_code != 0:
1980
+ message, transient = self._exit_failure(args, res)
1981
+ raise (GitHubUnavailableError if transient else GitHubError)(f"{what}: {message}")
1982
+ token = "" if res.truncated else res.stdout.strip()
1983
+ if not _API_TOKEN_RE.fullmatch(token):
1984
+ raise GitHubError(f"{what}: `gh auth token` printed no usable credential")
1985
+ return token
1986
+
1987
+ def _exact_object(self, path: str, what: str) -> dict:
1988
+ """One REST object as GitHub holds it, past `gh`'s sanitizer (the exact read).
1989
+
1990
+ ``path`` is relative to :data:`EXACT_READ_ROOT`, built by a typed
1991
+ caller from a parsed URL, so the read reaches GitHub's API and nothing
1992
+ else. It sends one GET with the credential `gh` holds, follows no
1993
+ redirect and reads a bounded reply. Failures are classified as `gh`'s
1994
+ are: no connection, a 5xx, a 429 or a rate-limited 403 (by its headers
1995
+ or GitHub's message) is GitHubUnavailableError (inconclusive), a 404
1996
+ GitHubNotFoundError, and
1997
+ any other status, a redirect, a certificate that does not verify, or a
1998
+ reply that is not one JSON object within the bound a conclusive
1999
+ GitHubError. No message quotes the reply.
2000
+ """
2001
+ assert_shared_git_lock_free("call GitHub")
2002
+ # The credential goes straight into the request, whose repr leaves the
2003
+ # headers out: no local of this frame holds it on its own.
2004
+ request = HttpsRequest(
2005
+ url=EXACT_READ_ROOT + path,
2006
+ headers=(*_EXACT_READ_HEADERS, ("Authorization", "Bearer " + self._api_token(what))),
2007
+ timeout_seconds=self.timeout_seconds,
2008
+ max_bytes=EXACT_READ_MAX_BYTES,
2009
+ )
2010
+ try:
2011
+ reply = self._https(request)
2012
+ except Exception as exc: # every transport failure is classified, none escapes
2013
+ raise _exact_transport_failure(exc, what) from None
2014
+ status = reply.status
2015
+ # A primary limit sends its headers; a secondary limit may send neither,
2016
+ # only GitHub's message, which is read for this and never quoted.
2017
+ rate_limited = (
2018
+ reply.headers.get("x-ratelimit-remaining") == "0"
2019
+ or "retry-after" in reply.headers
2020
+ or b"rate limit" in reply.body[:EXACT_READ_ERROR_BYTES].lower()
2021
+ )
2022
+ if status >= 500 or status == 429 or (status == 403 and rate_limited):
2023
+ raise GitHubUnavailableError(f"{what}: GitHub answered HTTP {status}")
2024
+ if status == 404:
2025
+ raise GitHubNotFoundError(f"{what}: GitHub answered HTTP 404")
2026
+ if status != 200:
2027
+ redirect = " (a redirect, which is never followed)" if 300 <= status < 400 else ""
2028
+ raise GitHubError(f"{what}: GitHub answered HTTP {status}{redirect}")
2029
+ content_type = reply.headers.get("content-type", "")
2030
+ if not _JSON_CONTENT_TYPE_RE.match(content_type):
2031
+ raise GitHubError(f"{what}: GitHub answered {content_type[:80]!r}, not JSON")
2032
+ if len(reply.body) > EXACT_READ_MAX_BYTES:
2033
+ raise GitHubError(f"{what}: the reply is over {EXACT_READ_MAX_BYTES} bytes")
2034
+ try:
2035
+ data = json.loads(reply.body.decode("utf-8"))
2036
+ except ValueError:
2037
+ raise GitHubError(f"{what}: the reply is not UTF-8 JSON") from None
2038
+ if not isinstance(data, dict):
2039
+ raise GitHubError(f"{what}: the reply is not a JSON object")
2040
+ return data
2041
+
2042
+ # -- environment / doctor --------------------------------------------
2043
+ def version(self) -> str:
2044
+ res = self._run_gh(["--version"])
2045
+ first = (res.stdout or "").strip().splitlines()
2046
+ return first[0] if first else ""
2047
+
2048
+ def auth_status(self) -> tuple[bool, str]:
2049
+ """(authenticated?, human-readable detail). Never raises for non-auth."""
2050
+ try:
2051
+ with gh_workdir() as workdir:
2052
+ res = self._runner(
2053
+ gh_request(
2054
+ [self.gh_command, "auth", "status"],
2055
+ workdir,
2056
+ timeout_seconds=self.timeout_seconds,
2057
+ )
2058
+ )
2059
+ except Exception as exc: # spawn failure etc.
2060
+ return False, str(exc)
2061
+ text = (res.stdout or "") + (res.stderr or "")
2062
+ return res.exit_code == 0, text.strip()
2063
+
2064
+ # -- repository -------------------------------------------------------
2065
+ def get_repo(self, repo: str) -> RepoInfo:
2066
+ """``repo`` as 'owner/name'."""
2067
+ data = self._api_json(["repo", "view", repo, "--json", "nameWithOwner,defaultBranchRef"])
2068
+ return RepoInfo(
2069
+ name_with_owner=data.get("nameWithOwner", repo),
2070
+ default_branch=(data.get("defaultBranchRef") or {}).get("name", ""),
2071
+ )
2072
+
2073
+ # -- branch rules (read-only; `doctor`) ---------------------------------
2074
+ def get_required_status_check_rules(
2075
+ self, repo: str, branch: str
2076
+ ) -> list[RequiredStatusChecksRule]:
2077
+ """The ``required_status_checks`` rules in effect on ``branch`` (may be empty).
2078
+
2079
+ Effective rules only: what GitHub *enforces* on the branch right now,
2080
+ which is the question a merge gate asks. Every page is read, so an
2081
+ empty result means no active ruleset requires a check, not that the
2082
+ first page held none.
2083
+ """
2084
+ rules: list[RequiredStatusChecksRule] = []
2085
+ for item in self._api_pages(f"repos/{repo}/rules/branches/{branch}"):
2086
+ if not isinstance(item, dict) or item.get("type") != "required_status_checks":
2087
+ continue
2088
+ params = item.get("parameters") or {}
2089
+ if not isinstance(params, dict):
2090
+ raise GitHubError("branch rule `required_status_checks` has no parameters")
2091
+ contexts = []
2092
+ for check in params.get("required_status_checks") or []:
2093
+ context = check.get("context") if isinstance(check, dict) else None
2094
+ if not isinstance(context, str) or not context:
2095
+ raise GitHubError("branch rule `required_status_checks` names no context")
2096
+ contexts.append(context)
2097
+ rules.append(
2098
+ RequiredStatusChecksRule(
2099
+ contexts=tuple(contexts),
2100
+ ruleset_id=_as_int(item.get("ruleset_id"), "ruleset_id"),
2101
+ ruleset_source=str(item.get("ruleset_source") or ""),
2102
+ ruleset_source_type=str(item.get("ruleset_source_type") or ""),
2103
+ strict=bool(params.get("strict_required_status_checks_policy", False)),
2104
+ )
2105
+ )
2106
+ return rules
2107
+
2108
+ def get_ruleset(self, repo: str, ruleset_id: int) -> RulesetInfo:
2109
+ """One ruleset with its enforcement level and bypass actors."""
2110
+ data = self._api_json(["api", f"repos/{repo}/rulesets/{ruleset_id}"])
2111
+ return _ruleset_from_data(data)
2112
+
2113
+ def list_rulesets(self, repo: str) -> list[RulesetInfo]:
2114
+ """Every ruleset applying to ``repo`` (organization ones included), any enforcement."""
2115
+ return [
2116
+ _ruleset_from_data(item)
2117
+ for item in self._api_pages(f"repos/{repo}/rulesets")
2118
+ if isinstance(item, dict)
2119
+ ]
2120
+
2121
+ def get_branch_protection(self, repo: str, branch: str) -> ClassicBranchProtection | None:
2122
+ """Classic branch protection on ``branch``, or None when GitHub says it has none.
2123
+
2124
+ ``HTTP 404: Branch not protected`` is the conclusive negative answer
2125
+ and yields None; an access failure propagates, because a token that
2126
+ may not read the protection has not shown the branch to be
2127
+ unprotected.
2128
+ """
2129
+ try:
2130
+ data = self._api_json(["api", f"repos/{repo}/branches/{branch}/protection"])
2131
+ except GitHubNotFoundError:
2132
+ return None
2133
+ checks = data.get("required_status_checks")
2134
+ contexts: list[str] = []
2135
+ if isinstance(checks, dict):
2136
+ for context in checks.get("contexts") or []:
2137
+ if isinstance(context, str) and context:
2138
+ contexts.append(context)
2139
+ for check in checks.get("checks") or []:
2140
+ context = check.get("context") if isinstance(check, dict) else None
2141
+ if isinstance(context, str) and context and context not in contexts:
2142
+ contexts.append(context)
2143
+ admins = data.get("enforce_admins")
2144
+ return ClassicBranchProtection(
2145
+ required_status_checks=isinstance(checks, dict),
2146
+ required_contexts=tuple(contexts),
2147
+ enforce_admins=bool(admins.get("enabled")) if isinstance(admins, dict) else False,
2148
+ )
2149
+
2150
+ # -- issues -----------------------------------------------------------
2151
+ def get_issue(self, url: str) -> IssueInfo:
2152
+ ref = parse_issue_url(url)
2153
+ data = self._api_json(
2154
+ ["issue", "view", ref.canonical, "--json", "url,number,title,state,body"]
2155
+ )
2156
+ answered = data.get("url") if isinstance(data, dict) else None
2157
+ if isinstance(answered, str) and "/pull/" in answered:
2158
+ try:
2159
+ pr = parse_pr_url(answered).canonical
2160
+ except ConfigurationError:
2161
+ pr = ""
2162
+ if pr:
2163
+ raise GitHubWrongTargetError(
2164
+ f"issue {ref.canonical}: `gh` answered with pull request {pr}", pr
2165
+ )
2166
+ issue = _decode_issue(data, f"issue {ref.canonical}")
2167
+ if not parse_issue_url(issue.url).same_target(ref):
2168
+ # A pull request of that number, or the issue it was transferred
2169
+ # to: typed, so a caller can name which (#243).
2170
+ raise GitHubWrongTargetError(
2171
+ f"issue {ref.canonical}: `gh` answered with {issue.url}", issue.url
2172
+ )
2173
+ return issue
2174
+
2175
+ def get_issue_exact(self, url: str) -> IssueInfo:
2176
+ """The issue as GitHub holds it, its title and body byte for byte (the exact read).
2177
+
2178
+ :meth:`get_pr_exact` for an issue. A pull request of that number, or
2179
+ an issue transferred away (GitHub redirects it), is a conclusive
2180
+ GitHubError.
2181
+ """
2182
+ ref = parse_issue_url(url)
2183
+ what = f"exact read of {ref.canonical}"
2184
+ path = f"repos/{_repository_path(ref.repository)}/issues/{ref.number}"
2185
+ issue = _decode_issue(_rest_issue_row(self._exact_object(path, what)), what)
2186
+ if not parse_issue_url(issue.url).same_target(ref):
2187
+ raise GitHubWrongTargetError(f"{what}: GitHub answered with {issue.url}", issue.url)
2188
+ return issue
2189
+
2190
+ def get_issue_state(self, url: str) -> str:
2191
+ return self.get_issue(url).state
2192
+
2193
+ def edit_issue_body(self, url: str, body: str) -> None:
2194
+ """Replace the whole body of an issue (``gh issue edit --body-file``).
2195
+
2196
+ The only controller-owned issue-body write: UPDATE_EPIC splices the
2197
+ EPIC's managed roadmap section into a body it read moments before.
2198
+ ``gh issue edit`` has no precondition, so the caller proves the write
2199
+ did what it meant by reading the body back and comparing every byte
2200
+ outside the managed section (``engine._apply_update_epic``); the exit
2201
+ status alone is no evidence. The body is handed over in a private
2202
+ temporary file that is removed whether or not ``gh`` succeeds.
2203
+ """
2204
+ fd, path = tempfile.mkstemp(prefix="autoforge-issue-body-", suffix=".md")
2205
+ try:
2206
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
2207
+ handle.write(body)
2208
+ self._run_gh(build_edit_issue_body_argv(url, path))
2209
+ finally:
2210
+ try:
2211
+ os.unlink(path)
2212
+ except FileNotFoundError:
2213
+ pass
2214
+
2215
+ def list_open_issues(self, repo: str, *, strict: bool = False) -> list[IssueInfo]:
2216
+ """Every open issue in ``repo`` (bodies included), pull requests excluded.
2217
+
2218
+ ``strict`` raises GitHubError instead of returning a set that may be
2219
+ incomplete: the listing is bounded by :data:`STRICT_ISSUE_LIST_LIMIT`,
2220
+ and a caller deciding "no such issue exists" must not confuse that
2221
+ with "the issue was past the limit".
2222
+ """
2223
+ limit = STRICT_ISSUE_LIST_LIMIT if strict else 100
2224
+ data = self._api_list(
2225
+ [
2226
+ "issue",
2227
+ "list",
2228
+ "--repo",
2229
+ repo,
2230
+ "--state",
2231
+ "open",
2232
+ "--limit",
2233
+ str(limit),
2234
+ "--json",
2235
+ _ISSUE_LIST_FIELDS,
2236
+ ]
2237
+ )
2238
+ # Truncation is judged on the rows `gh` returned, before any of them
2239
+ # is decoded: a malformed row must not shorten the count below the
2240
+ # ceiling and turn "may be truncated" into "complete".
2241
+ if strict and len(data) >= limit:
2242
+ raise GitHubError(
2243
+ f"{repo} has at least {limit} open issues, so the listing may be truncated "
2244
+ "and the set of follow-up issues cannot be established"
2245
+ )
2246
+ return [_decode_issue(d, f"open issue listing of {repo}") for d in data]
2247
+
2248
+ def issue_exists(self, url: str) -> bool:
2249
+ try:
2250
+ self.get_issue(url)
2251
+ return True
2252
+ except GitHubError:
2253
+ return False
2254
+
2255
+ # -- the native EPIC dependency graph (read-only; ADR 0008 D3, #179) ---------
2256
+ def get_repository_id(self, repo: str) -> int:
2257
+ """GitHub's numeric id of ``repo`` (``owner/name``), the RunKey's repository part."""
2258
+ path = _repository_path(repo)
2259
+ data = self._api_json(["api", f"repos/{path}"])
2260
+ return _positive_id(data, "id", f"repository {path}")
2261
+
2262
+ def get_issue_node(self, url: str) -> IssueNode:
2263
+ """The eligibility facts of one issue (``GET .../issues/<n>``).
2264
+
2265
+ The answer must be the issue asked for: one GitHub answers for
2266
+ another (a transferred issue's redirect, a pull request's number)
2267
+ is a conclusive GitHubError.
2268
+ """
2269
+ ref = parse_issue_url(url)
2270
+ path = f"repos/{_repository_path(ref.repository)}/issues/{ref.number}"
2271
+ node = _decode_issue_node(self._api_json(["api", path]), f"issue {ref.canonical}")
2272
+ if not node.ref.same_target(ref):
2273
+ raise GitHubError(f"issue {ref.canonical}: GitHub answered with {node.url}")
2274
+ return node
2275
+
2276
+ def list_sub_issues(self, url: str) -> tuple[IssueNode, ...]:
2277
+ """Every direct native sub-issue of an issue, every page read, in API order.
2278
+
2279
+ Not recursive (ADR 0008 D3.1). The caller checks the length against
2280
+ the parent's ``sub_issues_summary.total`` and de-duplicates by ``id``.
2281
+ """
2282
+ ref = parse_issue_url(url)
2283
+ what = f"sub-issues of {ref.canonical}"
2284
+ endpoint = (
2285
+ f"repos/{_repository_path(ref.repository)}/issues/{ref.number}/sub_issues"
2286
+ f"?per_page={_GRAPH_PAGE_SIZE}"
2287
+ )
2288
+ rows = self._api_full_pages(endpoint, _GRAPH_PAGE_SIZE, what)
2289
+ return tuple(_decode_issue_node(row, what) for row in rows)
2290
+
2291
+ def list_blocked_by(self, url: str) -> tuple[BlockerNode, ...]:
2292
+ """Every native ``blocked_by`` dependency of an issue, every page read, in API order.
2293
+
2294
+ ``blocking`` is never read (ADR 0008 D3.2). A blocker may live in
2295
+ any repository; one the token may not read is a ``readable`` False
2296
+ node (:func:`_decode_blocker`). The caller checks the length against
2297
+ the issue's ``total_blocked_by`` and de-duplicates by ``id``.
2298
+ """
2299
+ ref = parse_issue_url(url)
2300
+ what = f"blockers of {ref.canonical}"
2301
+ endpoint = (
2302
+ f"repos/{_repository_path(ref.repository)}/issues/{ref.number}"
2303
+ f"/dependencies/blocked_by?per_page={_GRAPH_PAGE_SIZE}"
2304
+ )
2305
+ rows = self._api_full_pages(endpoint, _GRAPH_PAGE_SIZE, what)
2306
+ return tuple(_decode_blocker(row, what) for row in rows)
2307
+
2308
+ # -- pull requests ------------------------------------------------------
2309
+ @staticmethod
2310
+ def _pr_from_data(row: object, what: str) -> PRInfo:
2311
+ """One strict decoder for every PR view and listing (see ``_decode_issue``)."""
2312
+ data = _row_object(row, what)
2313
+ url = _text_field(data, "url", what)
2314
+ try:
2315
+ ref = parse_pr_url(url)
2316
+ except ConfigurationError as exc:
2317
+ raise GitHubError(
2318
+ f"{what}: url {url!r} is not a GitHub pull request URL ({exc})"
2319
+ ) from exc
2320
+ number = _number_field(data, "number", what, ref.number)
2321
+ checks = _rollup_checks(data, what)
2322
+ linked: list[int] = []
2323
+ for linked_issue in data.get("closingIssuesReferences") or []:
2324
+ if isinstance(linked_issue, dict) and isinstance(linked_issue.get("number"), int):
2325
+ linked.append(int(linked_issue["number"]))
2326
+ head_repo = ""
2327
+ hr = data.get("headRepository") or {}
2328
+ hro = data.get("headRepositoryOwner") or {}
2329
+ if isinstance(hr, dict) and hr.get("name"):
2330
+ owner = hro.get("login", "") if isinstance(hro, dict) else ""
2331
+ head_repo = f"{owner}/{hr['name']}" if owner else str(hr["name"])
2332
+ return PRInfo(
2333
+ url=ref.canonical,
2334
+ number=number,
2335
+ title=_text_field(data, "title", what),
2336
+ state=_text_field(data, "state", what).upper(),
2337
+ head_sha=_text_field(data, "headRefOid", what).lower(),
2338
+ base_ref=_text_field(data, "baseRefName", what),
2339
+ head_ref=_text_field(data, "headRefName", what),
2340
+ mergeable=_text_field(data, "mergeable", what).upper(),
2341
+ merge_state_status=_text_field(data, "mergeStateStatus", what).upper(),
2342
+ auto_merge_enabled=data.get("autoMergeRequest") is not None,
2343
+ is_draft=bool(data.get("isDraft", False)),
2344
+ body=_text_field(data, "body", what),
2345
+ repository=ref.repository,
2346
+ head_repository=head_repo,
2347
+ linked_issue_numbers=linked,
2348
+ checks=checks,
2349
+ )
2350
+
2351
+ def _view_pr(self, url: str, fields: str) -> PRInfo:
2352
+ ref = parse_pr_url(url)
2353
+ data = self._api_json(["pr", "view", ref.canonical, "--json", fields])
2354
+ pr = self._pr_from_data(data, f"PR {ref.canonical}")
2355
+ if not parse_pr_url(pr.url).same_target(ref):
2356
+ raise GitHubError(f"PR {ref.canonical}: `gh` answered with {pr.url}")
2357
+ return pr
2358
+
2359
+ def get_pr(self, url: str) -> PRInfo:
2360
+ """The PR's identity read: state, HEAD, base, branch, body, links; no check results.
2361
+
2362
+ ``statusCheckRollup`` is not requested, so a credential GitHub
2363
+ refuses the PR's check runs (#291) still reads the PR, and
2364
+ ``checks`` is None ("not read"), never ``[]``. Every caller that
2365
+ does not judge checks uses this read.
2366
+ """
2367
+ return self._view_pr(url, _PR_FIELDS)
2368
+
2369
+ def get_pr_exact(self, url: str) -> PRInfo:
2370
+ """The PR as GitHub holds it, its title and body byte for byte (the exact read).
2371
+
2372
+ The fields a REST listing row carries (:meth:`list_prs_for_head`),
2373
+ through the one PR decoder: state, HEAD, refs, head repository, title
2374
+ and body; no checks, closing issues or mergeability. An effect's
2375
+ read-back makes it when the text `gh` printed differs from the
2376
+ payload, since `gh`'s sanitizer may have rewritten it. An answer
2377
+ naming another PR is a conclusive GitHubError.
2378
+ """
2379
+ ref = parse_pr_url(url)
2380
+ what = f"exact read of {ref.canonical}"
2381
+ path = f"repos/{_repository_path(ref.repository)}/pulls/{ref.number}"
2382
+ data = self._exact_object(path, what)
2383
+ pr = self._pr_from_data(_rest_pr_row(data, what), what)
2384
+ if not parse_pr_url(pr.url).same_target(ref):
2385
+ raise GitHubError(f"{what}: GitHub answered with {pr.url}")
2386
+ return pr
2387
+
2388
+ def get_pr_with_checks(self, url: str) -> PRInfo:
2389
+ """The PR's checks read: the identity fields and the head's check results, at once.
2390
+
2391
+ One ``gh pr view`` answer, so the checks are those of the HEAD it
2392
+ reports. ``checks`` is always a list here: ``[]`` is GitHub reporting
2393
+ no check for the head, which is not a set of passed checks. GitHub
2394
+ refusing this credential the check results raises
2395
+ :class:`GitHubChecksAccessError` (conclusive, never retried, never a
2396
+ not-found and never ``[]``); a 404, a transient failure and an
2397
+ authentication failure are classified as for every other read.
2398
+ """
2399
+ pr = self._view_pr(url, _PR_CHECKS_FIELDS)
2400
+ if pr.checks is None:
2401
+ raise GitHubError(f"PR {pr.url}: `gh` returned no statusCheckRollup field")
2402
+ return pr
2403
+
2404
+ def get_pr_head_sha(self, url: str) -> str:
2405
+ return self.get_pr(url).head_sha
2406
+
2407
+ def get_pr_state(self, url: str) -> str:
2408
+ return self.get_pr(url).state
2409
+
2410
+ def get_pr_branch(self, url: str) -> str:
2411
+ return self.get_pr(url).head_ref
2412
+
2413
+ def get_pr_checks(self, url: str) -> list[CheckInfo]:
2414
+ checks = self.get_pr_with_checks(url).checks
2415
+ if checks is None: # get_pr_with_checks never returns one; never read it as []
2416
+ raise GitHubError(f"PR {url}: its check results were not read")
2417
+ return checks
2418
+
2419
+ def list_checks_probe_candidates(self, repo: str) -> list[tuple[str, int]]:
2420
+ """Open PRs whose head has check results, for `doctor`'s checks probe (#291).
2421
+
2422
+ ``(url, count)`` for each of the most recently updated open PRs
2423
+ (one page of :data:`_CHECKS_PROBE_PAGE_SIZE`) whose head commit's
2424
+ rollup counts at least one check result, in that order. The count
2425
+ is GitHub's ``contexts.totalCount``, which it returns even to a
2426
+ credential it refuses the check-run nodes, so this read selects a
2427
+ PR to probe and proves nothing about the checks read itself. Not a
2428
+ complete listing: a probe that finds no candidate here knows nothing.
2429
+ """
2430
+ what = f"open PRs of {repo} with check results"
2431
+ owner, _, name = repo.partition("/")
2432
+ data = self._graphql_data(
2433
+ [
2434
+ "api",
2435
+ "graphql",
2436
+ "-f",
2437
+ f"query={_CHECKS_PROBE_QUERY}",
2438
+ "-f",
2439
+ f"owner={owner}",
2440
+ "-f",
2441
+ f"name={name}",
2442
+ ],
2443
+ what,
2444
+ )
2445
+ repository = data.get("repository")
2446
+ connection = repository.get("pullRequests") if isinstance(repository, dict) else None
2447
+ nodes = connection.get("nodes") if isinstance(connection, dict) else None
2448
+ if not isinstance(nodes, list):
2449
+ raise GitHubError(f"{what}: no pull-request nodes: {data!r:.200}")
2450
+ found: list[tuple[str, int]] = []
2451
+ for node in nodes:
2452
+ row = _row_object(node, what)
2453
+ url = _text_field(row, "url", what)
2454
+ try:
2455
+ ref = parse_pr_url(url)
2456
+ except ConfigurationError as exc:
2457
+ raise GitHubError(f"{what}: url {url!r} is not a pull request URL") from exc
2458
+ commits = row.get("commits")
2459
+ heads = commits.get("nodes") if isinstance(commits, dict) else None
2460
+ if not isinstance(heads, list):
2461
+ raise GitHubError(f"{what}: {ref.canonical} has no commit nodes")
2462
+ count = 0
2463
+ for head in heads:
2464
+ commit = head.get("commit") if isinstance(head, dict) else None
2465
+ rollup = commit.get("statusCheckRollup") if isinstance(commit, dict) else None
2466
+ contexts = rollup.get("contexts") if isinstance(rollup, dict) else None
2467
+ total = contexts.get("totalCount") if isinstance(contexts, dict) else 0
2468
+ if isinstance(total, int) and not isinstance(total, bool):
2469
+ count = max(count, total)
2470
+ if count > 0:
2471
+ found.append((ref.canonical, count))
2472
+ return found
2473
+
2474
+ def get_pr_changed_files(self, url: str) -> ChangedFiles:
2475
+ """Every file the PR changes, a rename's former path included.
2476
+
2477
+ Read through the REST files endpoint rather than ``gh pr view --json
2478
+ files``: the GraphQL type behind that flag carries a file's *current*
2479
+ path only, so a renamed file appears there under its new name alone.
2480
+ A caller refusing on protected paths would then miss a PR that renames
2481
+ a protected file *out* of the protected range -- the same edit
2482
+ dressed differently. REST names the other end ``previous_filename``.
2483
+
2484
+ Every page is read (``gh api --paginate --slurp``), so the listing
2485
+ is short only past GitHub's own 3000-file ceiling on this endpoint,
2486
+ not past one page of it.
2487
+
2488
+ The count comes from ``gh pr view`` because REST has none, and is read
2489
+ *after* the listing so that a file added between the two reads makes
2490
+ the listing look short (fail closed) rather than complete. Missing,
2491
+ non-list or unusable data raises GitHubError; a short listing is
2492
+ reported as incomplete rather than as "these are all the files".
2493
+ """
2494
+ ref = parse_pr_url(url)
2495
+ endpoint = f"repos/{ref.repository}/pulls/{ref.number}/files"
2496
+ raw = self._api_pages(f"{endpoint}?per_page={_PR_FILES_PAGE_SIZE}")
2497
+ files: list[ChangedFile] = []
2498
+ for entry in raw:
2499
+ path = entry.get("filename") if isinstance(entry, dict) else None
2500
+ previous = entry.get("previous_filename") if isinstance(entry, dict) else None
2501
+ bad_previous = previous is not None and (not isinstance(previous, str) or not previous)
2502
+ if not isinstance(path, str) or not path or bad_previous:
2503
+ raise GitHubError(
2504
+ f"changed files of {ref.canonical} contain an unusable entry: {entry!r}"
2505
+ )
2506
+ files.append(ChangedFile(path=path, previous_path=previous or ""))
2507
+ data = self._api_json(["pr", "view", ref.canonical, "--json", "changedFiles"])
2508
+ total = data.get("changedFiles")
2509
+ if isinstance(total, bool) or not isinstance(total, int) or total < 0:
2510
+ raise GitHubError(f"changedFiles of {ref.canonical} is not a count: {total!r}")
2511
+ return ChangedFiles(files=tuple(files), total=total)
2512
+
2513
+ def get_pr_close_event_count(self, url: str) -> int:
2514
+ """How many times the PR has been closed so far, from its issue events.
2515
+
2516
+ The replan transaction records this immediately before it persists
2517
+ its close intent (#69). Issue events are append-only: GitHub writes a
2518
+ ``closed`` event for every close (a merge included) and no actor can
2519
+ delete one, so a count that is higher on a later read proves a close
2520
+ happened in between, and an equal count proves none did -- whoever
2521
+ would have performed it. That is what lets a resume that finds the
2522
+ source OPEN under a recorded intent distinguish "the close never
2523
+ ran" from "it landed, lost its receipt to a crash, and a human
2524
+ reopened the PR" without comparing clocks and without turning the
2525
+ absence of a comment into a licence to close.
2526
+
2527
+ Read through the REST issue-events endpoint (a PR is an issue there),
2528
+ every page (``gh api --paginate --slurp``), and counted strictly: an
2529
+ entry without a string ``event`` name is a malformed answer, refused
2530
+ rather than skipped, because a skipped event would lower the count
2531
+ and a lower count is what licenses the retry.
2532
+ """
2533
+ ref = parse_pr_url(url)
2534
+ endpoint = f"repos/{ref.repository}/issues/{ref.number}/events"
2535
+ count = 0
2536
+ for entry in self._api_pages(f"{endpoint}?per_page={_EVENTS_PAGE_SIZE}"):
2537
+ kind = entry.get("event") if isinstance(entry, dict) else None
2538
+ if not isinstance(kind, str) or not kind:
2539
+ raise GitHubError(
2540
+ f"issue events of {ref.canonical} contain an unusable entry: {entry!r:.200}"
2541
+ )
2542
+ if kind == "closed":
2543
+ count += 1
2544
+ return count
2545
+
2546
+ def get_pr_merge_queue_status(self, url: str) -> MergeQueueStatus:
2547
+ """Whether the PR's base branch requires a merge queue / the PR is enqueued.
2548
+
2549
+ ``gh pr merge`` silently switches to auto-merge or queue insertion on
2550
+ such branches, so the engine must know this *before* writing. Missing
2551
+ or non-boolean data raises GitHubError (fail closed).
2552
+ """
2553
+ ref = parse_pr_url(url)
2554
+ owner, _, name = ref.repository.partition("/")
2555
+ data = self._api_json(
2556
+ [
2557
+ "api",
2558
+ "graphql",
2559
+ "-f",
2560
+ f"query={_MERGE_QUEUE_QUERY}",
2561
+ "-F",
2562
+ f"owner={owner}",
2563
+ "-F",
2564
+ f"name={name}",
2565
+ "-F",
2566
+ f"number={ref.number}",
2567
+ ]
2568
+ )
2569
+ pr = ((data.get("data") or {}).get("repository") or {}).get("pullRequest")
2570
+ if not isinstance(pr, dict):
2571
+ raise GitHubError(f"merge-queue status for {ref.canonical} unavailable: {data}")
2572
+ enabled = pr.get("isMergeQueueEnabled")
2573
+ in_queue = pr.get("isInMergeQueue")
2574
+ if not isinstance(enabled, bool) or not isinstance(in_queue, bool):
2575
+ raise GitHubError(f"merge-queue status for {ref.canonical} is not boolean: {pr}")
2576
+ return MergeQueueStatus(enabled=enabled, in_queue=in_queue)
2577
+
2578
+ # -- GitHub Actions (the definition behind a required check) -------------
2579
+ def _run_from_data(self, data: object, what: str) -> WorkflowRunInfo:
2580
+ if not isinstance(data, dict):
2581
+ raise GitHubError(f"{what} is not an object: {data!r:.200}")
2582
+ repo = data.get("repository")
2583
+ full_name = repo.get("full_name") if isinstance(repo, dict) else None
2584
+ run_id = data.get("id")
2585
+ workflow_id = data.get("workflow_id")
2586
+ attempt = data.get("run_attempt", 1)
2587
+ head_sha = data.get("head_sha")
2588
+ for name, value in (("id", run_id), ("workflow_id", workflow_id), ("run_attempt", attempt)):
2589
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
2590
+ raise GitHubError(f"{what} has no usable {name}: {value!r}")
2591
+ assert isinstance(run_id, int) and isinstance(workflow_id, int)
2592
+ assert isinstance(attempt, int)
2593
+ if not isinstance(full_name, str) or not full_name:
2594
+ raise GitHubError(f"{what} names no repository: {repo!r:.200}")
2595
+ if not isinstance(head_sha, str) or not head_sha:
2596
+ raise GitHubError(f"{what} has no head_sha: {head_sha!r}")
2597
+ text = {
2598
+ key: value if isinstance(value := data.get(key), str) else ""
2599
+ for key in ("path", "event", "head_branch", "status", "conclusion")
2600
+ }
2601
+ if not text["path"] or not text["status"]:
2602
+ raise GitHubError(f"{what} has no workflow path or status: {data!r:.200}")
2603
+ return WorkflowRunInfo(
2604
+ id=run_id,
2605
+ repository=full_name,
2606
+ path=text["path"],
2607
+ workflow_id=workflow_id,
2608
+ event=text["event"],
2609
+ head_sha=head_sha.lower(),
2610
+ head_branch=text["head_branch"],
2611
+ status=text["status"],
2612
+ conclusion=text["conclusion"],
2613
+ run_attempt=attempt,
2614
+ )
2615
+
2616
+ def get_workflow_run(self, repository: str, run_id: int) -> WorkflowRunInfo:
2617
+ """One workflow run by id, in the repository the check's URL names."""
2618
+ what = f"workflow run {run_id} of {repository}"
2619
+ data = self._api_json(["api", f"repos/{repository}/actions/runs/{run_id}"])
2620
+ return self._run_from_data(data, what)
2621
+
2622
+ def get_workflow_run_jobs(self, repository: str, run_id: int) -> WorkflowRunJobs:
2623
+ """Every job of the run's latest attempt with its step names, all pages read.
2624
+
2625
+ The endpoint answers an object per page (``total_count`` + ``jobs``);
2626
+ the count comes from GitHub, so a listing that does not reach it is
2627
+ reported as incomplete rather than as "these are all the jobs". A job
2628
+ without a usable name, or a step without one, is malformed data
2629
+ (GitHubError): the comparison built on it would compare nothing.
2630
+ """
2631
+ what = f"the jobs of workflow run {run_id} of {repository}"
2632
+ endpoint = f"repos/{repository}/actions/runs/{run_id}/jobs?per_page={_JOBS_PAGE_SIZE}"
2633
+ pages = self._json(["api", "--paginate", "--slurp", endpoint])
2634
+ if not isinstance(pages, list) or not all(isinstance(page, dict) for page in pages):
2635
+ raise GitHubError(f"{what}: non-paginated JSON payload: {pages!r:.200}")
2636
+ total = -1
2637
+ jobs: list[WorkflowJob] = []
2638
+ for page in pages:
2639
+ count = page.get("total_count")
2640
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
2641
+ raise GitHubError(f"{what}: total_count is not a count: {count!r}")
2642
+ total = max(total, count)
2643
+ listed = page.get("jobs")
2644
+ if not isinstance(listed, list):
2645
+ raise GitHubError(f"{what}: jobs is not a list: {listed!r:.200}")
2646
+ for job in listed:
2647
+ name = job.get("name") if isinstance(job, dict) else None
2648
+ steps = job.get("steps") if isinstance(job, dict) else None
2649
+ if not isinstance(name, str) or not name or not isinstance(steps, list):
2650
+ raise GitHubError(f"{what}: unusable job entry: {job!r:.200}")
2651
+ names: list[str] = []
2652
+ for step in steps:
2653
+ step_name = step.get("name") if isinstance(step, dict) else None
2654
+ if not isinstance(step_name, str) or not step_name:
2655
+ raise GitHubError(
2656
+ f"{what}: job {name!r} has an unusable step: {step!r:.200}"
2657
+ )
2658
+ names.append(step_name)
2659
+ jobs.append(WorkflowJob(name=name, steps=tuple(names)))
2660
+ if total < 0:
2661
+ raise GitHubError(f"{what}: no page was returned")
2662
+ return WorkflowRunJobs(jobs=tuple(jobs), total=total)
2663
+
2664
+ def get_branch_head_sha(self, repository: str, branch: str) -> str:
2665
+ """The commit a branch currently points at (lower-case SHA)."""
2666
+ data = self._api_json(["api", f"repos/{repository}/branches/{branch}"])
2667
+ commit = data.get("commit")
2668
+ sha = commit.get("sha") if isinstance(commit, dict) else None
2669
+ if not isinstance(sha, str) or not sha:
2670
+ raise GitHubError(f"branch {branch!r} of {repository} has no readable head SHA")
2671
+ return sha.lower()
2672
+
2673
+ def get_branch_ref(self, repository: str, branch: str) -> str | None:
2674
+ """The commit ``refs/heads/<branch>`` of ``repository`` points at; None when absent.
2675
+
2676
+ One exact ref, through ``git/ref/heads/<branch>``: the singular
2677
+ endpoint answers that ref or a 404, where the plural one answers a
2678
+ prefix with every ref under it. A 404 is the branch not existing
2679
+ (the caller reads a repository it can read). An answer naming another
2680
+ ref, or a ref that is not a commit, is a conclusive GitHubError,
2681
+ never "absent" (#283).
2682
+ """
2683
+ what = f"branch {branch!r} of {repository}"
2684
+ path = f"repos/{_repository_path(repository)}/git/ref/heads/{_branch_path(branch, what)}"
2685
+ try:
2686
+ data = self._api_json(["api", path])
2687
+ except GitHubNotFoundError:
2688
+ return None
2689
+ target = data.get("object")
2690
+ sha = target.get("sha") if isinstance(target, dict) else None
2691
+ if (
2692
+ data.get("ref") != f"refs/heads/{branch}"
2693
+ or not isinstance(target, dict)
2694
+ or target.get("type") != "commit"
2695
+ or not isinstance(sha, str)
2696
+ or not _FULL_SHA_RE.fullmatch(sha.lower())
2697
+ ):
2698
+ raise GitHubError(f"{what}: GitHub answered with another ref, or an unreadable one")
2699
+ return sha.lower()
2700
+
2701
+ def get_merge_base_sha(self, repository: str, base_ref: str, head_sha: str) -> str:
2702
+ """The merge base of a base branch and a commit (lower-case SHA).
2703
+
2704
+ This is the commit a PR diff is computed against, so it is what a
2705
+ review is bound to (#96): a base branch rewritten under the same
2706
+ name (force-push, reset) moves it while the base *name* and the PR
2707
+ HEAD stay put; ordinary commits landing on the base do not move it.
2708
+
2709
+ Read through the compare endpoint, projected with ``--jq`` before it
2710
+ reaches the controller: the full response carries every commit and
2711
+ every file patch between the two revisions, which for a long-lived
2712
+ base can exceed the executor's output bound, and truncated `gh`
2713
+ output is refused rather than parsed. The base is percent-encoded
2714
+ (git allows ``#`` and ``%`` in a ref name) and the head is the
2715
+ reviewed commit itself, never a branch name, so the answer cannot
2716
+ change under the read.
2717
+ """
2718
+ if not _SHA40_RE.fullmatch(head_sha.lower()):
2719
+ raise GitHubError(f"merge base of {base_ref!r}: head {head_sha!r} is not a full SHA")
2720
+ data = self._api_json(
2721
+ [
2722
+ "api",
2723
+ f"repos/{repository}/compare/{quote(base_ref, safe='')}...{head_sha.lower()}",
2724
+ "--jq",
2725
+ "{merge_base_sha: .merge_base_commit.sha}",
2726
+ ]
2727
+ )
2728
+ sha = data.get("merge_base_sha")
2729
+ if not isinstance(sha, str) or not _SHA40_RE.fullmatch(sha.lower()):
2730
+ raise GitHubError(
2731
+ f"merge base of {base_ref!r} and {head_sha[:12]} of {repository} is unreadable:"
2732
+ f" {sha!r:.100}"
2733
+ )
2734
+ return sha.lower()
2735
+
2736
+ def find_workflow_runs(
2737
+ self, repository: str, workflow_id: int, *, branch: str, event: str, head_sha: str
2738
+ ) -> WorkflowRuns:
2739
+ """Runs of one workflow filtered by branch, event and head commit, all pages read.
2740
+
2741
+ The runs of a single commit on a single branch are normally one, but
2742
+ the caller picks a *reference* out of them, so the listing is read
2743
+ like the jobs listing: every page, against GitHub's own
2744
+ ``total_count``, and a listing that does not reach it is reported as
2745
+ incomplete rather than as "these are all the runs".
2746
+ """
2747
+ query = urlencode(
2748
+ {"branch": branch, "event": event, "head_sha": head_sha, "per_page": _RUNS_PAGE_SIZE}
2749
+ )
2750
+ what = f"the runs of workflow {workflow_id} of {repository}"
2751
+ endpoint = f"repos/{repository}/actions/workflows/{workflow_id}/runs?{query}"
2752
+ pages = self._json(["api", "--paginate", "--slurp", endpoint])
2753
+ if not isinstance(pages, list) or not all(isinstance(page, dict) for page in pages):
2754
+ raise GitHubError(f"{what}: non-paginated JSON payload: {pages!r:.200}")
2755
+ total = -1
2756
+ runs: list[WorkflowRunInfo] = []
2757
+ for page in pages:
2758
+ count = page.get("total_count")
2759
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
2760
+ raise GitHubError(f"{what}: total_count is not a count: {count!r}")
2761
+ total = max(total, count)
2762
+ listed = page.get("workflow_runs")
2763
+ if not isinstance(listed, list):
2764
+ raise GitHubError(f"{what}: workflow_runs is not a list: {listed!r:.200}")
2765
+ runs.extend(self._run_from_data(run, f"a run in {what}") for run in listed)
2766
+ if total < 0:
2767
+ raise GitHubError(f"{what}: no page was returned")
2768
+ return WorkflowRuns(runs=tuple(runs), total=total)
2769
+
2770
+ def pr_exists(self, url: str) -> bool:
2771
+ """Whether GitHub resolves the PR: False only for a conclusive not-found.
2772
+
2773
+ Any other failure propagates: a refused read (authentication,
2774
+ permissions, a refused checks read, #291), malformed data or a
2775
+ transient error says nothing about whether the PR exists, so it is
2776
+ never reported as its absence.
2777
+ """
2778
+ try:
2779
+ self.get_pr(url)
2780
+ return True
2781
+ except GitHubNotFoundError:
2782
+ return False
2783
+
2784
+ def _walk_connection(
2785
+ self,
2786
+ query: str,
2787
+ variables: list[str],
2788
+ connection_of: Callable[[dict, str], dict],
2789
+ what: str,
2790
+ ) -> Iterator[object]:
2791
+ """Every node of one GraphQL connection, read by cursor to its end.
2792
+
2793
+ ``variables`` are the query's ``-f``/``-F`` arguments; the walk adds
2794
+ ``after``. ``connection_of`` picks the connection out of a page's
2795
+ ``data``, raising (its message led by the page's name) when the page
2796
+ does not hold one. Nodes are yielded page by page, as read and
2797
+ undecoded. Pages are walked until GitHub says there is no next one;
2798
+ a page GitHub answered with errors (so possibly partial), a
2799
+ connection without usable ``nodes`` and ``pageInfo``, a next page
2800
+ GitHub announces without a cursor to reach it, or a cursor the walk
2801
+ already used (a cycle) is a conclusive GitHubError, never a shorter
2802
+ listing.
2803
+ """
2804
+ after: str | None = None
2805
+ # Every cursor the walk has already asked for. A cursor that comes
2806
+ # back a second time, however many pages later, would walk a cycle
2807
+ # forever; the walk terminates only because each page's cursor is new.
2808
+ used_cursors: set[str] = set()
2809
+ page_number = 0
2810
+ while True:
2811
+ page_number += 1
2812
+ args = ["api", "graphql", "-f", f"query={query}", *variables]
2813
+ if after is not None:
2814
+ args += ["-f", f"after={after}"]
2815
+ page_what = f"{what}, page {page_number}"
2816
+ connection = connection_of(self._graphql_data(args, page_what), page_what)
2817
+ nodes = connection.get("nodes")
2818
+ info = connection.get("pageInfo")
2819
+ if not isinstance(nodes, list) or not isinstance(info, dict):
2820
+ raise GitHubError(
2821
+ f"{page_what} has no usable nodes or pageInfo: {connection!r:.200}"
2822
+ )
2823
+ yield from nodes
2824
+ has_next = info.get("hasNextPage")
2825
+ if not isinstance(has_next, bool):
2826
+ raise GitHubError(
2827
+ f"{page_what} does not say whether a next page exists: {info!r:.200}"
2828
+ )
2829
+ if not has_next:
2830
+ return
2831
+ cursor = info.get("endCursor")
2832
+ if not isinstance(cursor, str) or not cursor:
2833
+ raise GitHubError(
2834
+ f"{what} cannot be read to its end: page {page_number} announces a "
2835
+ f"next page but no cursor that reaches it ({cursor!r})"
2836
+ )
2837
+ if cursor in used_cursors:
2838
+ raise GitHubError(
2839
+ f"{what} cannot be read to its end: page {page_number} announces a "
2840
+ f"next page behind a cursor the walk already used ({cursor!r})"
2841
+ )
2842
+ used_cursors.add(cursor)
2843
+ after = cursor
2844
+
2845
+ def list_open_prs(self, repo: str) -> list[PRInfo]:
2846
+ """Every open PR in ``repo`` (bodies included), read to the end.
2847
+
2848
+ The listing is complete or it raises: every caller decides "no
2849
+ candidate exists" on it (an ANALYZE_EXECUTE entry launching an
2850
+ implementer, a replan concluding no replacement was created), and
2851
+ "the candidate was past a limit" must never be reported as absence
2852
+ (issue #20). The pages are walked by cursor
2853
+ (:meth:`_walk_connection`), so a page that cannot be read or walked
2854
+ past is a conclusive GitHubError, never a shorter list. A PR seen
2855
+ twice (the listing moved under the walk) is kept once.
2856
+ """
2857
+ what = f"open PR listing of {repo}"
2858
+ owner, _, name = repo.partition("/")
2859
+
2860
+ def pull_requests(data: dict, page_what: str) -> dict:
2861
+ repository = data.get("repository")
2862
+ if not isinstance(repository, dict):
2863
+ raise GitHubError(f"{page_what}: repository is not readable: {repository!r:.200}")
2864
+ connection = repository.get("pullRequests")
2865
+ if not isinstance(connection, dict):
2866
+ raise GitHubError(
2867
+ f"{page_what} is not a pull-request connection: {connection!r:.200}"
2868
+ )
2869
+ return connection
2870
+
2871
+ # `-f` sends each variable as a string. `-F` would type-coerce it: an
2872
+ # all-digit owner login would become an integer and a repository
2873
+ # named `null`, `true` or `false` a null or boolean, which GitHub
2874
+ # refuses for a `String!` variable.
2875
+ variables = ["-f", f"owner={owner}", "-f", f"name={name}"]
2876
+ prs: list[PRInfo] = []
2877
+ seen: set[int] = set()
2878
+ for node in self._walk_connection(_OPEN_PR_PAGE_QUERY, variables, pull_requests, what):
2879
+ row = _row_object(node, what)
2880
+ # `gh pr list --json` flattens this connection to its nodes; the
2881
+ # decoder reads that shape. The connection is validated here,
2882
+ # before flattening: a malformed one must not read as "this PR
2883
+ # closes no issue".
2884
+ linked = _linked_issue_nodes(row.get("closingIssuesReferences"), what)
2885
+ pr = self._pr_from_data(dict(row, closingIssuesReferences=linked), what)
2886
+ if pr.number in seen:
2887
+ continue
2888
+ seen.add(pr.number)
2889
+ prs.append(pr)
2890
+ return prs
2891
+
2892
+ def list_all_prs(self, repo: str, *, strict: bool = False) -> list[PRInfo]:
2893
+ """Every PR in ``repo`` across all states (bodies included).
2894
+
2895
+ Needed where "no candidate exists" must be proven, not assumed: a
2896
+ replacement the agent created and that was then closed is invisible to
2897
+ :meth:`list_open_prs`, and treating it as absent would start a second
2898
+ implementation attempt. ``strict`` refuses a possibly-truncated
2899
+ listing instead of reporting it as complete.
2900
+ """
2901
+ limit = STRICT_PR_LIST_LIMIT if strict else 100
2902
+ data = self._api_list(
2903
+ [
2904
+ "pr",
2905
+ "list",
2906
+ "--repo",
2907
+ repo,
2908
+ "--state",
2909
+ "all",
2910
+ "--limit",
2911
+ str(limit),
2912
+ "--json",
2913
+ _PR_LIST_FIELDS,
2914
+ ]
2915
+ )
2916
+ if strict and len(data) >= limit:
2917
+ raise GitHubError(
2918
+ f"{repo} has at least {limit} pull requests, so the listing may be "
2919
+ "truncated and the set of candidates cannot be established"
2920
+ )
2921
+ return [self._pr_from_data(d, f"PR listing of {repo}") for d in data]
2922
+
2923
+ def latest_pr_number(self, repo: str) -> int:
2924
+ """The largest PR number that currently exists in ``repo`` (0 if none).
2925
+
2926
+ Used as a provenance watermark: a PR whose number is <= the value read
2927
+ before a replan transaction id was generated cannot have been created
2928
+ after it, so it can never be that transaction's replacement even if the
2929
+ marker is later copied into its body.
2930
+
2931
+ Implemented as a proven numeric maximum over ``--state all`` (open,
2932
+ closed and merged alike), never inferred from one CREATED_AT-ordered
2933
+ node. A listing that reaches the ceiling, or malformed number data,
2934
+ raises GitHubError (fail closed) rather than returning an
2935
+ under-estimate that would let a pre-existing PR slip past the
2936
+ watermark.
2937
+ """
2938
+ data = self._api_list(
2939
+ [
2940
+ "pr",
2941
+ "list",
2942
+ "--repo",
2943
+ repo,
2944
+ "--state",
2945
+ "all",
2946
+ "--limit",
2947
+ str(STRICT_PR_LIST_LIMIT),
2948
+ "--json",
2949
+ _PR_NUMBER_LIST_FIELDS,
2950
+ ]
2951
+ )
2952
+ if len(data) >= STRICT_PR_LIST_LIMIT:
2953
+ raise GitHubError(
2954
+ f"{repo} has at least {STRICT_PR_LIST_LIMIT} pull requests, so the latest "
2955
+ "pull-request number cannot be established"
2956
+ )
2957
+ highest = 0
2958
+ for entry in data:
2959
+ if not isinstance(entry, dict):
2960
+ raise GitHubError(
2961
+ f"latest pull-request number for {repo} is not an object: {entry!r}"
2962
+ )
2963
+ number = entry.get("number")
2964
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
2965
+ raise GitHubError(
2966
+ f"latest pull-request number for {repo} is not a number: {entry!r}"
2967
+ )
2968
+ highest = max(highest, number)
2969
+ return highest
2970
+
2971
+ # -- effect identity reads (ADR 0004 D4.5, D7.5) -----------------------------
2972
+ def commit_in_history(self, repository: str, base_sha: str, commit_sha: str) -> bool:
2973
+ """Whether ``commit_sha`` is in the history of ``base_sha``, in GitHub's word (D7.5).
2974
+
2975
+ Read through the compare endpoint with ``base_sha`` as the base and
2976
+ the commit as the head, projected to its ``status`` with ``--jq``
2977
+ (the full answer carries every commit and patch between the two):
2978
+ ``behind`` or ``identical`` means the commit is an ancestor of, or
2979
+ equal to, the base; ``ahead`` or ``diverged`` means it is not. A 404
2980
+ means GitHub does not know the commit as part of this repository, so
2981
+ it is not in the history: that is a definitive answer, not a
2982
+ transient one. (GitHub also answers 404 for an unknown base or a
2983
+ repository it does not show; reading that as "not in the history"
2984
+ only widens what the caller has to publish.) Any other status is a
2985
+ conclusive GitHubError, never guessed.
2986
+
2987
+ Both SHAs must be full object ids (40 or 64 hex digits), checked
2988
+ before any process runs: a branch name or a short SHA could resolve
2989
+ differently between the read and its use.
2990
+ """
2991
+ what = f"history of {base_sha!r:.80} in {repository!r:.120}"
2992
+ base = _full_sha(base_sha, "base", what)
2993
+ commit = _full_sha(commit_sha, "commit", what)
2994
+ repo = _repository_path(repository)
2995
+ try:
2996
+ res = self._run_gh(
2997
+ ["api", f"repos/{repo}/compare/{base}...{commit}", "--jq", ".status"]
2998
+ )
2999
+ except GitHubNotFoundError:
3000
+ return False
3001
+ status = res.stdout.strip()
3002
+ if status in _IN_HISTORY_STATUSES:
3003
+ return True
3004
+ if status in _NOT_IN_HISTORY_STATUSES:
3005
+ return False
3006
+ raise GitHubError(
3007
+ f"{what}: the compare of {commit[:12]} answered an unknown status {status!r:.40}"
3008
+ )
3009
+
3010
+ def latest_issue_number(self, repository: str) -> int:
3011
+ """The largest issue *or pull-request* number in ``repository`` (0 if none).
3012
+
3013
+ The K5 provenance watermark, mirroring :meth:`latest_pr_number`: an
3014
+ issue whose number is <= the value read before a create cannot be
3015
+ the issue that create made. Issues and pull requests share one
3016
+ number sequence, and the REST issues listing returns both (a pull
3017
+ request is an issue there), so the maximum is taken over both: it is
3018
+ the last number GitHub handed out, whichever kind took it.
3019
+
3020
+ A proven numeric maximum over the all-states listing read to its
3021
+ end (``--paginate``, which, unlike ``gh pr list --limit``, has no
3022
+ ceiling that could cut it short), projected to the numbers with
3023
+ ``--jq`` so a large repository's bodies never reach the executor's
3024
+ output bound. Never inferred from creation order. A row without a
3025
+ positive integer number raises GitHubError (fail closed), as does a
3026
+ listing that cannot be read to its end.
3027
+ """
3028
+ repo = _repository_path(repository)
3029
+ endpoint = f"repos/{repo}/issues?" + urlencode({"state": "all", "per_page": 100})
3030
+ res = self._run_gh(["api", "--paginate", endpoint, "--jq", ".[].number"])
3031
+ highest = 0
3032
+ for line in res.stdout.splitlines():
3033
+ text = line.strip()
3034
+ if not text:
3035
+ continue
3036
+ if not text.isdigit() or not text.isascii() or int(text) < 1:
3037
+ raise GitHubError(
3038
+ f"latest issue number for {repo}: a listed number is not a positive "
3039
+ f"integer: {text!r:.40}"
3040
+ )
3041
+ highest = max(highest, int(text))
3042
+ return highest
3043
+
3044
+ def list_issues_above(self, repository: str, watermark: int) -> list[IssueInfo]:
3045
+ """Every issue of ``repository`` numbered above ``watermark``, in any state.
3046
+
3047
+ The K5 identity read (D4.5): the all-states listing read newest
3048
+ first (``sort=created``, ``direction=desc``) down to the watermark,
3049
+ never the search API. Pull requests, which the REST issues listing
3050
+ also returns (marked by a ``pull_request`` key), are not issues and
3051
+ are left out; they still mark where the walk is, since they share
3052
+ the number sequence.
3053
+
3054
+ GitHub orders by creation time to the second, so rows created in
3055
+ the same second may come in any order: the walk does not stop at
3056
+ the first row at or below the watermark, but reads on while the
3057
+ listing is still in that row's second, and keeps every issue above
3058
+ the watermark it meets. A listing that ends first is read to its
3059
+ end. The result is newest first. (An issue transferred in keeps its
3060
+ old creation time under a new number, so it may sort below the
3061
+ watermark's second and not be listed; it is not an issue the
3062
+ caller created.)
3063
+
3064
+ A malformed row, or a listing that cannot be read down to the
3065
+ watermark within ``_ISSUE_WALK_MAX_PAGES`` pages, raises GitHubError.
3066
+ """
3067
+ if isinstance(watermark, bool) or not isinstance(watermark, int) or watermark < 0:
3068
+ raise GitHubError(f"issues above {watermark!r:.40}: the watermark is not a number")
3069
+ repo = _repository_path(repository)
3070
+ what = f"issues of {repo} above #{watermark}"
3071
+ found: dict[int, IssueInfo] = {}
3072
+ boundary: datetime | None = None
3073
+ for page in range(1, _ISSUE_WALK_MAX_PAGES + 1):
3074
+ endpoint = f"repos/{repo}/issues?" + urlencode(
3075
+ {
3076
+ "state": "all",
3077
+ "sort": "created",
3078
+ "direction": "desc",
3079
+ "per_page": _ISSUE_PAGE_SIZE,
3080
+ "page": page,
3081
+ }
3082
+ )
3083
+ rows = [_listed_issue(row, what) for row in self._api_list(["api", endpoint])]
3084
+ for row in rows:
3085
+ if row.number <= watermark:
3086
+ if boundary is None:
3087
+ boundary = row.created_at
3088
+ continue
3089
+ if row.is_pull_request or row.number in found:
3090
+ continue
3091
+ issue = _decode_issue(_rest_issue_row(row.row), what)
3092
+ if issue.repository.lower() != repo.lower():
3093
+ raise GitHubError(f"{what}: the listing returned {issue.url}")
3094
+ found[row.number] = issue
3095
+ if len(rows) < _ISSUE_PAGE_SIZE:
3096
+ break
3097
+ if boundary is not None and rows[-1].created_at < boundary:
3098
+ break
3099
+ else:
3100
+ raise GitHubError(
3101
+ f"{what}: the listing cannot be read down to the watermark within "
3102
+ f"{_ISSUE_WALK_MAX_PAGES * _ISSUE_PAGE_SIZE} rows"
3103
+ )
3104
+ return sorted(found.values(), key=lambda issue: issue.number, reverse=True)
3105
+
3106
+ def list_prs_for_head(self, repository: str, branch: str) -> list[PRInfo]:
3107
+ """Every PR of ``repository`` headed at ``branch`` of the same repository, any state.
3108
+
3109
+ The K2/K7 identity read (D4.5): the REST pulls listing filtered by
3110
+ ``head=<owner>:<branch>`` and ``state=all``, read to the end, so it
3111
+ sees closed and merged PRs too; never the search API. A row the
3112
+ filter returns that is not headed at ``branch`` of this repository
3113
+ (a fork's PR, or a PR whose head repository is gone) is left out.
3114
+
3115
+ The rows go through the one PR decoder; the REST listing carries
3116
+ neither closing-issue references, mergeability nor checks, so those
3117
+ are empty (``checks`` None, not read): a caller that needs them reads
3118
+ the PR with :meth:`get_pr`, or :meth:`get_pr_with_checks` for the checks.
3119
+ """
3120
+ repo = _repository_path(repository)
3121
+ if not isinstance(branch, str) or not branch.strip() or ":" in branch:
3122
+ raise GitHubError(f"PRs of {repo}: head branch {branch!r:.120} is not a branch name")
3123
+ owner = repo.split("/", 1)[0]
3124
+ what = f"PRs of {repo} headed at {branch!r:.120}"
3125
+ endpoint = f"repos/{repo}/pulls?" + urlencode(
3126
+ {"state": "all", "head": f"{owner}:{branch}", "per_page": _HEAD_PR_PAGE_SIZE}
3127
+ )
3128
+ prs: list[PRInfo] = []
3129
+ for row in self._api_pages(endpoint):
3130
+ pr = self._pr_from_data(_rest_pr_row(row, what), what)
3131
+ if pr.repository.lower() != repo.lower():
3132
+ raise GitHubError(f"{what}: the listing returned {pr.url}")
3133
+ if pr.head_ref == branch and pr.head_repository.lower() == repo.lower():
3134
+ prs.append(pr)
3135
+ return sorted(prs, key=lambda pr: pr.number, reverse=True)
3136
+
3137
+ # -- typed effect writes (ADR 0004 D12.2) ------------------------------------
3138
+ # One method per effect kind, each issued once outside the retry loop
3139
+ # (D4.3, :meth:`_run_gh_once`), its payload on stdin, the repository in
3140
+ # the API path (D7.4). The typed result is what GitHub's reply says it
3141
+ # wrote: informational, never the evidence; the caller reads it back.
3142
+ def create_issue_comment(self, issue_url: str, body: str) -> CreatedObject:
3143
+ """Post ``body`` as a new comment on an issue (K8)."""
3144
+ ref = parse_issue_url(issue_url)
3145
+ what = f"comment on {ref.canonical}"
3146
+ _check_write_bounds(what, body=body)
3147
+ path = f"repos/{_repository_path(ref.repository)}/issues/{ref.number}/comments"
3148
+ return _created_comment(self._write("POST", path, {"body": body}, what), ref, what)
3149
+
3150
+ def create_pr_comment(self, pr_url: str, body: str) -> CreatedObject:
3151
+ """Post ``body`` as a new comment on a pull request (K4).
3152
+
3153
+ A PR's conversation comments are issue comments to the REST API, so
3154
+ this is the issues comments endpoint with the PR's number.
3155
+ """
3156
+ ref = parse_pr_url(pr_url)
3157
+ what = f"comment on {ref.canonical}"
3158
+ _check_write_bounds(what, body=body)
3159
+ path = f"repos/{_repository_path(ref.repository)}/issues/{ref.number}/comments"
3160
+ return _created_comment(self._write("POST", path, {"body": body}, what), ref, what)
3161
+
3162
+ def create_pull_request(
3163
+ self, repository: str, *, base: str, head: str, title: str, body: str
3164
+ ) -> CreatedObject:
3165
+ """Open a PR from ``head`` onto ``base``, both branches of ``repository`` (K2, K7).
3166
+
3167
+ ``head`` is a branch of the same repository: an ``owner:branch``
3168
+ head would open a PR from another repository, so a ``:`` in it is
3169
+ refused (git forbids it in a branch name anyway). A 422 such as
3170
+ "a pull request already exists" is a conclusive GitHubError.
3171
+ """
3172
+ repo = _repository_path(repository)
3173
+ what = f"pull request {head!r:.120} -> {base!r:.120} in {repo}"
3174
+ for role, name in (("base", base), ("head", head)):
3175
+ if not isinstance(name, str) or not name.strip() or ":" in name:
3176
+ raise GitHubError(f"{what}: {role} {name!r:.120} is not a branch name")
3177
+ _check_write_bounds(what, body=body, title=title)
3178
+ payload = {"title": title, "head": head, "base": base, "body": body}
3179
+ reply = self._write("POST", f"repos/{repo}/pulls", payload, what)
3180
+ return _replied_object(reply, what, repository=repo, expect="pr")
3181
+
3182
+ def write_pr_body(self, pr_url: str, body: str) -> None:
3183
+ """Replace the whole body of a pull request (K3)."""
3184
+ ref = parse_pr_url(pr_url)
3185
+ what = f"body of {ref.canonical}"
3186
+ _check_write_bounds(what, body=body)
3187
+ path = f"repos/{_repository_path(ref.repository)}/pulls/{ref.number}"
3188
+ reply = self._write("PATCH", path, {"body": body}, what)
3189
+ _replied_object(reply, what, repository=ref.repository, expect="pr", number=ref.number)
3190
+
3191
+ def create_issue(self, repository: str, *, title: str, body: str) -> CreatedObject:
3192
+ """Open a new issue in ``repository`` (K5)."""
3193
+ repo = _repository_path(repository)
3194
+ what = f"new issue in {repo}"
3195
+ _check_write_bounds(what, body=body, title=title)
3196
+ reply = self._write("POST", f"repos/{repo}/issues", {"title": title, "body": body}, what)
3197
+ return _replied_object(reply, what, repository=repo, expect="issue")
3198
+
3199
+ def write_issue_body(self, issue_url: str, body: str) -> None:
3200
+ """Replace the whole body of an issue (K6).
3201
+
3202
+ Unlike :meth:`edit_issue_body`, the EPIC roadmap splice that keeps
3203
+ its own mechanism (D5.4), this is the typed effect write: issued
3204
+ once, payload on stdin, reply checked to name the issue.
3205
+ """
3206
+ ref = parse_issue_url(issue_url)
3207
+ what = f"body of {ref.canonical}"
3208
+ _check_write_bounds(what, body=body)
3209
+ path = f"repos/{_repository_path(ref.repository)}/issues/{ref.number}"
3210
+ reply = self._write("PATCH", path, {"body": body}, what)
3211
+ # A PR is an issue to this endpoint: the reply may name it by either path.
3212
+ _replied_object(reply, what, repository=ref.repository, expect=None, number=ref.number)
3213
+
3214
+ # -- merge (the only write; controller-owned, engine-gated) ------------------
3215
+ def merge_pr(self, url: str, method: str = "squash", match_head_sha: str = "") -> None:
3216
+ """Run ``gh pr merge`` bound to ``match_head_sha``.
3217
+
3218
+ Raises GitHubError when ``gh`` fails (conflict, branch protection,
3219
+ HEAD moved, permissions, ...). A normal return only means ``gh``
3220
+ exited 0: callers must re-read the PR and confirm ``state == MERGED``
3221
+ before treating the merge as done (merge queues / auto-merge may
3222
+ leave the PR open). No branch is deleted here (#283).
3223
+ """
3224
+ self._run_gh(build_merge_argv(url, method, match_head_sha))
3225
+
3226
+ def delete_branch_ref(self, repository: str, branch: str, *, default_branch: str) -> None:
3227
+ """Delete ``refs/heads/<branch>`` of ``repository`` on GitHub, and nothing else (#283).
3228
+
3229
+ The controller's one branch deletion, behind ``merge.delete_branch``:
3230
+ ``DELETE repos/<owner>/<repo>/git/refs/heads/<branch>``, a REST call
3231
+ that names its repository and its ref, so no local git runs and no
3232
+ local branch or worktree is touched. ``default_branch`` is the
3233
+ repository's default branch as the caller just read it from GitHub:
3234
+ that branch (in any letter case), and an empty answer, are refused
3235
+ before any process starts, so no caller reaches the default branch
3236
+ through this method. Sent exactly once, outside the transient retry
3237
+ (D4.3): an ambiguous outcome is reconciled by reading the ref back
3238
+ (:meth:`get_branch_ref`), never by sending the deletion again.
3239
+ """
3240
+ what = f"deleting branch {branch!r} of {repository}"
3241
+ path = f"repos/{_repository_path(repository)}/git/refs/heads/{_branch_path(branch, what)}"
3242
+ if not default_branch or branch.lower() == default_branch.lower():
3243
+ raise GitHubError(
3244
+ f"{what}: refused, the repository's default branch is "
3245
+ f"{default_branch or '(unknown)'!r}, and it is never deleted; nothing was sent"
3246
+ )
3247
+ self._run_gh_once(["api", "--method", "DELETE", path])
3248
+
3249
+ def disable_auto_merge(self, url: str) -> None:
3250
+ """Disarm GitHub auto-merge on ``url`` (``gh pr merge --disable-auto``).
3251
+
3252
+ Used only to undo an auto-merge that a controller ``merge_pr`` call
3253
+ left armed, so no unreviewed HEAD can merge later on its own.
3254
+ """
3255
+ self._run_gh(build_disable_auto_merge_argv(url))
3256
+
3257
+ def close_pr(self, url: str, comment: str) -> None:
3258
+ """Close an obsolete PR without merging or deleting any branch.
3259
+
3260
+ ``gh pr close`` takes no precondition -- GitHub has no conditional
3261
+ close -- so a caller that needs the close bound to a checkpoint must
3262
+ complete the comparison *after* the write and undo it with
3263
+ :meth:`reopen_pr` when the checkpoint no longer holds.
3264
+
3265
+ The comment posted here must NOT carry ownership proof: ``gh pr close
3266
+ --comment`` posts the comment as a separate effect before the close
3267
+ lands, so a receipt in it can predate the close and later be mistaken
3268
+ for proof that this transaction closed the PR. Ownership is posted
3269
+ afterwards with :meth:`comment_pr`, only after the close is observed.
3270
+ """
3271
+ ref = parse_pr_url(url)
3272
+ self._run_gh(["pr", "close", ref.canonical, "--comment", comment])
3273
+
3274
+ def comment_pr(self, url: str, body: str) -> None:
3275
+ """Post a follow-up comment on a PR (open or closed).
3276
+
3277
+ Used for the replan close receipt: posted only after the controller
3278
+ observed its own close landing, so presence of the receipt proves the
3279
+ close was this transaction's. Never posted by a resume that did not
3280
+ perform the close.
3281
+ """
3282
+ ref = parse_pr_url(url)
3283
+ self._run_gh(["pr", "comment", ref.canonical, "--body", body])
3284
+
3285
+ def reopen_pr(self, url: str, comment: str) -> None:
3286
+ """Undo a controller close (``gh pr reopen``).
3287
+
3288
+ The compensating half of :meth:`close_pr`: the only supported caller
3289
+ is a checkpointed close whose preconditions turned out to have moved
3290
+ inside the write window. The branch is never deleted by ``close_pr``,
3291
+ so the PR comes back with its commits and review history intact.
3292
+ """
3293
+ ref = parse_pr_url(url)
3294
+ self._run_gh(["pr", "reopen", ref.canonical, "--comment", comment])
3295
+
3296
+ # -- comments -----------------------------------------------------------
3297
+ def get_pr_comments(self, url: str) -> list[CommentInfo]:
3298
+ """Every conversation comment of a PR, oldest first (:meth:`_comments`)."""
3299
+ ref = parse_pr_url(url)
3300
+ return self._comments(_PR_COMMENT_PAGE_QUERY, "pullRequest", ref)
3301
+
3302
+ def get_issue_comments(self, url: str) -> list[CommentInfo]:
3303
+ """Every comment of an issue (an EPIC's progress comments), oldest first."""
3304
+ ref = parse_issue_url(url)
3305
+ return self._comments(_ISSUE_COMMENT_PAGE_QUERY, "issue", ref)
3306
+
3307
+ def _comments(self, query: str, parent: str, ref: GitHubRef) -> list[CommentInfo]:
3308
+ """The complete comment listing of ``ref`` (ADR 0004 D4.5), or a GitHubError.
3309
+
3310
+ Every identity read of a comment marker decides "absent" on this
3311
+ listing (a review comment, a progress comment, a replan close
3312
+ receipt), so it is read by cursor to its end
3313
+ (:meth:`_walk_connection`) and a listing that cannot be is an error,
3314
+ never the comments it did read. A missing issue or PR is
3315
+ GitHubNotFoundError, as GitHub's "Could not resolve" is classified.
3316
+ A comment seen twice is kept once.
3317
+ """
3318
+ parent_url = ref.canonical
3319
+ what = f"comments of {parent_url}"
3320
+ owner, _, name = ref.repository.partition("/")
3321
+
3322
+ def comments(data: dict, page_what: str) -> dict:
3323
+ repository = data.get("repository")
3324
+ if not isinstance(repository, dict):
3325
+ raise GitHubError(f"{page_what}: repository is not readable: {repository!r:.200}")
3326
+ holder = repository.get(parent)
3327
+ if not isinstance(holder, dict):
3328
+ raise GitHubError(f"{page_what}: {parent} is not readable: {holder!r:.200}")
3329
+ connection = holder.get("comments")
3330
+ if not isinstance(connection, dict):
3331
+ raise GitHubError(f"{page_what} is not a comment connection: {connection!r:.200}")
3332
+ return connection
3333
+
3334
+ # Strings as `-f`, like the open-PR walk; the number as `-F`, which
3335
+ # sends it as the integer the `Int!` variable needs.
3336
+ variables = ["-f", f"owner={owner}", "-f", f"name={name}", "-F", f"number={ref.number}"]
3337
+ found: list[CommentInfo] = []
3338
+ seen: set[int] = set()
3339
+ for node in self._walk_connection(query, variables, comments, what):
3340
+ comment = _decode_comment(node, parent_url)
3341
+ if comment.id in seen:
3342
+ continue
3343
+ seen.add(comment.id)
3344
+ found.append(comment)
3345
+ return found
3346
+
3347
+ def get_comment(self, comment_url: str) -> CommentInfo:
3348
+ """Fetch one issue-style comment by its HTML URL (``#issuecomment-<id>``).
3349
+
3350
+ The comment is addressed by its id alone (GitHub's comments API has
3351
+ no per-issue path), so the returned ``url`` and ``parent_url`` are
3352
+ what GitHub says the comment is on, which need not be the parent the
3353
+ requested URL named; the caller compares. A comment that no longer
3354
+ exists is a :class:`GitHubNotFoundError` (conclusive).
3355
+ """
3356
+ ref: GitHubCommentRef = parse_comment_url(comment_url)
3357
+ data = self._api_json(
3358
+ ["api", f"repos/{ref.parent.owner}/{ref.parent.repo}/issues/comments/{ref.comment_id}"]
3359
+ )
3360
+ return _decode_rest_comment(data, ref)
3361
+
3362
+ def get_comment_exact(self, comment_url: str) -> CommentInfo:
3363
+ """The comment as GitHub holds it, its body byte for byte (the exact read).
3364
+
3365
+ :meth:`get_pr_exact` for an issue-style comment. Unlike
3366
+ :meth:`get_comment`, an answer for a comment on another parent than
3367
+ the URL names is a conclusive GitHubError: the read-back compares a
3368
+ comment it found on that parent.
3369
+ """
3370
+ ref: GitHubCommentRef = parse_comment_url(comment_url)
3371
+ what = f"exact read of {ref.canonical}"
3372
+ path = f"repos/{_repository_path(ref.parent.repository)}/issues/comments/{ref.comment_id}"
3373
+ comment = _decode_rest_comment(self._exact_object(path, what), ref)
3374
+ if not parse_comment_url(comment.url).same_target(ref):
3375
+ raise GitHubError(f"{what}: GitHub answered with {comment.url}")
3376
+ return comment
3377
+
3378
+
3379
+ __all__ = [
3380
+ "EXACT_READ_ERROR_BYTES",
3381
+ "EXACT_READ_MAX_BYTES",
3382
+ "EXACT_READ_ROOT",
3383
+ "MAX_BODY_CHARS",
3384
+ "MAX_TITLE_CHARS",
3385
+ "MERGE_METHODS",
3386
+ "CheckInfo",
3387
+ "CommentInfo",
3388
+ "CreatedObject",
3389
+ "GitHubClient",
3390
+ "GitHubIssueRef",
3391
+ "GitHubPullRequestRef",
3392
+ "HttpsReply",
3393
+ "HttpsRequest",
3394
+ "IssueInfo",
3395
+ "MergeQueueStatus",
3396
+ "PRInfo",
3397
+ "RepoInfo",
3398
+ "build_disable_auto_merge_argv",
3399
+ "build_edit_issue_body_argv",
3400
+ "build_merge_argv",
3401
+ "https_get",
3402
+ ]