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.
Files changed (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. 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