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,494 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from hashlib import sha1
|
|
5
|
+
|
|
6
|
+
from agentic_runner_contracts.github_port import (
|
|
7
|
+
ApprovalRequest,
|
|
8
|
+
ApprovalResponse,
|
|
9
|
+
ApprovalState,
|
|
10
|
+
BranchRequest,
|
|
11
|
+
BranchResponse,
|
|
12
|
+
CommentRequest,
|
|
13
|
+
CommentResponse,
|
|
14
|
+
MergeRequest,
|
|
15
|
+
MergeResponse,
|
|
16
|
+
PullRequestCloseRequest,
|
|
17
|
+
PullRequestCloseResponse,
|
|
18
|
+
PullRequestFilesRequest,
|
|
19
|
+
PullRequestFilesResponse,
|
|
20
|
+
PullRequestReadyRequest,
|
|
21
|
+
PullRequestReadyResponse,
|
|
22
|
+
PullRequestRequest,
|
|
23
|
+
PullRequestResponse,
|
|
24
|
+
PullRequestRetargetRequest,
|
|
25
|
+
PullRequestRetargetResponse,
|
|
26
|
+
PullRequestReviewRequest,
|
|
27
|
+
PullRequestReviewResponse,
|
|
28
|
+
RepositoryReadRequest,
|
|
29
|
+
RepositoryReadResponse,
|
|
30
|
+
ReviewEvent,
|
|
31
|
+
ReviewRequest,
|
|
32
|
+
ReviewResponse,
|
|
33
|
+
is_human_reviewer,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class _PullRequestRecord:
|
|
39
|
+
repo: str
|
|
40
|
+
pr_number: int
|
|
41
|
+
branch: str
|
|
42
|
+
base: str
|
|
43
|
+
title: str
|
|
44
|
+
body: str
|
|
45
|
+
head_revision: int = 1
|
|
46
|
+
draft: bool = False
|
|
47
|
+
reviewers: list[str] = field(default_factory=list)
|
|
48
|
+
comments: list[str] = field(default_factory=list)
|
|
49
|
+
# Reviews submitted through ``submit_review`` (the `pr.comment` seam), in order. They
|
|
50
|
+
# are an Agent's, so like a bot's review on GitHub they never move the approval state.
|
|
51
|
+
reviews: list[PullRequestReviewRequest] = field(default_factory=list)
|
|
52
|
+
changed_paths: list[str] = field(default_factory=list)
|
|
53
|
+
approver: str | None = None
|
|
54
|
+
approval_head_sha: str | None = None
|
|
55
|
+
# Every standing approval, login -> the head SHA it was submitted against. The merge
|
|
56
|
+
# seam counts the distinct human logins standing on the current head (ADR-0011 s6);
|
|
57
|
+
# ``approver``/``approval_head_sha`` stay the newest one, which is the review
|
|
58
|
+
# decision the Reviewer Gate reports.
|
|
59
|
+
approvals: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
changes_requested_by: str | None = None
|
|
61
|
+
changes_requested_head_sha: str | None = None
|
|
62
|
+
merge_commit_sha: str | None = None
|
|
63
|
+
merged: bool = False
|
|
64
|
+
merged_by: str | None = None
|
|
65
|
+
closed: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FakeGitHubClient:
|
|
69
|
+
"""Deterministic in-memory GitHub client for tests and Ralph loop rehearsal."""
|
|
70
|
+
|
|
71
|
+
# The login every App-token call arrives as, as on GitHub: an Agent has no identity of
|
|
72
|
+
# its own there (``contracts.is_human_reviewer``).
|
|
73
|
+
app_login = "agentic-os[bot]"
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._branches: dict[tuple[str, str], BranchResponse] = {}
|
|
77
|
+
self._pull_requests_by_number: dict[tuple[str, int], _PullRequestRecord] = {}
|
|
78
|
+
self._pull_requests_by_branch: dict[tuple[str, str], _PullRequestRecord] = {}
|
|
79
|
+
self._review_requests: list[tuple[str, int, str]] = []
|
|
80
|
+
self._retargets: list[tuple[str, int, str]] = []
|
|
81
|
+
self._default_branches: dict[str, str] = {}
|
|
82
|
+
self._next_pr_number = 1
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def pull_requests(self) -> tuple[_PullRequestRecord, ...]:
|
|
86
|
+
"""Return current pull requests in deterministic PR-number order for assertions."""
|
|
87
|
+
|
|
88
|
+
return tuple(
|
|
89
|
+
sorted(
|
|
90
|
+
self._pull_requests_by_number.values(),
|
|
91
|
+
key=lambda record: (record.repo, record.pr_number),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def review_requests(self) -> tuple[tuple[str, int, str], ...]:
|
|
97
|
+
"""Return recorded review requests for deterministic workflow assertions."""
|
|
98
|
+
|
|
99
|
+
return tuple(self._review_requests)
|
|
100
|
+
|
|
101
|
+
def create_branch(self, request: BranchRequest) -> BranchResponse:
|
|
102
|
+
key = (request.repo, request.branch)
|
|
103
|
+
existing = self._branches.get(key)
|
|
104
|
+
if existing is not None:
|
|
105
|
+
return BranchResponse(
|
|
106
|
+
repo=existing.repo,
|
|
107
|
+
branch=existing.branch,
|
|
108
|
+
base_ref=existing.base_ref,
|
|
109
|
+
created=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
response = BranchResponse(
|
|
113
|
+
repo=request.repo,
|
|
114
|
+
branch=request.branch,
|
|
115
|
+
base_ref=request.base_ref,
|
|
116
|
+
created=True,
|
|
117
|
+
)
|
|
118
|
+
self._branches[key] = response
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
def create_or_update_pr(self, request: PullRequestRequest) -> PullRequestResponse:
|
|
122
|
+
self._require_branch(repo=request.repo, branch=request.branch)
|
|
123
|
+
key = (request.repo, request.branch)
|
|
124
|
+
existing = self._pull_requests_by_branch.get(key)
|
|
125
|
+
if existing is not None:
|
|
126
|
+
self._ensure_pr_is_open(existing)
|
|
127
|
+
existing.base = request.base
|
|
128
|
+
existing.title = request.title
|
|
129
|
+
existing.body = request.body
|
|
130
|
+
return self._to_pr_response(existing, created=False)
|
|
131
|
+
|
|
132
|
+
record = _PullRequestRecord(
|
|
133
|
+
repo=request.repo,
|
|
134
|
+
pr_number=self._next_pr_number,
|
|
135
|
+
branch=request.branch,
|
|
136
|
+
base=request.base,
|
|
137
|
+
title=request.title,
|
|
138
|
+
body=request.body,
|
|
139
|
+
draft=request.draft,
|
|
140
|
+
)
|
|
141
|
+
self._next_pr_number += 1
|
|
142
|
+
self._pull_requests_by_branch[key] = record
|
|
143
|
+
self._pull_requests_by_number[(request.repo, record.pr_number)] = record
|
|
144
|
+
return self._to_pr_response(record, created=True)
|
|
145
|
+
|
|
146
|
+
def mark_pr_ready_for_review(
|
|
147
|
+
self,
|
|
148
|
+
request: PullRequestReadyRequest,
|
|
149
|
+
) -> PullRequestReadyResponse:
|
|
150
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
151
|
+
self._ensure_pr_is_open(record)
|
|
152
|
+
marked_ready = record.draft
|
|
153
|
+
record.draft = False
|
|
154
|
+
return PullRequestReadyResponse(
|
|
155
|
+
repo=request.repo,
|
|
156
|
+
pr_number=request.pr_number,
|
|
157
|
+
ready=True,
|
|
158
|
+
marked_ready=marked_ready,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def close_pr(self, request: PullRequestCloseRequest) -> PullRequestCloseResponse:
|
|
162
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
163
|
+
already_settled = record.merged or record.closed
|
|
164
|
+
record.closed = True
|
|
165
|
+
return PullRequestCloseResponse(
|
|
166
|
+
repo=request.repo,
|
|
167
|
+
pr_number=request.pr_number,
|
|
168
|
+
closed=not already_settled,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def retarget_pr(self, request: PullRequestRetargetRequest) -> PullRequestRetargetResponse:
|
|
172
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
173
|
+
if record.merged or record.closed:
|
|
174
|
+
return PullRequestRetargetResponse(
|
|
175
|
+
repo=request.repo, pr_number=request.pr_number, base=record.base, retargeted=False
|
|
176
|
+
)
|
|
177
|
+
record.base = request.base
|
|
178
|
+
self._retargets.append((request.repo, request.pr_number, request.base))
|
|
179
|
+
return PullRequestRetargetResponse(
|
|
180
|
+
repo=request.repo, pr_number=request.pr_number, base=request.base, retargeted=True
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def retargets(self) -> tuple[tuple[str, int, str], ...]:
|
|
185
|
+
"""Every base change recorded through ``retarget_pr``, in order (PRD issue 59)."""
|
|
186
|
+
|
|
187
|
+
return tuple(self._retargets)
|
|
188
|
+
|
|
189
|
+
def request_review(self, request: ReviewRequest) -> ReviewResponse:
|
|
190
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
191
|
+
self._ensure_pr_is_open(record)
|
|
192
|
+
self._review_requests.append((request.repo, request.pr_number, request.reviewer))
|
|
193
|
+
if request.reviewer not in record.reviewers:
|
|
194
|
+
record.reviewers.append(request.reviewer)
|
|
195
|
+
|
|
196
|
+
return ReviewResponse(
|
|
197
|
+
repo=request.repo,
|
|
198
|
+
pr_number=request.pr_number,
|
|
199
|
+
reviewer=request.reviewer,
|
|
200
|
+
reviewers=tuple(record.reviewers),
|
|
201
|
+
requested=True,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def wait_for_approval(self, request: ApprovalRequest) -> ApprovalResponse:
|
|
205
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
206
|
+
current_head_sha = self._head_sha(record)
|
|
207
|
+
# Mirrors the real adapter: the PR lifecycle supersedes any review verdict — a
|
|
208
|
+
# merged or closed PR reports PENDING plus the lifecycle flags, never a review
|
|
209
|
+
# state the gate could act on.
|
|
210
|
+
if record.merged:
|
|
211
|
+
return ApprovalResponse(
|
|
212
|
+
repo=request.repo,
|
|
213
|
+
pr_number=request.pr_number,
|
|
214
|
+
state=ApprovalState.PENDING,
|
|
215
|
+
current_head_sha=current_head_sha,
|
|
216
|
+
pr_merged=True,
|
|
217
|
+
merged_by=record.merged_by,
|
|
218
|
+
merge_commit_sha=record.merge_commit_sha,
|
|
219
|
+
)
|
|
220
|
+
if record.closed:
|
|
221
|
+
return ApprovalResponse(
|
|
222
|
+
repo=request.repo,
|
|
223
|
+
pr_number=request.pr_number,
|
|
224
|
+
state=ApprovalState.PENDING,
|
|
225
|
+
current_head_sha=current_head_sha,
|
|
226
|
+
pr_closed=True,
|
|
227
|
+
)
|
|
228
|
+
approved = record.approver is not None and record.approval_head_sha == current_head_sha
|
|
229
|
+
if approved:
|
|
230
|
+
return ApprovalResponse(
|
|
231
|
+
repo=request.repo,
|
|
232
|
+
pr_number=request.pr_number,
|
|
233
|
+
state=ApprovalState.APPROVED,
|
|
234
|
+
current_head_sha=current_head_sha,
|
|
235
|
+
approved_head_sha=record.approval_head_sha,
|
|
236
|
+
approver=record.approver,
|
|
237
|
+
approving_reviewers=self._human_approvers(record, current_head_sha),
|
|
238
|
+
)
|
|
239
|
+
# Mirrors the real adapter: a changes-requested review only denies the head it
|
|
240
|
+
# was submitted against; a new push returns the gate to PENDING.
|
|
241
|
+
if (
|
|
242
|
+
record.changes_requested_by is not None
|
|
243
|
+
and record.changes_requested_head_sha == current_head_sha
|
|
244
|
+
):
|
|
245
|
+
return ApprovalResponse(
|
|
246
|
+
repo=request.repo,
|
|
247
|
+
pr_number=request.pr_number,
|
|
248
|
+
state=ApprovalState.CHANGES_REQUESTED,
|
|
249
|
+
current_head_sha=current_head_sha,
|
|
250
|
+
approver=record.changes_requested_by,
|
|
251
|
+
)
|
|
252
|
+
return ApprovalResponse(
|
|
253
|
+
repo=request.repo,
|
|
254
|
+
pr_number=request.pr_number,
|
|
255
|
+
state=ApprovalState.PENDING,
|
|
256
|
+
current_head_sha=current_head_sha,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def merge_pr(self, request: MergeRequest) -> MergeResponse:
|
|
260
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
261
|
+
self._ensure_pr_is_open(record)
|
|
262
|
+
if record.approver is None:
|
|
263
|
+
raise PermissionError(
|
|
264
|
+
f"Pull request '{request.repo}#{request.pr_number}' requires approval before merge"
|
|
265
|
+
)
|
|
266
|
+
current_head_sha = self._head_sha(record)
|
|
267
|
+
if record.approval_head_sha != current_head_sha:
|
|
268
|
+
raise PermissionError(
|
|
269
|
+
f"Pull request '{request.repo}#{request.pr_number}' requires current head approval"
|
|
270
|
+
)
|
|
271
|
+
if request.expected_head_sha != current_head_sha:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"Pull request '{request.repo}#{request.pr_number}' expected head SHA did not match"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
record.merged = True
|
|
277
|
+
record.merge_commit_sha = self._merge_commit_sha(record)
|
|
278
|
+
return MergeResponse(
|
|
279
|
+
repo=request.repo,
|
|
280
|
+
pr_number=request.pr_number,
|
|
281
|
+
commit_title=request.commit_title,
|
|
282
|
+
merged=True,
|
|
283
|
+
merge_commit_sha=record.merge_commit_sha,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def post_comment(self, request: CommentRequest) -> CommentResponse:
|
|
287
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
288
|
+
self._ensure_pr_is_open(record)
|
|
289
|
+
record.comments.append(request.body)
|
|
290
|
+
return CommentResponse(
|
|
291
|
+
repo=request.repo,
|
|
292
|
+
pr_number=request.pr_number,
|
|
293
|
+
comment_number=len(record.comments),
|
|
294
|
+
body=request.body,
|
|
295
|
+
comments=tuple(record.comments),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def submit_review(self, request: PullRequestReviewRequest) -> PullRequestReviewResponse:
|
|
299
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
300
|
+
self._ensure_pr_is_open(record)
|
|
301
|
+
record.reviews.append(request)
|
|
302
|
+
return PullRequestReviewResponse(
|
|
303
|
+
repo=request.repo,
|
|
304
|
+
pr_number=request.pr_number,
|
|
305
|
+
review_id=len(record.reviews),
|
|
306
|
+
state=(
|
|
307
|
+
"CHANGES_REQUESTED" if request.event is ReviewEvent.REQUEST_CHANGES else "COMMENTED"
|
|
308
|
+
),
|
|
309
|
+
reviewer_login=self.app_login,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def list_changed_files(self, request: PullRequestFilesRequest) -> PullRequestFilesResponse:
|
|
313
|
+
# No open-PR guard: the real API serves the file listing for merged and closed
|
|
314
|
+
# PRs too, and a review may legitimately read one.
|
|
315
|
+
record = self._get_pr(repo=request.repo, pr_number=request.pr_number)
|
|
316
|
+
return PullRequestFilesResponse(
|
|
317
|
+
repo=request.repo,
|
|
318
|
+
pr_number=request.pr_number,
|
|
319
|
+
changed_paths=tuple(record.changed_paths),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def read_repository(self, request: RepositoryReadRequest) -> RepositoryReadResponse:
|
|
323
|
+
try:
|
|
324
|
+
default_branch = self._default_branches[request.repo]
|
|
325
|
+
except KeyError as error:
|
|
326
|
+
raise KeyError(f"Repository '{request.repo}' not found") from error
|
|
327
|
+
return RepositoryReadResponse(repo=request.repo, default_branch=default_branch)
|
|
328
|
+
|
|
329
|
+
def record_repository(self, *, repo: str, default_branch: str = "main") -> None:
|
|
330
|
+
"""Test helper: make ``repo`` readable; any other repository answers not-found."""
|
|
331
|
+
|
|
332
|
+
self._default_branches[repo] = default_branch
|
|
333
|
+
|
|
334
|
+
def record_changed_paths(self, *, repo: str, pr_number: int, changed_paths: list[str]) -> None:
|
|
335
|
+
"""Test helper for deterministically setting a PR's changed file paths."""
|
|
336
|
+
|
|
337
|
+
record = self._get_pr(repo=repo, pr_number=pr_number)
|
|
338
|
+
record.changed_paths = list(changed_paths)
|
|
339
|
+
|
|
340
|
+
def record_approval(self, *, repo: str, pr_number: int, approver: str) -> ApprovalResponse:
|
|
341
|
+
"""Test helper for deterministically recording an external approval."""
|
|
342
|
+
|
|
343
|
+
record = self._get_pr(repo=repo, pr_number=pr_number)
|
|
344
|
+
self._ensure_pr_is_open(record)
|
|
345
|
+
record.approver = approver
|
|
346
|
+
record.approval_head_sha = self._head_sha(record)
|
|
347
|
+
record.approvals[approver] = record.approval_head_sha
|
|
348
|
+
record.changes_requested_by = None
|
|
349
|
+
record.changes_requested_head_sha = None
|
|
350
|
+
return ApprovalResponse(
|
|
351
|
+
repo=repo,
|
|
352
|
+
pr_number=pr_number,
|
|
353
|
+
state=ApprovalState.APPROVED,
|
|
354
|
+
current_head_sha=record.approval_head_sha,
|
|
355
|
+
approved_head_sha=record.approval_head_sha,
|
|
356
|
+
approver=approver,
|
|
357
|
+
approving_reviewers=self._human_approvers(record, record.approval_head_sha),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def record_changes_requested(
|
|
361
|
+
self, *, repo: str, pr_number: int, reviewer: str
|
|
362
|
+
) -> ApprovalResponse:
|
|
363
|
+
"""Test helper for deterministically recording a changes-requested review."""
|
|
364
|
+
|
|
365
|
+
record = self._get_pr(repo=repo, pr_number=pr_number)
|
|
366
|
+
self._ensure_pr_is_open(record)
|
|
367
|
+
current_head_sha = self._head_sha(record)
|
|
368
|
+
record.approver = None
|
|
369
|
+
record.approval_head_sha = None
|
|
370
|
+
record.approvals.clear()
|
|
371
|
+
record.changes_requested_by = reviewer
|
|
372
|
+
record.changes_requested_head_sha = current_head_sha
|
|
373
|
+
return ApprovalResponse(
|
|
374
|
+
repo=repo,
|
|
375
|
+
pr_number=pr_number,
|
|
376
|
+
state=ApprovalState.CHANGES_REQUESTED,
|
|
377
|
+
current_head_sha=current_head_sha,
|
|
378
|
+
approver=reviewer,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def record_external_merge(self, *, repo: str, pr_number: int, actor: str) -> ApprovalResponse:
|
|
382
|
+
"""Test helper for deterministically merging the PR outside the Ralph loop."""
|
|
383
|
+
|
|
384
|
+
record = self._get_pr(repo=repo, pr_number=pr_number)
|
|
385
|
+
self._ensure_pr_is_open(record)
|
|
386
|
+
record.merged = True
|
|
387
|
+
record.merged_by = actor
|
|
388
|
+
record.merge_commit_sha = self._merge_commit_sha(record)
|
|
389
|
+
return ApprovalResponse(
|
|
390
|
+
repo=repo,
|
|
391
|
+
pr_number=pr_number,
|
|
392
|
+
state=ApprovalState.PENDING,
|
|
393
|
+
current_head_sha=self._head_sha(record),
|
|
394
|
+
pr_merged=True,
|
|
395
|
+
merged_by=actor,
|
|
396
|
+
merge_commit_sha=record.merge_commit_sha,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def record_external_close(self, *, repo: str, pr_number: int) -> ApprovalResponse:
|
|
400
|
+
"""Test helper for deterministically closing the PR without merging."""
|
|
401
|
+
|
|
402
|
+
record = self._get_pr(repo=repo, pr_number=pr_number)
|
|
403
|
+
self._ensure_pr_is_open(record)
|
|
404
|
+
record.closed = True
|
|
405
|
+
return ApprovalResponse(
|
|
406
|
+
repo=repo,
|
|
407
|
+
pr_number=pr_number,
|
|
408
|
+
state=ApprovalState.PENDING,
|
|
409
|
+
current_head_sha=self._head_sha(record),
|
|
410
|
+
pr_closed=True,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def record_dismissal(self, *, repo: str, pr_number: int) -> ApprovalResponse:
|
|
414
|
+
"""Test helper for deterministically dismissing the current approval."""
|
|
415
|
+
|
|
416
|
+
record = self._get_pr(repo=repo, pr_number=pr_number)
|
|
417
|
+
self._ensure_pr_is_open(record)
|
|
418
|
+
current_head_sha = self._head_sha(record)
|
|
419
|
+
record.approver = None
|
|
420
|
+
record.approval_head_sha = None
|
|
421
|
+
record.approvals.clear()
|
|
422
|
+
record.changes_requested_by = None
|
|
423
|
+
record.changes_requested_head_sha = None
|
|
424
|
+
return ApprovalResponse(
|
|
425
|
+
repo=repo,
|
|
426
|
+
pr_number=pr_number,
|
|
427
|
+
state=ApprovalState.PENDING,
|
|
428
|
+
current_head_sha=current_head_sha,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
def record_branch_head_change(self, *, repo: str, branch: str) -> None:
|
|
432
|
+
"""Test helper for deterministically modeling pushed branch content changes."""
|
|
433
|
+
|
|
434
|
+
self._require_branch(repo=repo, branch=branch)
|
|
435
|
+
try:
|
|
436
|
+
record = self._pull_requests_by_branch[(repo, branch)]
|
|
437
|
+
except KeyError as error:
|
|
438
|
+
raise KeyError(f"Pull request for branch '{repo}:{branch}' not found") from error
|
|
439
|
+
self._ensure_pr_is_open(record)
|
|
440
|
+
record.head_revision += 1
|
|
441
|
+
|
|
442
|
+
def _require_branch(self, *, repo: str, branch: str) -> None:
|
|
443
|
+
if (repo, branch) not in self._branches:
|
|
444
|
+
raise KeyError(f"Branch '{repo}:{branch}' not found")
|
|
445
|
+
|
|
446
|
+
def _get_pr(self, *, repo: str, pr_number: int) -> _PullRequestRecord:
|
|
447
|
+
try:
|
|
448
|
+
return self._pull_requests_by_number[(repo, pr_number)]
|
|
449
|
+
except KeyError as error:
|
|
450
|
+
raise KeyError(f"Pull request '{repo}#{pr_number}' not found") from error
|
|
451
|
+
|
|
452
|
+
def _ensure_pr_is_open(self, record: _PullRequestRecord) -> None:
|
|
453
|
+
if record.merged:
|
|
454
|
+
raise ValueError(f"Pull request '{record.repo}#{record.pr_number}' is already merged")
|
|
455
|
+
if record.closed:
|
|
456
|
+
raise ValueError(f"Pull request '{record.repo}#{record.pr_number}' is closed")
|
|
457
|
+
|
|
458
|
+
def _to_pr_response(
|
|
459
|
+
self,
|
|
460
|
+
record: _PullRequestRecord,
|
|
461
|
+
*,
|
|
462
|
+
created: bool,
|
|
463
|
+
) -> PullRequestResponse:
|
|
464
|
+
return PullRequestResponse(
|
|
465
|
+
repo=record.repo,
|
|
466
|
+
pr_number=record.pr_number,
|
|
467
|
+
branch=record.branch,
|
|
468
|
+
base=record.base,
|
|
469
|
+
title=record.title,
|
|
470
|
+
body=record.body,
|
|
471
|
+
created=created,
|
|
472
|
+
merged=record.merged,
|
|
473
|
+
draft=record.draft,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
def _human_approvers(record: _PullRequestRecord, head_sha: str) -> tuple[str, ...]:
|
|
478
|
+
return tuple(
|
|
479
|
+
sorted(
|
|
480
|
+
login
|
|
481
|
+
for login, approved_head_sha in record.approvals.items()
|
|
482
|
+
if approved_head_sha == head_sha and is_human_reviewer(login)
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _head_sha(record: _PullRequestRecord) -> str:
|
|
488
|
+
seed = f"fake-pr:{record.repo}:{record.pr_number}:{record.branch}:{record.head_revision}"
|
|
489
|
+
return sha1(seed.encode("utf-8"), usedforsecurity=False).hexdigest()
|
|
490
|
+
|
|
491
|
+
@classmethod
|
|
492
|
+
def _merge_commit_sha(cls, record: _PullRequestRecord) -> str:
|
|
493
|
+
seed = f"fake-merge:{record.repo}:{record.pr_number}:{cls._head_sha(record)}"
|
|
494
|
+
return sha1(seed.encode("utf-8"), usedforsecurity=False).hexdigest()
|