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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|