agentic-runner 2.6.0__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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote, urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from agentic_runner.integrations.github.auth import (
|
|
11
|
+
GitHubAppAuthenticationError,
|
|
12
|
+
GitHubAppAuthProvider,
|
|
13
|
+
)
|
|
14
|
+
from agentic_runner_contracts.github_port import (
|
|
15
|
+
ApprovalRequest,
|
|
16
|
+
ApprovalResponse,
|
|
17
|
+
ApprovalState,
|
|
18
|
+
BranchRequest,
|
|
19
|
+
BranchResponse,
|
|
20
|
+
CommentRequest,
|
|
21
|
+
CommentResponse,
|
|
22
|
+
MergeRequest,
|
|
23
|
+
MergeResponse,
|
|
24
|
+
PullRequestCloseRequest,
|
|
25
|
+
PullRequestCloseResponse,
|
|
26
|
+
PullRequestFilesRequest,
|
|
27
|
+
PullRequestFilesResponse,
|
|
28
|
+
PullRequestReadyRequest,
|
|
29
|
+
PullRequestReadyResponse,
|
|
30
|
+
PullRequestRequest,
|
|
31
|
+
PullRequestResponse,
|
|
32
|
+
PullRequestRetargetRequest,
|
|
33
|
+
PullRequestRetargetResponse,
|
|
34
|
+
PullRequestReviewRequest,
|
|
35
|
+
PullRequestReviewResponse,
|
|
36
|
+
RepositoryReadRequest,
|
|
37
|
+
RepositoryReadResponse,
|
|
38
|
+
ReviewRequest,
|
|
39
|
+
ReviewResponse,
|
|
40
|
+
is_human_reviewer,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_DEFAULT_API_BASE_URL = "https://api.github.com"
|
|
44
|
+
_REQUEST_TIMEOUT_SECONDS = 10.0
|
|
45
|
+
_REPO_PATH_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
46
|
+
_URL_CONTROL_CHARS = frozenset(chr(value) for value in range(0x20)) | {chr(0x7F)}
|
|
47
|
+
_GITHUB_BRANCH_REF_FORBIDDEN_CHARS = _URL_CONTROL_CHARS | set(" ?#~^*[:\\+")
|
|
48
|
+
_GITHUB_BRANCH_REF_FORBIDDEN_VALUES = frozenset(
|
|
49
|
+
{"-", "--all", "--delete", "--mirror", "all", "delete", "head", "mirror"}
|
|
50
|
+
)
|
|
51
|
+
_GITHUB_REVIEWS_PAGE_SIZE = 100
|
|
52
|
+
_GITHUB_PR_FILES_PAGE_SIZE = 100
|
|
53
|
+
_COMMIT_SHA_PATTERN = re.compile(r"^[0-9A-Fa-f]{40}$")
|
|
54
|
+
# REST cannot take a pull request out of draft — PATCH /pulls/{n} silently ignores
|
|
55
|
+
# `draft` — so the `pr.review` seam goes through the one API that supports it.
|
|
56
|
+
_MARK_READY_MUTATION = (
|
|
57
|
+
"mutation($pullRequestId: ID!) { "
|
|
58
|
+
"markPullRequestReadyForReview(input: {pullRequestId: $pullRequestId}) "
|
|
59
|
+
"{ pullRequest { number isDraft } } }"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GitHubAppClient:
|
|
64
|
+
"""Production GitHub App adapter with private auth transport helpers.
|
|
65
|
+
|
|
66
|
+
Branch, pull request, review request, and comment lifecycle methods use authenticated
|
|
67
|
+
GitHub REST calls including approval polling and merge gating.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
app_id: str | None = None,
|
|
73
|
+
installation_id: str | None = None,
|
|
74
|
+
private_key_pem: str | None = None,
|
|
75
|
+
api_base_url: str = _DEFAULT_API_BASE_URL,
|
|
76
|
+
*,
|
|
77
|
+
http_client: httpx.Client | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._configured = self._validate_configuration(app_id, installation_id, private_key_pem)
|
|
80
|
+
self._api_base_url = api_base_url.rstrip("/")
|
|
81
|
+
self._http_client = http_client or httpx.Client(timeout=_REQUEST_TIMEOUT_SECONDS)
|
|
82
|
+
self._auth_provider: GitHubAppAuthProvider | None = None
|
|
83
|
+
if self._configured:
|
|
84
|
+
assert app_id is not None
|
|
85
|
+
assert installation_id is not None
|
|
86
|
+
assert private_key_pem is not None
|
|
87
|
+
self._auth_provider = GitHubAppAuthProvider(
|
|
88
|
+
app_id=app_id,
|
|
89
|
+
installation_id=installation_id,
|
|
90
|
+
private_key_pem=private_key_pem,
|
|
91
|
+
api_base_url=api_base_url,
|
|
92
|
+
http_client=self._http_client,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _validate_configuration(
|
|
97
|
+
app_id: str | None, installation_id: str | None, private_key_pem: str | None
|
|
98
|
+
) -> bool:
|
|
99
|
+
provided_values = (app_id, installation_id, private_key_pem)
|
|
100
|
+
if all(value is None for value in provided_values):
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
missing_fields = [
|
|
104
|
+
field
|
|
105
|
+
for field, value in (
|
|
106
|
+
("app_id", app_id),
|
|
107
|
+
("installation_id", installation_id),
|
|
108
|
+
("private_key_pem", private_key_pem),
|
|
109
|
+
)
|
|
110
|
+
if value is None or value.strip() == ""
|
|
111
|
+
]
|
|
112
|
+
if missing_fields:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"GitHub App credentials are required and must be non-empty: "
|
|
115
|
+
+ ", ".join(missing_fields)
|
|
116
|
+
)
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def _ensure_configured(self) -> None:
|
|
120
|
+
if not self._configured:
|
|
121
|
+
raise GitHubAppAuthenticationError("GitHub App credentials are not configured")
|
|
122
|
+
|
|
123
|
+
def _generate_app_jwt(self) -> str:
|
|
124
|
+
self._ensure_configured()
|
|
125
|
+
if self._auth_provider is None:
|
|
126
|
+
raise GitHubAppAuthenticationError("GitHub App credentials are not configured")
|
|
127
|
+
return self._auth_provider.generate_app_jwt()
|
|
128
|
+
|
|
129
|
+
def _request_json(
|
|
130
|
+
self, method: str, path: str, *, json_body: dict[str, Any] | None = None
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
token = self._get_installation_token()
|
|
133
|
+
headers = self._github_headers(f"Bearer {token}")
|
|
134
|
+
return self._send_json_request(method, path, headers=headers, json_body=json_body)
|
|
135
|
+
|
|
136
|
+
def _get_installation_token(self) -> str:
|
|
137
|
+
self._ensure_configured()
|
|
138
|
+
if self._auth_provider is None:
|
|
139
|
+
raise GitHubAppAuthenticationError("GitHub App credentials are not configured")
|
|
140
|
+
return self._auth_provider.get_installation_token()
|
|
141
|
+
|
|
142
|
+
def installation_token_provider(self) -> Callable[[], str] | None:
|
|
143
|
+
"""Return a callable minting fresh installation tokens, or None if unconfigured.
|
|
144
|
+
|
|
145
|
+
The returned callable reuses this client's auth provider, so installation
|
|
146
|
+
tokens are cached and refreshed in one place across REST calls and git pushes.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
if self._auth_provider is None:
|
|
150
|
+
return None
|
|
151
|
+
return self._auth_provider.get_installation_token
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _github_headers(authorization: str) -> dict[str, str]:
|
|
155
|
+
return GitHubAppAuthProvider.github_headers(authorization)
|
|
156
|
+
|
|
157
|
+
def _send_json_request(
|
|
158
|
+
self,
|
|
159
|
+
method: str,
|
|
160
|
+
path: str,
|
|
161
|
+
*,
|
|
162
|
+
headers: dict[str, str],
|
|
163
|
+
json_body: dict[str, Any] | None = None,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
response_data = self._send_json_request_value(
|
|
166
|
+
method,
|
|
167
|
+
path,
|
|
168
|
+
headers=headers,
|
|
169
|
+
json_body=json_body,
|
|
170
|
+
)
|
|
171
|
+
if not isinstance(response_data, dict):
|
|
172
|
+
raise GitHubAppAuthenticationError(
|
|
173
|
+
"GitHub API response JSON object was invalid"
|
|
174
|
+
) from None
|
|
175
|
+
return response_data
|
|
176
|
+
|
|
177
|
+
def _send_json_request_value(
|
|
178
|
+
self,
|
|
179
|
+
method: str,
|
|
180
|
+
path: str,
|
|
181
|
+
*,
|
|
182
|
+
headers: dict[str, str],
|
|
183
|
+
json_body: dict[str, Any] | None = None,
|
|
184
|
+
) -> Any:
|
|
185
|
+
response = self._send_authenticated_request(
|
|
186
|
+
method,
|
|
187
|
+
path,
|
|
188
|
+
headers=headers,
|
|
189
|
+
json_body=json_body,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
193
|
+
raise GitHubAppAuthenticationError(
|
|
194
|
+
f"GitHub API request failed with status {response.status_code}"
|
|
195
|
+
) from None
|
|
196
|
+
|
|
197
|
+
return self._decode_json_response(response)
|
|
198
|
+
|
|
199
|
+
def _send_authenticated_request(
|
|
200
|
+
self,
|
|
201
|
+
method: str,
|
|
202
|
+
path: str,
|
|
203
|
+
*,
|
|
204
|
+
headers: dict[str, str],
|
|
205
|
+
json_body: dict[str, Any] | None = None,
|
|
206
|
+
) -> httpx.Response:
|
|
207
|
+
url = f"{self._api_base_url}/{path.lstrip('/')}"
|
|
208
|
+
try:
|
|
209
|
+
return self._http_client.request(
|
|
210
|
+
method,
|
|
211
|
+
url,
|
|
212
|
+
headers=headers,
|
|
213
|
+
json=json_body,
|
|
214
|
+
timeout=_REQUEST_TIMEOUT_SECONDS,
|
|
215
|
+
)
|
|
216
|
+
except httpx.HTTPError:
|
|
217
|
+
raise GitHubAppAuthenticationError("GitHub API request failed") from None
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _decode_json_response(response: httpx.Response) -> Any:
|
|
221
|
+
try:
|
|
222
|
+
response_data = response.json()
|
|
223
|
+
except ValueError:
|
|
224
|
+
raise GitHubAppAuthenticationError("GitHub API response was not valid JSON") from None
|
|
225
|
+
return response_data
|
|
226
|
+
|
|
227
|
+
def _request_json_list(self, method: str, path: str) -> list[Any]:
|
|
228
|
+
token = self._get_installation_token()
|
|
229
|
+
headers = self._github_headers(f"Bearer {token}")
|
|
230
|
+
response_data = self._send_json_request_value(method, path, headers=headers)
|
|
231
|
+
if not isinstance(response_data, list):
|
|
232
|
+
raise GitHubAppAuthenticationError(
|
|
233
|
+
"GitHub API response JSON array was invalid"
|
|
234
|
+
) from None
|
|
235
|
+
return response_data
|
|
236
|
+
|
|
237
|
+
def _request_json_list_pages(self, method: str, path: str) -> list[Any]:
|
|
238
|
+
token = self._get_installation_token()
|
|
239
|
+
headers = self._github_headers(f"Bearer {token}")
|
|
240
|
+
items: list[Any] = []
|
|
241
|
+
next_path: str | None = path
|
|
242
|
+
while next_path is not None:
|
|
243
|
+
response = self._send_authenticated_request(method, next_path, headers=headers)
|
|
244
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
245
|
+
raise GitHubAppAuthenticationError(
|
|
246
|
+
f"GitHub API request failed with status {response.status_code}"
|
|
247
|
+
) from None
|
|
248
|
+
response_data = self._decode_json_response(response)
|
|
249
|
+
if not isinstance(response_data, list):
|
|
250
|
+
raise GitHubAppAuthenticationError(
|
|
251
|
+
"GitHub API response JSON array was invalid"
|
|
252
|
+
) from None
|
|
253
|
+
items.extend(response_data)
|
|
254
|
+
next_path = self._next_page_path(response)
|
|
255
|
+
return items
|
|
256
|
+
|
|
257
|
+
def _request_response(
|
|
258
|
+
self, method: str, path: str, *, json_body: dict[str, Any] | None = None
|
|
259
|
+
) -> httpx.Response:
|
|
260
|
+
token = self._get_installation_token()
|
|
261
|
+
headers = self._github_headers(f"Bearer {token}")
|
|
262
|
+
return self._send_authenticated_request(method, path, headers=headers, json_body=json_body)
|
|
263
|
+
|
|
264
|
+
def create_branch(self, request: BranchRequest) -> BranchResponse:
|
|
265
|
+
base_ref_name = self._validated_github_branch_ref(request.base_ref)
|
|
266
|
+
branch_name = self._validated_github_branch_ref(request.branch)
|
|
267
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
268
|
+
encoded_base_ref = self._encoded_git_ref_path_value(base_ref_name)
|
|
269
|
+
base_ref = self._request_json(
|
|
270
|
+
"GET",
|
|
271
|
+
f"/repos/{repo_path}/git/ref/heads/{encoded_base_ref}",
|
|
272
|
+
)
|
|
273
|
+
base_sha = self._required_nested_str(base_ref, ("object", "sha"), "base ref sha")
|
|
274
|
+
response = self._request_response(
|
|
275
|
+
"POST",
|
|
276
|
+
f"/repos/{repo_path}/git/refs",
|
|
277
|
+
json_body={"ref": f"refs/heads/{branch_name}", "sha": base_sha},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
if response.status_code == 422 and self._is_already_exists_response(response):
|
|
281
|
+
return BranchResponse(
|
|
282
|
+
repo=request.repo,
|
|
283
|
+
branch=branch_name,
|
|
284
|
+
base_ref=base_ref_name,
|
|
285
|
+
created=False,
|
|
286
|
+
)
|
|
287
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
288
|
+
raise GitHubAppAuthenticationError(
|
|
289
|
+
f"GitHub API request failed with status {response.status_code}"
|
|
290
|
+
) from None
|
|
291
|
+
|
|
292
|
+
response_data = self._decode_json_response(response)
|
|
293
|
+
if not isinstance(response_data, dict):
|
|
294
|
+
raise GitHubAppAuthenticationError(
|
|
295
|
+
"GitHub API response JSON object was invalid"
|
|
296
|
+
) from None
|
|
297
|
+
self._required_str(response_data, "ref", "created ref")
|
|
298
|
+
return BranchResponse(
|
|
299
|
+
repo=request.repo,
|
|
300
|
+
branch=branch_name,
|
|
301
|
+
base_ref=base_ref_name,
|
|
302
|
+
created=True,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def create_or_update_pr(self, request: PullRequestRequest) -> PullRequestResponse:
|
|
306
|
+
branch_name = self._validated_github_branch_ref(request.branch)
|
|
307
|
+
base_name = self._validated_github_branch_ref(request.base)
|
|
308
|
+
owner, _repo_name = self._split_repo(request.repo)
|
|
309
|
+
repo_path = self._encoded_repo_path_from_parts(owner, _repo_name)
|
|
310
|
+
encoded_head = quote(f"{owner}:{branch_name}", safe="")
|
|
311
|
+
existing_pull_requests = self._request_json_list(
|
|
312
|
+
"GET",
|
|
313
|
+
f"/repos/{repo_path}/pulls?head={encoded_head}&state=open",
|
|
314
|
+
)
|
|
315
|
+
if existing_pull_requests:
|
|
316
|
+
pr_number = self._required_int_from_mapping(
|
|
317
|
+
existing_pull_requests[0], "number", "pull request number"
|
|
318
|
+
)
|
|
319
|
+
response_data = self._request_json(
|
|
320
|
+
"PATCH",
|
|
321
|
+
f"/repos/{repo_path}/pulls/{pr_number}",
|
|
322
|
+
json_body={
|
|
323
|
+
"base": base_name,
|
|
324
|
+
"title": request.title,
|
|
325
|
+
"body": request.body,
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
return self._to_pull_request_response(request.repo, response_data, created=False)
|
|
329
|
+
|
|
330
|
+
response_data = self._request_json(
|
|
331
|
+
"POST",
|
|
332
|
+
f"/repos/{repo_path}/pulls",
|
|
333
|
+
json_body={
|
|
334
|
+
"head": branch_name,
|
|
335
|
+
"base": base_name,
|
|
336
|
+
"title": request.title,
|
|
337
|
+
"body": request.body,
|
|
338
|
+
"draft": request.draft,
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
return self._to_pull_request_response(request.repo, response_data, created=True)
|
|
342
|
+
|
|
343
|
+
def mark_pr_ready_for_review(
|
|
344
|
+
self,
|
|
345
|
+
request: PullRequestReadyRequest,
|
|
346
|
+
) -> PullRequestReadyResponse:
|
|
347
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
348
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
349
|
+
pull_request = self._request_json("GET", f"/repos/{repo_path}/pulls/{pr_number}")
|
|
350
|
+
if not self._optional_bool(pull_request, "draft", default=False):
|
|
351
|
+
return PullRequestReadyResponse(
|
|
352
|
+
repo=request.repo,
|
|
353
|
+
pr_number=pr_number,
|
|
354
|
+
ready=True,
|
|
355
|
+
marked_ready=False,
|
|
356
|
+
)
|
|
357
|
+
node_id = self._required_str(pull_request, "node_id", "pull request node id")
|
|
358
|
+
response_data = self._request_json(
|
|
359
|
+
"POST",
|
|
360
|
+
"/graphql",
|
|
361
|
+
json_body={"query": _MARK_READY_MUTATION, "variables": {"pullRequestId": node_id}},
|
|
362
|
+
)
|
|
363
|
+
if response_data.get("errors"):
|
|
364
|
+
# GraphQL reports failures inside a 200, so the REST status check above is
|
|
365
|
+
# blind to them; without this a refused mutation would read as success and
|
|
366
|
+
# the gate would wait forever on a PR still in draft.
|
|
367
|
+
raise GitHubAppAuthenticationError(
|
|
368
|
+
"GitHub API request failed: markPullRequestReadyForReview was rejected"
|
|
369
|
+
) from None
|
|
370
|
+
return PullRequestReadyResponse(
|
|
371
|
+
repo=request.repo,
|
|
372
|
+
pr_number=pr_number,
|
|
373
|
+
ready=True,
|
|
374
|
+
marked_ready=True,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def close_pr(self, request: PullRequestCloseRequest) -> PullRequestCloseResponse:
|
|
378
|
+
"""Close a PR without merging it. The branch is deliberately left in place.
|
|
379
|
+
|
|
380
|
+
The ending an owner refusal or timeout at a PR verb produces (ADR-0011 s15): the
|
|
381
|
+
work survives for whoever picks it up, the org just never gets asked to review it.
|
|
382
|
+
A PR already merged or closed is reported as ``closed=False`` rather than PATCHed,
|
|
383
|
+
so the terminal activity is safe to retry.
|
|
384
|
+
"""
|
|
385
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
386
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
387
|
+
pull_request = self._request_json("GET", f"/repos/{repo_path}/pulls/{pr_number}")
|
|
388
|
+
state = self._required_str(pull_request, "state", "pull request state")
|
|
389
|
+
if state != "open":
|
|
390
|
+
return PullRequestCloseResponse(repo=request.repo, pr_number=pr_number, closed=False)
|
|
391
|
+
self._request_json(
|
|
392
|
+
"PATCH",
|
|
393
|
+
f"/repos/{repo_path}/pulls/{pr_number}",
|
|
394
|
+
json_body={"state": "closed"},
|
|
395
|
+
)
|
|
396
|
+
return PullRequestCloseResponse(repo=request.repo, pr_number=pr_number, closed=True)
|
|
397
|
+
|
|
398
|
+
def retarget_pr(self, request: PullRequestRetargetRequest) -> PullRequestRetargetResponse:
|
|
399
|
+
"""Move an open PR onto a new base (a stack's base merged, PRD issue 59). A PR
|
|
400
|
+
already merged or closed is reported ``retargeted=False`` rather than PATCHed."""
|
|
401
|
+
|
|
402
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
403
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
404
|
+
base_name = self._validated_github_branch_ref(request.base)
|
|
405
|
+
pull_request = self._request_json("GET", f"/repos/{repo_path}/pulls/{pr_number}")
|
|
406
|
+
state = self._required_str(pull_request, "state", "pull request state")
|
|
407
|
+
if state != "open":
|
|
408
|
+
return PullRequestRetargetResponse(
|
|
409
|
+
repo=request.repo, pr_number=pr_number, base=base_name, retargeted=False
|
|
410
|
+
)
|
|
411
|
+
self._request_json(
|
|
412
|
+
"PATCH", f"/repos/{repo_path}/pulls/{pr_number}", json_body={"base": base_name}
|
|
413
|
+
)
|
|
414
|
+
return PullRequestRetargetResponse(
|
|
415
|
+
repo=request.repo, pr_number=pr_number, base=base_name, retargeted=True
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def request_review(self, request: ReviewRequest) -> ReviewResponse:
|
|
419
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
420
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
421
|
+
response_data = self._request_json(
|
|
422
|
+
"POST",
|
|
423
|
+
f"/repos/{repo_path}/pulls/{pr_number}/requested_reviewers",
|
|
424
|
+
json_body={"reviewers": [request.reviewer]},
|
|
425
|
+
)
|
|
426
|
+
reviewers = self._reviewers_from_response(response_data)
|
|
427
|
+
return ReviewResponse(
|
|
428
|
+
repo=request.repo,
|
|
429
|
+
pr_number=pr_number,
|
|
430
|
+
reviewer=request.reviewer,
|
|
431
|
+
reviewers=reviewers,
|
|
432
|
+
requested=True,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def wait_for_approval(self, request: ApprovalRequest) -> ApprovalResponse:
|
|
436
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
437
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
438
|
+
approval, _head_sha = self._current_head_approval(request.repo, repo_path, pr_number)
|
|
439
|
+
return approval
|
|
440
|
+
|
|
441
|
+
def merge_pr(self, request: MergeRequest) -> MergeResponse:
|
|
442
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
443
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
444
|
+
approval, _approved_head_sha = self._current_head_approval(
|
|
445
|
+
request.repo, repo_path, pr_number
|
|
446
|
+
)
|
|
447
|
+
if approval.state != ApprovalState.APPROVED:
|
|
448
|
+
raise GitHubAppAuthenticationError(
|
|
449
|
+
"GitHub pull request merge requires approval"
|
|
450
|
+
) from None
|
|
451
|
+
expected_head_sha = self._validated_commit_sha(
|
|
452
|
+
request.expected_head_sha, "expected head commit SHA"
|
|
453
|
+
)
|
|
454
|
+
if expected_head_sha != approval.current_head_sha:
|
|
455
|
+
raise GitHubAppAuthenticationError(
|
|
456
|
+
"GitHub pull request merge expected head SHA did not match current head"
|
|
457
|
+
) from None
|
|
458
|
+
|
|
459
|
+
response_data = self._request_json(
|
|
460
|
+
"PUT",
|
|
461
|
+
f"/repos/{repo_path}/pulls/{pr_number}/merge",
|
|
462
|
+
json_body={"commit_title": request.commit_title, "sha": expected_head_sha},
|
|
463
|
+
)
|
|
464
|
+
merged = self._required_bool(response_data, "merged", "merge status")
|
|
465
|
+
merge_commit_sha = self._required_commit_sha(response_data, "sha", "merge commit SHA")
|
|
466
|
+
return MergeResponse(
|
|
467
|
+
repo=request.repo,
|
|
468
|
+
pr_number=pr_number,
|
|
469
|
+
commit_title=request.commit_title,
|
|
470
|
+
merged=merged,
|
|
471
|
+
merge_commit_sha=merge_commit_sha,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def post_comment(self, request: CommentRequest) -> CommentResponse:
|
|
475
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
476
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
477
|
+
response_data = self._request_json(
|
|
478
|
+
"POST",
|
|
479
|
+
f"/repos/{repo_path}/issues/{pr_number}/comments",
|
|
480
|
+
json_body={"body": request.body},
|
|
481
|
+
)
|
|
482
|
+
comment_number = self._required_int(response_data, "id", "comment id")
|
|
483
|
+
body = self._required_str(response_data, "body", "comment body")
|
|
484
|
+
return CommentResponse(
|
|
485
|
+
repo=request.repo,
|
|
486
|
+
pr_number=pr_number,
|
|
487
|
+
comment_number=comment_number,
|
|
488
|
+
body=body,
|
|
489
|
+
comments=(body,),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def submit_review(self, request: PullRequestReviewRequest) -> PullRequestReviewResponse:
|
|
493
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
494
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
495
|
+
response_data = self._request_json(
|
|
496
|
+
"POST",
|
|
497
|
+
f"/repos/{repo_path}/pulls/{pr_number}/reviews",
|
|
498
|
+
json_body={
|
|
499
|
+
"commit_id": self._validated_commit_sha(request.commit_id, "review commit SHA"),
|
|
500
|
+
"event": request.event.value,
|
|
501
|
+
"body": request.body,
|
|
502
|
+
},
|
|
503
|
+
)
|
|
504
|
+
return PullRequestReviewResponse(
|
|
505
|
+
repo=request.repo,
|
|
506
|
+
pr_number=pr_number,
|
|
507
|
+
review_id=self._required_int(response_data, "id", "review id"),
|
|
508
|
+
state=self._required_str(response_data, "state", "review state"),
|
|
509
|
+
reviewer_login=self._required_nested_str(
|
|
510
|
+
response_data, ("user", "login"), "review user login"
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def list_changed_files(self, request: PullRequestFilesRequest) -> PullRequestFilesResponse:
|
|
515
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
516
|
+
pr_number = self._validated_pr_number(request.pr_number)
|
|
517
|
+
files = self._request_json_list_pages(
|
|
518
|
+
"GET",
|
|
519
|
+
f"/repos/{repo_path}/pulls/{pr_number}/files?per_page={_GITHUB_PR_FILES_PAGE_SIZE}",
|
|
520
|
+
)
|
|
521
|
+
changed_paths = tuple(
|
|
522
|
+
self._required_str_from_mapping(file_entry, "filename", "changed file path")
|
|
523
|
+
for file_entry in files
|
|
524
|
+
)
|
|
525
|
+
# `/files` stops paginating at 3000 entries without an error, so a PR that large
|
|
526
|
+
# could push a Protected Path past the cap and auto-merge (QTS-1262). The PR's own
|
|
527
|
+
# `changed_files` count is the check: a listing that disagrees is refused, and the
|
|
528
|
+
# Autonomy Policy holds an unreadable diff for human.
|
|
529
|
+
pull_request = self._request_json("GET", f"/repos/{repo_path}/pulls/{pr_number}")
|
|
530
|
+
changed_file_count = self._required_int(
|
|
531
|
+
pull_request, "changed_files", "pull request changed file count"
|
|
532
|
+
)
|
|
533
|
+
if len(changed_paths) != changed_file_count:
|
|
534
|
+
raise GitHubAppAuthenticationError(
|
|
535
|
+
"GitHub pull request changed file list was incomplete"
|
|
536
|
+
) from None
|
|
537
|
+
return PullRequestFilesResponse(
|
|
538
|
+
repo=request.repo,
|
|
539
|
+
pr_number=pr_number,
|
|
540
|
+
changed_paths=changed_paths,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
def read_repository(self, request: RepositoryReadRequest) -> RepositoryReadResponse:
|
|
544
|
+
repo_path = self._encoded_repo_path(request.repo)
|
|
545
|
+
repository = self._request_json("GET", f"/repos/{repo_path}")
|
|
546
|
+
return RepositoryReadResponse(
|
|
547
|
+
repo=request.repo,
|
|
548
|
+
default_branch=self._required_str(repository, "default_branch", "default branch"),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
@staticmethod
|
|
552
|
+
def _split_repo(repo: str) -> tuple[str, str]:
|
|
553
|
+
parts = repo.split("/")
|
|
554
|
+
if len(parts) != 2:
|
|
555
|
+
raise GitHubAppAuthenticationError("GitHub repository identifier was invalid") from None
|
|
556
|
+
owner, repo_name = parts
|
|
557
|
+
if not GitHubAppClient._is_safe_repo_path_segment(
|
|
558
|
+
owner
|
|
559
|
+
) or not GitHubAppClient._is_safe_repo_path_segment(repo_name):
|
|
560
|
+
raise GitHubAppAuthenticationError("GitHub repository identifier was invalid") from None
|
|
561
|
+
return owner, repo_name
|
|
562
|
+
|
|
563
|
+
@classmethod
|
|
564
|
+
def _encoded_repo_path(cls, repo: str) -> str:
|
|
565
|
+
owner, repo_name = cls._split_repo(repo)
|
|
566
|
+
return cls._encoded_repo_path_from_parts(owner, repo_name)
|
|
567
|
+
|
|
568
|
+
@staticmethod
|
|
569
|
+
def _encoded_repo_path_from_parts(owner: str, repo_name: str) -> str:
|
|
570
|
+
return f"{quote(owner, safe='')}/{quote(repo_name, safe='')}"
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def _is_safe_repo_path_segment(value: str) -> bool:
|
|
574
|
+
return bool(_REPO_PATH_SEGMENT_PATTERN.fullmatch(value)) and value not in {".", ".."}
|
|
575
|
+
|
|
576
|
+
@staticmethod
|
|
577
|
+
def _encoded_git_ref_path_value(ref: str) -> str:
|
|
578
|
+
return quote(GitHubAppClient._validated_github_branch_ref(ref), safe="")
|
|
579
|
+
|
|
580
|
+
@staticmethod
|
|
581
|
+
def _validated_github_branch_ref(ref: str) -> str:
|
|
582
|
+
normalized_ref = ref.lower()
|
|
583
|
+
ref_parts = ref.split("/")
|
|
584
|
+
if (
|
|
585
|
+
ref == ""
|
|
586
|
+
or normalized_ref in _GITHUB_BRANCH_REF_FORBIDDEN_VALUES
|
|
587
|
+
or normalized_ref.startswith("refs/")
|
|
588
|
+
or normalized_ref.startswith("-")
|
|
589
|
+
or ref.startswith(("/", "."))
|
|
590
|
+
or ref.endswith(("/", "."))
|
|
591
|
+
or ".." in ref
|
|
592
|
+
or "@{" in ref
|
|
593
|
+
or any(char in _GITHUB_BRANCH_REF_FORBIDDEN_CHARS for char in ref)
|
|
594
|
+
or any(part in {"", ".", ".."} for part in ref_parts)
|
|
595
|
+
or any(part.endswith(".lock") for part in ref_parts)
|
|
596
|
+
):
|
|
597
|
+
raise GitHubAppAuthenticationError("GitHub ref identifier was invalid") from None
|
|
598
|
+
return ref
|
|
599
|
+
|
|
600
|
+
@staticmethod
|
|
601
|
+
def _validated_pr_number(pr_number: int) -> int:
|
|
602
|
+
if isinstance(pr_number, bool) or not isinstance(pr_number, int) or pr_number < 1:
|
|
603
|
+
raise GitHubAppAuthenticationError("GitHub pull request number was invalid") from None
|
|
604
|
+
return pr_number
|
|
605
|
+
|
|
606
|
+
@staticmethod
|
|
607
|
+
def _is_already_exists_response(response: httpx.Response) -> bool:
|
|
608
|
+
try:
|
|
609
|
+
response_data = response.json()
|
|
610
|
+
except ValueError:
|
|
611
|
+
return False
|
|
612
|
+
if not isinstance(response_data, dict):
|
|
613
|
+
return False
|
|
614
|
+
message = response_data.get("message")
|
|
615
|
+
return isinstance(message, str) and "already exists" in message.lower()
|
|
616
|
+
|
|
617
|
+
@classmethod
|
|
618
|
+
def _to_pull_request_response(
|
|
619
|
+
cls,
|
|
620
|
+
repo: str,
|
|
621
|
+
response_data: dict[str, Any],
|
|
622
|
+
*,
|
|
623
|
+
created: bool,
|
|
624
|
+
) -> PullRequestResponse:
|
|
625
|
+
pr_number = cls._required_int(response_data, "number", "pull request number")
|
|
626
|
+
title = cls._required_str(response_data, "title", "pull request title")
|
|
627
|
+
body = cls._required_str(response_data, "body", "pull request body")
|
|
628
|
+
branch = cls._required_nested_str(response_data, ("head", "ref"), "pull request head ref")
|
|
629
|
+
base = cls._required_nested_str(response_data, ("base", "ref"), "pull request base ref")
|
|
630
|
+
merged = cls._optional_bool(response_data, "merged", default=False)
|
|
631
|
+
draft = cls._optional_bool(response_data, "draft", default=False)
|
|
632
|
+
return PullRequestResponse(
|
|
633
|
+
repo=repo,
|
|
634
|
+
pr_number=pr_number,
|
|
635
|
+
branch=branch,
|
|
636
|
+
base=base,
|
|
637
|
+
title=title,
|
|
638
|
+
body=body,
|
|
639
|
+
created=created,
|
|
640
|
+
merged=merged,
|
|
641
|
+
draft=draft,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
@classmethod
|
|
645
|
+
def _reviewers_from_response(cls, response_data: dict[str, Any]) -> tuple[str, ...]:
|
|
646
|
+
requested_reviewers = response_data.get("requested_reviewers")
|
|
647
|
+
if not isinstance(requested_reviewers, list):
|
|
648
|
+
raise GitHubAppAuthenticationError("GitHub API response missing reviewers") from None
|
|
649
|
+
reviewers: list[str] = []
|
|
650
|
+
for reviewer in requested_reviewers:
|
|
651
|
+
reviewers.append(cls._required_str_from_mapping(reviewer, "login", "reviewer login"))
|
|
652
|
+
return tuple(reviewers)
|
|
653
|
+
|
|
654
|
+
def _current_head_approval(
|
|
655
|
+
self,
|
|
656
|
+
repo: str,
|
|
657
|
+
repo_path: str,
|
|
658
|
+
pr_number: int,
|
|
659
|
+
) -> tuple[ApprovalResponse, str]:
|
|
660
|
+
pull_request = self._request_json("GET", f"/repos/{repo_path}/pulls/{pr_number}")
|
|
661
|
+
head_sha = self._required_nested_commit_sha(
|
|
662
|
+
pull_request, ("head", "sha"), "pull request head commit SHA"
|
|
663
|
+
)
|
|
664
|
+
pr_state = self._required_str(pull_request, "state", "pull request state")
|
|
665
|
+
if pr_state not in {"open", "closed"}:
|
|
666
|
+
raise GitHubAppAuthenticationError(
|
|
667
|
+
"GitHub API response invalid pull request state"
|
|
668
|
+
) from None
|
|
669
|
+
if pr_state == "closed":
|
|
670
|
+
# The PR lifecycle supersedes any review verdict: reviews on a merged or
|
|
671
|
+
# closed PR can no longer gate anything, so skip the reviews fetch and report
|
|
672
|
+
# the lifecycle outcome. Merge attribution is read leniently — a malformed
|
|
673
|
+
# merged_by/merge_commit_sha must degrade to an unattributed merge, not fail
|
|
674
|
+
# the poll into the ~3-day approval_timeout this outcome exists to prevent.
|
|
675
|
+
merged = self._required_bool(pull_request, "merged", "pull request merged flag")
|
|
676
|
+
return (
|
|
677
|
+
ApprovalResponse(
|
|
678
|
+
repo=repo,
|
|
679
|
+
pr_number=pr_number,
|
|
680
|
+
state=ApprovalState.PENDING,
|
|
681
|
+
current_head_sha=head_sha,
|
|
682
|
+
pr_merged=merged,
|
|
683
|
+
pr_closed=not merged,
|
|
684
|
+
merged_by=(
|
|
685
|
+
self._optional_login(pull_request.get("merged_by")) if merged else None
|
|
686
|
+
),
|
|
687
|
+
merge_commit_sha=(
|
|
688
|
+
self._optional_commit_sha(pull_request.get("merge_commit_sha"))
|
|
689
|
+
if merged
|
|
690
|
+
else None
|
|
691
|
+
),
|
|
692
|
+
),
|
|
693
|
+
head_sha,
|
|
694
|
+
)
|
|
695
|
+
reviews = self._request_json_list_pages(
|
|
696
|
+
"GET",
|
|
697
|
+
f"/repos/{repo_path}/pulls/{pr_number}/reviews?per_page={_GITHUB_REVIEWS_PAGE_SIZE}",
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# GitHub's review decision is per reviewer: each reviewer's LATEST decisive
|
|
701
|
+
# review is their standing verdict, and one reviewer's later approval never
|
|
702
|
+
# overrides another reviewer's active changes-request. Collapsing to a single
|
|
703
|
+
# globally-latest review would let exactly that override happen — in both the
|
|
704
|
+
# Reviewer Gate and the merge_pr guard, which share this computation.
|
|
705
|
+
latest_by_reviewer: dict[str, tuple[str, int, str, str, str]] = {}
|
|
706
|
+
# ``user.type`` is read leniently: a missing or odd value falls back to the
|
|
707
|
+
# ``[bot]`` login suffix, which GitHub reserves for App accounts, so a malformed
|
|
708
|
+
# field can only ever make the four-eyes count stricter.
|
|
709
|
+
human_types: dict[str, str] = {}
|
|
710
|
+
for index, review in enumerate(reviews):
|
|
711
|
+
state = self._required_str_from_mapping(review, "state", "review state")
|
|
712
|
+
if state not in {"APPROVED", "CHANGES_REQUESTED", "DISMISSED"}:
|
|
713
|
+
continue
|
|
714
|
+
commit_id = self._required_commit_sha_from_mapping(
|
|
715
|
+
review, "commit_id", "review commit SHA"
|
|
716
|
+
)
|
|
717
|
+
if commit_id != head_sha:
|
|
718
|
+
continue
|
|
719
|
+
submitted_at = self._required_str_from_mapping(
|
|
720
|
+
review, "submitted_at", "review submitted timestamp"
|
|
721
|
+
)
|
|
722
|
+
approver = self._required_nested_str(review, ("user", "login"), "review user login")
|
|
723
|
+
candidate = (submitted_at, index, state, approver, commit_id)
|
|
724
|
+
user = review.get("user")
|
|
725
|
+
if isinstance(user, dict) and isinstance(user.get("type"), str):
|
|
726
|
+
human_types[approver] = str(user["type"])
|
|
727
|
+
previous = latest_by_reviewer.get(approver)
|
|
728
|
+
if previous is None or candidate[:2] >= previous[:2]:
|
|
729
|
+
latest_by_reviewer[approver] = candidate
|
|
730
|
+
|
|
731
|
+
# A reviewer whose standing verdict is DISMISSED contributes nothing — the
|
|
732
|
+
# dismissal clears only that reviewer's own decision, never another reviewer's.
|
|
733
|
+
# An Agent's changes-request is the Critic's veto (PRD issue 54), which the
|
|
734
|
+
# workflow holds and the seams assert; it is not the org's denial, so it must
|
|
735
|
+
# never end the Work Record through the Reviewer Gate.
|
|
736
|
+
blocking = [
|
|
737
|
+
candidate
|
|
738
|
+
for candidate in latest_by_reviewer.values()
|
|
739
|
+
if candidate[2] == "CHANGES_REQUESTED"
|
|
740
|
+
and is_human_reviewer(candidate[3], user_type=human_types.get(candidate[3]))
|
|
741
|
+
]
|
|
742
|
+
if blocking:
|
|
743
|
+
newest_block = max(blocking, key=lambda candidate: candidate[:2])
|
|
744
|
+
return (
|
|
745
|
+
ApprovalResponse(
|
|
746
|
+
repo=repo,
|
|
747
|
+
pr_number=pr_number,
|
|
748
|
+
state=ApprovalState.CHANGES_REQUESTED,
|
|
749
|
+
current_head_sha=head_sha,
|
|
750
|
+
approver=newest_block[3],
|
|
751
|
+
),
|
|
752
|
+
head_sha,
|
|
753
|
+
)
|
|
754
|
+
approvals = [
|
|
755
|
+
candidate for candidate in latest_by_reviewer.values() if candidate[2] == "APPROVED"
|
|
756
|
+
]
|
|
757
|
+
if not approvals:
|
|
758
|
+
return (
|
|
759
|
+
ApprovalResponse(
|
|
760
|
+
repo=repo,
|
|
761
|
+
pr_number=pr_number,
|
|
762
|
+
state=ApprovalState.PENDING,
|
|
763
|
+
current_head_sha=head_sha,
|
|
764
|
+
),
|
|
765
|
+
head_sha,
|
|
766
|
+
)
|
|
767
|
+
newest_approval = max(approvals, key=lambda candidate: candidate[:2])
|
|
768
|
+
return (
|
|
769
|
+
ApprovalResponse(
|
|
770
|
+
repo=repo,
|
|
771
|
+
pr_number=pr_number,
|
|
772
|
+
state=ApprovalState.APPROVED,
|
|
773
|
+
current_head_sha=head_sha,
|
|
774
|
+
approved_head_sha=newest_approval[4],
|
|
775
|
+
approver=newest_approval[3],
|
|
776
|
+
approving_reviewers=tuple(
|
|
777
|
+
sorted(
|
|
778
|
+
candidate[3]
|
|
779
|
+
for candidate in approvals
|
|
780
|
+
if is_human_reviewer(candidate[3], user_type=human_types.get(candidate[3]))
|
|
781
|
+
)
|
|
782
|
+
),
|
|
783
|
+
),
|
|
784
|
+
head_sha,
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
def _next_page_path(self, response: httpx.Response) -> str | None:
|
|
788
|
+
link_header = response.headers.get("Link")
|
|
789
|
+
if link_header is None:
|
|
790
|
+
return None
|
|
791
|
+
for link_part in link_header.split(","):
|
|
792
|
+
match = re.search(r'<([^>]+)>\s*;\s*rel="next"', link_part.strip())
|
|
793
|
+
if match is not None:
|
|
794
|
+
return self._api_relative_path_from_url(match.group(1))
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
def _api_relative_path_from_url(self, url: str) -> str:
|
|
798
|
+
if any(char in _URL_CONTROL_CHARS for char in url):
|
|
799
|
+
raise GitHubAppAuthenticationError("GitHub API pagination link was invalid") from None
|
|
800
|
+
|
|
801
|
+
parsed_url = urlsplit(url)
|
|
802
|
+
if parsed_url.scheme == "" and parsed_url.netloc == "":
|
|
803
|
+
path = parsed_url.path
|
|
804
|
+
else:
|
|
805
|
+
parsed_api_base_url = urlsplit(self._api_base_url)
|
|
806
|
+
if (
|
|
807
|
+
parsed_url.scheme != parsed_api_base_url.scheme
|
|
808
|
+
or parsed_url.netloc != parsed_api_base_url.netloc
|
|
809
|
+
):
|
|
810
|
+
raise GitHubAppAuthenticationError(
|
|
811
|
+
"GitHub API pagination link was invalid"
|
|
812
|
+
) from None
|
|
813
|
+
|
|
814
|
+
base_path = parsed_api_base_url.path.rstrip("/")
|
|
815
|
+
if base_path != "" and not (
|
|
816
|
+
parsed_url.path == base_path or parsed_url.path.startswith(f"{base_path}/")
|
|
817
|
+
):
|
|
818
|
+
raise GitHubAppAuthenticationError(
|
|
819
|
+
"GitHub API pagination link was invalid"
|
|
820
|
+
) from None
|
|
821
|
+
path = parsed_url.path.removeprefix(base_path) if base_path != "" else parsed_url.path
|
|
822
|
+
|
|
823
|
+
if path == "" or not path.startswith("/"):
|
|
824
|
+
raise GitHubAppAuthenticationError("GitHub API pagination link was invalid") from None
|
|
825
|
+
return f"{path}?{parsed_url.query}" if parsed_url.query else path
|
|
826
|
+
|
|
827
|
+
@classmethod
|
|
828
|
+
def _required_nested_str(
|
|
829
|
+
cls,
|
|
830
|
+
response_data: dict[str, Any],
|
|
831
|
+
path: tuple[str, str],
|
|
832
|
+
field_name: str,
|
|
833
|
+
) -> str:
|
|
834
|
+
parent = response_data.get(path[0])
|
|
835
|
+
if not isinstance(parent, dict):
|
|
836
|
+
raise GitHubAppAuthenticationError(
|
|
837
|
+
f"GitHub API response missing {field_name}"
|
|
838
|
+
) from None
|
|
839
|
+
return cls._required_str(parent, path[1], field_name)
|
|
840
|
+
|
|
841
|
+
@classmethod
|
|
842
|
+
def _required_nested_commit_sha(
|
|
843
|
+
cls,
|
|
844
|
+
response_data: dict[str, Any],
|
|
845
|
+
path: tuple[str, str],
|
|
846
|
+
field_name: str,
|
|
847
|
+
) -> str:
|
|
848
|
+
return cls._validated_commit_sha(
|
|
849
|
+
cls._required_nested_str(response_data, path, field_name), field_name
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
@classmethod
|
|
853
|
+
def _required_commit_sha(
|
|
854
|
+
cls,
|
|
855
|
+
response_data: dict[str, Any],
|
|
856
|
+
key: str,
|
|
857
|
+
field_name: str,
|
|
858
|
+
) -> str:
|
|
859
|
+
value = cls._required_str(response_data, key, field_name)
|
|
860
|
+
return cls._validated_commit_sha(value, field_name)
|
|
861
|
+
|
|
862
|
+
@classmethod
|
|
863
|
+
def _required_commit_sha_from_mapping(
|
|
864
|
+
cls,
|
|
865
|
+
response_data: Any,
|
|
866
|
+
key: str,
|
|
867
|
+
field_name: str,
|
|
868
|
+
) -> str:
|
|
869
|
+
return cls._validated_commit_sha(
|
|
870
|
+
cls._required_str_from_mapping(response_data, key, field_name), field_name
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
@staticmethod
|
|
874
|
+
def _validated_commit_sha(value: str, field_name: str) -> str:
|
|
875
|
+
if _COMMIT_SHA_PATTERN.fullmatch(value) is None:
|
|
876
|
+
raise GitHubAppAuthenticationError(
|
|
877
|
+
f"GitHub commit SHA was invalid for {field_name}"
|
|
878
|
+
) from None
|
|
879
|
+
return value
|
|
880
|
+
|
|
881
|
+
@staticmethod
|
|
882
|
+
def _optional_login(value: Any) -> str | None:
|
|
883
|
+
"""Lenient user-object read for attribution fields: None over a raised error."""
|
|
884
|
+
if not isinstance(value, dict):
|
|
885
|
+
return None
|
|
886
|
+
login = value.get("login")
|
|
887
|
+
return login if isinstance(login, str) and login else None
|
|
888
|
+
|
|
889
|
+
@staticmethod
|
|
890
|
+
def _optional_commit_sha(value: Any) -> str | None:
|
|
891
|
+
"""Lenient commit-SHA read for attribution fields: None over a raised error."""
|
|
892
|
+
if isinstance(value, str) and _COMMIT_SHA_PATTERN.fullmatch(value) is not None:
|
|
893
|
+
return value
|
|
894
|
+
return None
|
|
895
|
+
|
|
896
|
+
@classmethod
|
|
897
|
+
def _required_str(cls, response_data: dict[str, Any], key: str, field_name: str) -> str:
|
|
898
|
+
return cls._required_str_from_mapping(response_data, key, field_name)
|
|
899
|
+
|
|
900
|
+
@staticmethod
|
|
901
|
+
def _required_str_from_mapping(response_data: Any, key: str, field_name: str) -> str:
|
|
902
|
+
if not isinstance(response_data, dict):
|
|
903
|
+
raise GitHubAppAuthenticationError(
|
|
904
|
+
f"GitHub API response missing {field_name}"
|
|
905
|
+
) from None
|
|
906
|
+
value = response_data.get(key)
|
|
907
|
+
if not isinstance(value, str):
|
|
908
|
+
raise GitHubAppAuthenticationError(
|
|
909
|
+
f"GitHub API response missing {field_name}"
|
|
910
|
+
) from None
|
|
911
|
+
return value
|
|
912
|
+
|
|
913
|
+
@classmethod
|
|
914
|
+
def _required_int(cls, response_data: dict[str, Any], key: str, field_name: str) -> int:
|
|
915
|
+
return cls._required_int_from_mapping(response_data, key, field_name)
|
|
916
|
+
|
|
917
|
+
@staticmethod
|
|
918
|
+
def _required_int_from_mapping(response_data: Any, key: str, field_name: str) -> int:
|
|
919
|
+
if not isinstance(response_data, dict):
|
|
920
|
+
raise GitHubAppAuthenticationError(
|
|
921
|
+
f"GitHub API response missing {field_name}"
|
|
922
|
+
) from None
|
|
923
|
+
value = response_data.get(key)
|
|
924
|
+
if not isinstance(value, int):
|
|
925
|
+
raise GitHubAppAuthenticationError(
|
|
926
|
+
f"GitHub API response missing {field_name}"
|
|
927
|
+
) from None
|
|
928
|
+
return value
|
|
929
|
+
|
|
930
|
+
@staticmethod
|
|
931
|
+
def _optional_bool(response_data: dict[str, Any], key: str, *, default: bool) -> bool:
|
|
932
|
+
value = response_data.get(key, default)
|
|
933
|
+
if not isinstance(value, bool):
|
|
934
|
+
raise GitHubAppAuthenticationError(f"GitHub API response invalid {key}") from None
|
|
935
|
+
return value
|
|
936
|
+
|
|
937
|
+
@staticmethod
|
|
938
|
+
def _required_bool(response_data: dict[str, Any], key: str, field_name: str) -> bool:
|
|
939
|
+
value = response_data.get(key)
|
|
940
|
+
if not isinstance(value, bool):
|
|
941
|
+
raise GitHubAppAuthenticationError(
|
|
942
|
+
f"GitHub API response missing {field_name}"
|
|
943
|
+
) from None
|
|
944
|
+
return value
|