gitgrip 1.5.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.
- gitgrip-1.5.0.dist-info/METADATA +13 -0
- gitgrip-1.5.0.dist-info/RECORD +80 -0
- gitgrip-1.5.0.dist-info/WHEEL +5 -0
- gitgrip-1.5.0.dist-info/entry_points.txt +2 -0
- gitgrip-1.5.0.dist-info/top_level.txt +2 -0
- gr2/__init__.py +0 -0
- gr2/overlay/__init__.py +6 -0
- gr2/overlay/activate.py +196 -0
- gr2/overlay/agent_manifest.py +138 -0
- gr2/overlay/cli.py +181 -0
- gr2/overlay/cross_repo.py +124 -0
- gr2/overlay/drivers.py +113 -0
- gr2/overlay/introspection.py +155 -0
- gr2/overlay/language_drivers.py +115 -0
- gr2/overlay/objects.py +412 -0
- gr2/overlay/perf.py +251 -0
- gr2/overlay/refs.py +36 -0
- gr2/overlay/trust.py +150 -0
- gr2/overlay/types.py +69 -0
- gr2/overlay/units.py +313 -0
- gr2/overlay/workspace_spec.py +59 -0
- gr2/prototypes/__init__.py +0 -0
- gr2/prototypes/cache_materialization_probe.py +190 -0
- gr2/prototypes/concurrent_event_stress.py +199 -0
- gr2/prototypes/concurrent_lease_stress.py +240 -0
- gr2/prototypes/concurrent_workspace_cap_stress.py +231 -0
- gr2/prototypes/contribution_protocol.py +665 -0
- gr2/prototypes/cross_mode_lane_stress.py +986 -0
- gr2/prototypes/jsonl_store.py +158 -0
- gr2/prototypes/lane_workspace_prototype.py +2088 -0
- gr2/prototypes/layout_model_probe.py +139 -0
- gr2/prototypes/propagation_daemon.py +546 -0
- gr2/prototypes/propagation_state_machine.py +1478 -0
- gr2/prototypes/python_exec_playground.py +194 -0
- gr2/prototypes/python_hook_runtime_playground.py +240 -0
- gr2/prototypes/python_migration_playground.py +144 -0
- gr2/prototypes/python_review_checkout_playground.py +242 -0
- gr2/prototypes/python_spec_apply_playground.py +282 -0
- gr2/prototypes/real_git_lane_materialization.py +248 -0
- gr2/prototypes/real_git_playground.py +334 -0
- gr2/prototypes/recall_lane_history.py +274 -0
- gr2/prototypes/repo_maintenance_prototype.py +659 -0
- gr2/prototypes/repo_transport_probe.py +147 -0
- gr2/python_cli/__init__.py +2 -0
- gr2/python_cli/__main__.py +6 -0
- gr2/python_cli/add.py +51 -0
- gr2/python_cli/app.py +2516 -0
- gr2/python_cli/branch.py +67 -0
- gr2/python_cli/channel_bridge.py +131 -0
- gr2/python_cli/clone_exec.py +1019 -0
- gr2/python_cli/commit.py +199 -0
- gr2/python_cli/config.py +291 -0
- gr2/python_cli/env_exec.py +419 -0
- gr2/python_cli/events.py +529 -0
- gr2/python_cli/execops.py +372 -0
- gr2/python_cli/failures.py +98 -0
- gr2/python_cli/file_exec.py +256 -0
- gr2/python_cli/gitops.py +226 -0
- gr2/python_cli/grip.py +1337 -0
- gr2/python_cli/grip_cli.py +493 -0
- gr2/python_cli/hooks.py +450 -0
- gr2/python_cli/launch_exec.py +786 -0
- gr2/python_cli/merge_verification.py +274 -0
- gr2/python_cli/migration.py +985 -0
- gr2/python_cli/open_gr_review.py +699 -0
- gr2/python_cli/platform.py +441 -0
- gr2/python_cli/pr.py +487 -0
- gr2/python_cli/project_review.py +314 -0
- gr2/python_cli/prune.py +365 -0
- gr2/python_cli/push.py +172 -0
- gr2/python_cli/review.py +462 -0
- gr2/python_cli/review_ephemeral.py +143 -0
- gr2/python_cli/review_run.py +621 -0
- gr2/python_cli/spec_apply.py +1285 -0
- gr2/python_cli/staging_cleanup.py +205 -0
- gr2/python_cli/syncops.py +920 -0
- gr2/python_cli/target.py +100 -0
- gr2/python_cli/workspace_snapshot.py +105 -0
- gr2/schemas/gr2-materialization-plan-v1.schema.json +191 -0
- gr2_overlay/__init__.py +37 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PRRef:
|
|
16
|
+
repo: str
|
|
17
|
+
number: int | None = None
|
|
18
|
+
url: str | None = None
|
|
19
|
+
head_branch: str | None = None
|
|
20
|
+
base_branch: str | None = None
|
|
21
|
+
title: str | None = None
|
|
22
|
+
|
|
23
|
+
def as_dict(self) -> dict[str, object]:
|
|
24
|
+
return asdict(self)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MergeMethod(StrEnum):
|
|
28
|
+
MERGE = "merge"
|
|
29
|
+
SQUASH = "squash"
|
|
30
|
+
REBASE = "rebase"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def gh_flag(self) -> str:
|
|
34
|
+
return f"--{self.value}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class MergeReceipt:
|
|
39
|
+
"""Host-observed evidence for one completed merge operation.
|
|
40
|
+
|
|
41
|
+
`requested` is caller intent. `observed` and `commit_sha` come from the
|
|
42
|
+
hosting platform's structured PR record after the merge command returns.
|
|
43
|
+
Keeping both identities prevents a response for a different PR from being
|
|
44
|
+
accepted merely because the command exited successfully.
|
|
45
|
+
|
|
46
|
+
`commit_sha=None` has one meaning: the host explicitly returned no merge
|
|
47
|
+
commit identity. It never means parent verification succeeded.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
requested: PRRef
|
|
51
|
+
observed: PRRef
|
|
52
|
+
commit_sha: str | None
|
|
53
|
+
requested_method: MergeMethod
|
|
54
|
+
|
|
55
|
+
def as_dict(self) -> dict[str, object]:
|
|
56
|
+
return {
|
|
57
|
+
"requested": self.requested.as_dict(),
|
|
58
|
+
"observed": self.observed.as_dict(),
|
|
59
|
+
"commit_sha": self.commit_sha,
|
|
60
|
+
"requested_method": self.requested_method.value,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class PRCheck:
|
|
66
|
+
name: str
|
|
67
|
+
status: str
|
|
68
|
+
conclusion: str | None = None
|
|
69
|
+
details_url: str | None = None
|
|
70
|
+
|
|
71
|
+
def as_dict(self) -> dict[str, object]:
|
|
72
|
+
return asdict(self)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class PRStatus:
|
|
77
|
+
ref: PRRef
|
|
78
|
+
state: str
|
|
79
|
+
mergeable: str | None = None
|
|
80
|
+
checks: list[PRCheck] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
def as_dict(self) -> dict[str, object]:
|
|
83
|
+
return {
|
|
84
|
+
"ref": self.ref.as_dict(),
|
|
85
|
+
"state": self.state,
|
|
86
|
+
"mergeable": self.mergeable,
|
|
87
|
+
"checks": [item.as_dict() for item in self.checks],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class CreatePRRequest:
|
|
93
|
+
repo: str
|
|
94
|
+
title: str
|
|
95
|
+
body: str
|
|
96
|
+
head_branch: str
|
|
97
|
+
base_branch: str
|
|
98
|
+
draft: bool = False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PlatformAdapter(Protocol):
|
|
102
|
+
"""Protocol for platform-backed PR orchestration.
|
|
103
|
+
|
|
104
|
+
gr2 owns the orchestration UX. Adapters hide the hosting platform backend.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
name: str
|
|
108
|
+
|
|
109
|
+
def create_pr(self, request: CreatePRRequest) -> PRRef: ...
|
|
110
|
+
|
|
111
|
+
def merge_pr(
|
|
112
|
+
self,
|
|
113
|
+
repo: str,
|
|
114
|
+
number: int,
|
|
115
|
+
*,
|
|
116
|
+
method: MergeMethod,
|
|
117
|
+
) -> MergeReceipt: ...
|
|
118
|
+
|
|
119
|
+
def pr_status(self, repo: str, number: int) -> PRStatus: ...
|
|
120
|
+
|
|
121
|
+
def list_prs(self, repo: str, *, head_branch: str | None = None) -> list[PRRef]: ...
|
|
122
|
+
|
|
123
|
+
def pr_checks(self, repo: str, number: int) -> list[PRCheck]: ...
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AdapterError(RuntimeError):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class MergeEvidenceError(AdapterError):
|
|
131
|
+
"""The host acknowledged a merge, but its immutable receipt is unavailable.
|
|
132
|
+
|
|
133
|
+
This is deliberately distinct from a failed merge command. Retrying after
|
|
134
|
+
this error could merge or report the same operation twice.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
operation_acknowledged = True
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
requested: PRRef,
|
|
143
|
+
requested_method: MergeMethod,
|
|
144
|
+
reason: str,
|
|
145
|
+
) -> None:
|
|
146
|
+
self.requested = requested
|
|
147
|
+
self.requested_method = requested_method
|
|
148
|
+
self.reason = reason
|
|
149
|
+
super().__init__(
|
|
150
|
+
f"merge command succeeded for {requested.repo}#{requested.number}, "
|
|
151
|
+
f"but its immutable receipt is unavailable: {reason}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _run_json(command: list[str], *, cwd: Path | None = None) -> object:
|
|
156
|
+
try:
|
|
157
|
+
proc = subprocess.run(
|
|
158
|
+
command,
|
|
159
|
+
cwd=cwd,
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
check=False,
|
|
163
|
+
)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
raise AdapterError(
|
|
166
|
+
f"could not launch command for structured host evidence: {command[0]!r}: {exc}"
|
|
167
|
+
) from exc
|
|
168
|
+
if proc.returncode != 0:
|
|
169
|
+
detail = proc.stderr.strip() or proc.stdout.strip()
|
|
170
|
+
raise AdapterError(detail or f"command failed: {' '.join(command)}")
|
|
171
|
+
try:
|
|
172
|
+
return json.loads(proc.stdout)
|
|
173
|
+
except json.JSONDecodeError as exc:
|
|
174
|
+
raise AdapterError(f"command did not return valid json: {' '.join(command)}") from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class GitHubAdapter:
|
|
178
|
+
name = "github"
|
|
179
|
+
|
|
180
|
+
def __init__(self, gh_binary: str = "gh") -> None:
|
|
181
|
+
if shutil.which(gh_binary) is None:
|
|
182
|
+
raise AdapterError(f"`{gh_binary}` not found in PATH")
|
|
183
|
+
self.gh_binary = gh_binary
|
|
184
|
+
|
|
185
|
+
def create_pr(self, request: CreatePRRequest) -> PRRef:
|
|
186
|
+
cmd = [
|
|
187
|
+
self.gh_binary,
|
|
188
|
+
"pr",
|
|
189
|
+
"create",
|
|
190
|
+
"--repo",
|
|
191
|
+
request.repo,
|
|
192
|
+
"--title",
|
|
193
|
+
request.title,
|
|
194
|
+
"--body",
|
|
195
|
+
request.body,
|
|
196
|
+
"--head",
|
|
197
|
+
request.head_branch,
|
|
198
|
+
"--base",
|
|
199
|
+
request.base_branch,
|
|
200
|
+
]
|
|
201
|
+
if request.draft:
|
|
202
|
+
cmd.append("--draft")
|
|
203
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
204
|
+
if proc.returncode != 0:
|
|
205
|
+
raise AdapterError(proc.stderr.strip() or proc.stdout.strip() or "gh pr create failed")
|
|
206
|
+
url = proc.stdout.strip()
|
|
207
|
+
return PRRef(
|
|
208
|
+
repo=request.repo,
|
|
209
|
+
url=url or None,
|
|
210
|
+
head_branch=request.head_branch,
|
|
211
|
+
base_branch=request.base_branch,
|
|
212
|
+
title=request.title,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def merge_pr(
|
|
216
|
+
self,
|
|
217
|
+
repo: str,
|
|
218
|
+
number: int,
|
|
219
|
+
*,
|
|
220
|
+
method: MergeMethod,
|
|
221
|
+
) -> MergeReceipt:
|
|
222
|
+
proc = subprocess.run(
|
|
223
|
+
[
|
|
224
|
+
self.gh_binary,
|
|
225
|
+
"pr",
|
|
226
|
+
"merge",
|
|
227
|
+
str(number),
|
|
228
|
+
"--repo",
|
|
229
|
+
repo,
|
|
230
|
+
method.gh_flag,
|
|
231
|
+
],
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
check=False,
|
|
235
|
+
)
|
|
236
|
+
if proc.returncode != 0:
|
|
237
|
+
raise AdapterError(proc.stderr.strip() or proc.stdout.strip() or "gh pr merge failed")
|
|
238
|
+
|
|
239
|
+
# `gh pr merge` is silent on successful non-interactive merges. Read
|
|
240
|
+
# the immutable PR record rather than reconstructing a receipt from
|
|
241
|
+
# the command inputs or asking what commit the base points at now.
|
|
242
|
+
requested = PRRef(repo=repo, number=number)
|
|
243
|
+
try:
|
|
244
|
+
payload = _run_json(
|
|
245
|
+
[
|
|
246
|
+
self.gh_binary,
|
|
247
|
+
"pr",
|
|
248
|
+
"view",
|
|
249
|
+
str(number),
|
|
250
|
+
"--repo",
|
|
251
|
+
repo,
|
|
252
|
+
"--json",
|
|
253
|
+
"number,url,state,mergeCommit",
|
|
254
|
+
]
|
|
255
|
+
)
|
|
256
|
+
return _parse_merge_receipt(
|
|
257
|
+
payload,
|
|
258
|
+
requested=requested,
|
|
259
|
+
requested_method=method,
|
|
260
|
+
)
|
|
261
|
+
except AdapterError as exc:
|
|
262
|
+
raise MergeEvidenceError(
|
|
263
|
+
requested=requested,
|
|
264
|
+
requested_method=method,
|
|
265
|
+
reason=str(exc),
|
|
266
|
+
) from exc
|
|
267
|
+
|
|
268
|
+
def pr_status(self, repo: str, number: int) -> PRStatus:
|
|
269
|
+
payload = _run_json(
|
|
270
|
+
[
|
|
271
|
+
self.gh_binary,
|
|
272
|
+
"pr",
|
|
273
|
+
"view",
|
|
274
|
+
str(number),
|
|
275
|
+
"--repo",
|
|
276
|
+
repo,
|
|
277
|
+
"--json",
|
|
278
|
+
"number,url,headRefName,baseRefName,title,state,mergeable,statusCheckRollup",
|
|
279
|
+
]
|
|
280
|
+
)
|
|
281
|
+
assert isinstance(payload, dict)
|
|
282
|
+
checks = self._parse_checks(payload.get("statusCheckRollup") or [])
|
|
283
|
+
ref = PRRef(
|
|
284
|
+
repo=repo,
|
|
285
|
+
number=payload.get("number"),
|
|
286
|
+
url=payload.get("url"),
|
|
287
|
+
head_branch=payload.get("headRefName"),
|
|
288
|
+
base_branch=payload.get("baseRefName"),
|
|
289
|
+
title=payload.get("title"),
|
|
290
|
+
)
|
|
291
|
+
return PRStatus(
|
|
292
|
+
ref=ref,
|
|
293
|
+
state=str(payload.get("state", "UNKNOWN")),
|
|
294
|
+
mergeable=(
|
|
295
|
+
str(payload.get("mergeable"))
|
|
296
|
+
if payload.get("mergeable") is not None
|
|
297
|
+
else None
|
|
298
|
+
),
|
|
299
|
+
checks=checks,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def list_prs(self, repo: str, *, head_branch: str | None = None) -> list[PRRef]:
|
|
303
|
+
payload = _run_json(
|
|
304
|
+
[
|
|
305
|
+
self.gh_binary,
|
|
306
|
+
"pr",
|
|
307
|
+
"list",
|
|
308
|
+
"--repo",
|
|
309
|
+
repo,
|
|
310
|
+
"--json",
|
|
311
|
+
"number,url,headRefName,baseRefName,title",
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
assert isinstance(payload, list)
|
|
315
|
+
refs: list[PRRef] = []
|
|
316
|
+
for item in payload:
|
|
317
|
+
if not isinstance(item, dict):
|
|
318
|
+
continue
|
|
319
|
+
if head_branch and item.get("headRefName") != head_branch:
|
|
320
|
+
continue
|
|
321
|
+
refs.append(
|
|
322
|
+
PRRef(
|
|
323
|
+
repo=repo,
|
|
324
|
+
number=item.get("number"),
|
|
325
|
+
url=item.get("url"),
|
|
326
|
+
head_branch=item.get("headRefName"),
|
|
327
|
+
base_branch=item.get("baseRefName"),
|
|
328
|
+
title=item.get("title"),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
return refs
|
|
332
|
+
|
|
333
|
+
def pr_checks(self, repo: str, number: int) -> list[PRCheck]:
|
|
334
|
+
return self.pr_status(repo, number).checks
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _parse_checks(rows: list[object]) -> list[PRCheck]:
|
|
338
|
+
checks: list[PRCheck] = []
|
|
339
|
+
for row in rows:
|
|
340
|
+
if not isinstance(row, dict):
|
|
341
|
+
continue
|
|
342
|
+
checks.append(
|
|
343
|
+
PRCheck(
|
|
344
|
+
name=str(row.get("name", "unknown")),
|
|
345
|
+
status=str(row.get("status", "UNKNOWN")),
|
|
346
|
+
conclusion=(
|
|
347
|
+
str(row["conclusion"])
|
|
348
|
+
if row.get("conclusion") is not None
|
|
349
|
+
else None
|
|
350
|
+
),
|
|
351
|
+
details_url=row.get("detailsUrl"),
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
return checks
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_platform_adapter(name: str) -> PlatformAdapter:
|
|
358
|
+
normalized = name.strip().lower()
|
|
359
|
+
if normalized in {"github", "gh"}:
|
|
360
|
+
return GitHubAdapter()
|
|
361
|
+
raise AdapterError(f"unknown platform adapter: {name}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
_GIT_OBJECT_ID = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _parse_merge_receipt(
|
|
368
|
+
payload: object,
|
|
369
|
+
*,
|
|
370
|
+
requested: PRRef,
|
|
371
|
+
requested_method: MergeMethod,
|
|
372
|
+
) -> MergeReceipt:
|
|
373
|
+
if not isinstance(payload, dict):
|
|
374
|
+
raise AdapterError("gh pr view did not return an object")
|
|
375
|
+
if payload.get("state") != "MERGED":
|
|
376
|
+
raise AdapterError(
|
|
377
|
+
"gh pr merge returned success but the structured PR record is not MERGED"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
number = payload.get("number")
|
|
381
|
+
if isinstance(number, bool) or not isinstance(number, int):
|
|
382
|
+
raise AdapterError("merged PR record has no integer number")
|
|
383
|
+
url = payload.get("url")
|
|
384
|
+
if not isinstance(url, str) or not url:
|
|
385
|
+
raise AdapterError("merged PR record has no URL")
|
|
386
|
+
|
|
387
|
+
observed_repo, url_number = _repo_and_number_from_pr_url(url)
|
|
388
|
+
observed = PRRef(repo=observed_repo, number=number, url=url)
|
|
389
|
+
if number != requested.number or url_number != requested.number:
|
|
390
|
+
raise AdapterError(
|
|
391
|
+
f"merged PR record identifies #{number} at URL #{url_number}, "
|
|
392
|
+
f"expected #{requested.number}"
|
|
393
|
+
)
|
|
394
|
+
if observed.repo != requested.repo:
|
|
395
|
+
raise AdapterError(
|
|
396
|
+
f"merged PR record identifies repo {observed.repo!r}, "
|
|
397
|
+
f"expected {requested.repo!r}"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if "mergeCommit" not in payload:
|
|
401
|
+
raise AdapterError("merged PR record omitted mergeCommit")
|
|
402
|
+
merge_commit = payload["mergeCommit"]
|
|
403
|
+
commit_sha: str | None
|
|
404
|
+
if merge_commit is None:
|
|
405
|
+
commit_sha = None
|
|
406
|
+
elif isinstance(merge_commit, dict):
|
|
407
|
+
oid = merge_commit.get("oid")
|
|
408
|
+
if not isinstance(oid, str) or _GIT_OBJECT_ID.fullmatch(oid) is None:
|
|
409
|
+
raise AdapterError("merged PR record contains an invalid mergeCommit.oid")
|
|
410
|
+
commit_sha = oid
|
|
411
|
+
else:
|
|
412
|
+
raise AdapterError("merged PR record contains a malformed mergeCommit")
|
|
413
|
+
|
|
414
|
+
return MergeReceipt(
|
|
415
|
+
requested=requested,
|
|
416
|
+
observed=observed,
|
|
417
|
+
commit_sha=commit_sha,
|
|
418
|
+
requested_method=requested_method,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _repo_and_number_from_pr_url(url: str) -> tuple[str, int]:
|
|
423
|
+
try:
|
|
424
|
+
parsed = urlsplit(url)
|
|
425
|
+
except ValueError as exc:
|
|
426
|
+
raise AdapterError(f"merged PR record contains an invalid PR URL: {url!r}") from exc
|
|
427
|
+
path = [part for part in parsed.path.split("/") if part]
|
|
428
|
+
if (
|
|
429
|
+
parsed.scheme != "https"
|
|
430
|
+
or parsed.netloc.lower() != "github.com"
|
|
431
|
+
or bool(parsed.query)
|
|
432
|
+
or bool(parsed.fragment)
|
|
433
|
+
or len(path) != 4
|
|
434
|
+
or path[2] != "pull"
|
|
435
|
+
):
|
|
436
|
+
raise AdapterError(f"merged PR record contains an invalid PR URL: {url!r}")
|
|
437
|
+
try:
|
|
438
|
+
number = int(path[3])
|
|
439
|
+
except ValueError as exc:
|
|
440
|
+
raise AdapterError(f"merged PR record contains an invalid PR URL: {url!r}") from exc
|
|
441
|
+
return f"{path[0]}/{path[1]}", number
|