compforge-repokit 0.15.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.
- compforge_repokit-0.15.0.dist-info/METADATA +114 -0
- compforge_repokit-0.15.0.dist-info/RECORD +37 -0
- compforge_repokit-0.15.0.dist-info/WHEEL +4 -0
- repokit/__init__.py +40 -0
- repokit/_git.py +177 -0
- repokit/_inspect.py +70 -0
- repokit/_language.py +23 -0
- repokit/_layout.py +187 -0
- repokit/_process.py +135 -0
- repokit/_staging.py +59 -0
- repokit/budget.py +40 -0
- repokit/dependencies.py +378 -0
- repokit/forge/__init__.py +13 -0
- repokit/forge/_http_state.py +92 -0
- repokit/forge/_page.py +34 -0
- repokit/forge/_rest.py +233 -0
- repokit/forge/creation.py +88 -0
- repokit/forge/github.py +440 -0
- repokit/forge/gitlab.py +407 -0
- repokit/forge/model.py +380 -0
- repokit/forge/observation/__init__.py +9 -0
- repokit/forge/observation/context.py +255 -0
- repokit/forge/observation/model.py +40 -0
- repokit/forge/observation/service.py +283 -0
- repokit/forge/observation/store.py +136 -0
- repokit/git.py +296 -0
- repokit/git_index.py +275 -0
- repokit/git_state.py +447 -0
- repokit/languages.json +1115 -0
- repokit/model.py +59 -0
- repokit/py.typed +0 -0
- repokit/remote.py +41 -0
- repokit/repository.py +84 -0
- repokit/snapshot.py +275 -0
- repokit/stash.py +66 -0
- repokit/tree.py +191 -0
- repokit/validation.py +508 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Caller-selected, credential-isolated HTTP coordination for short-lived processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .._process import Budget
|
|
16
|
+
from .model import ForgeRateLimited
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpState:
|
|
20
|
+
"""Share cooldown and conditional responses, never application freshness policy.
|
|
21
|
+
|
|
22
|
+
+spec=`All processes using a state directory and credential serialize dispatch;
|
|
23
|
+
a persisted cooldown is checked after acquiring the lock and before network I/O.`
|
|
24
|
+
+link=component://docs/pr.md
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, directory: Path, authority: str, credential: str):
|
|
28
|
+
identity = hashlib.sha256(f"{authority}\0{credential}".encode()).hexdigest()
|
|
29
|
+
self.home = directory / identity
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def acquire(self, budget: Budget) -> Iterator[None]:
|
|
33
|
+
self.home.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
34
|
+
fd = os.open(self.home / "request.lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
35
|
+
try:
|
|
36
|
+
while True:
|
|
37
|
+
budget.remaining()
|
|
38
|
+
try:
|
|
39
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
40
|
+
break
|
|
41
|
+
except BlockingIOError:
|
|
42
|
+
time.sleep(min(0.05, budget.remaining()))
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
os.close(fd)
|
|
46
|
+
|
|
47
|
+
def read(self, name: str) -> dict[str, Any]:
|
|
48
|
+
try:
|
|
49
|
+
value = json.loads((self.home / name).read_text())
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
return {}
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
raise ValueError(f"invalid HTTP state: {name}")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
def write(self, name: str, value: dict[str, Any]) -> None:
|
|
57
|
+
path = self.home / name
|
|
58
|
+
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
|
59
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
60
|
+
with os.fdopen(fd, "w") as stream:
|
|
61
|
+
json.dump(value, stream)
|
|
62
|
+
temporary.replace(path)
|
|
63
|
+
|
|
64
|
+
def check(self, resource: str) -> None:
|
|
65
|
+
limits = self.read("limits.json")
|
|
66
|
+
for key in ("secondary", resource):
|
|
67
|
+
retry_at = float(limits.get(key, 0))
|
|
68
|
+
if retry_at > time.time():
|
|
69
|
+
raise ForgeRateLimited(resource, retry_at, secondary=key == "secondary")
|
|
70
|
+
|
|
71
|
+
def defer(self, limit: ForgeRateLimited) -> None:
|
|
72
|
+
limits = self.read("limits.json")
|
|
73
|
+
key = "secondary" if limit.secondary else limit.resource
|
|
74
|
+
if limit.secondary:
|
|
75
|
+
failures = int(limits.get("secondary_failures", 0)) + 1
|
|
76
|
+
# Consecutive secondary limits back off even without Retry-After.
|
|
77
|
+
limit.retry_at = max(
|
|
78
|
+
limit.retry_at, time.time() + min(3600, 60 * 2 ** min(failures - 1, 6))
|
|
79
|
+
)
|
|
80
|
+
limits["secondary_failures"] = failures
|
|
81
|
+
limits[key] = max(float(limits.get(key, 0)), limit.retry_at)
|
|
82
|
+
self.write("limits.json", limits)
|
|
83
|
+
|
|
84
|
+
def succeeded(self) -> None:
|
|
85
|
+
limits = self.read("limits.json")
|
|
86
|
+
if limits.pop("secondary_failures", None) is not None:
|
|
87
|
+
self.write("limits.json", limits)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def cache_key(url: str, headers: dict[str, str]) -> str:
|
|
91
|
+
representation = json.dumps([url, sorted(headers.items())], sort_keys=True)
|
|
92
|
+
return hashlib.sha256(representation.encode()).hexdigest() + ".json"
|
repokit/forge/_page.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Bounded repository page positions; provider URLs never come from a cursor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .model import ForgeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def page_position(scope: str, page_size: int | None, cursor: str | None) -> tuple[int, int]:
|
|
11
|
+
page = 1
|
|
12
|
+
size = 50 if page_size is None else page_size
|
|
13
|
+
if cursor is not None:
|
|
14
|
+
try:
|
|
15
|
+
value = json.loads(cursor)
|
|
16
|
+
except (ValueError, TypeError) as exc:
|
|
17
|
+
raise ForgeError("Invalid repository page cursor") from exc
|
|
18
|
+
if (
|
|
19
|
+
not isinstance(value, dict)
|
|
20
|
+
or value.get("version") != 2
|
|
21
|
+
or value.get("scope") != scope
|
|
22
|
+
or (page_size is not None and page_size != value.get("page_size"))
|
|
23
|
+
):
|
|
24
|
+
raise ForgeError("Repository page cursor does not match this query")
|
|
25
|
+
page, size = value.get("page", 0), value.get("page_size", 0)
|
|
26
|
+
if type(page) is not int or page < 1 or type(size) is not int or not 1 <= size <= 100:
|
|
27
|
+
raise ForgeError("Repository page requires a positive page and page_size between 1 and 100")
|
|
28
|
+
return page, size
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def page_continuation(scope: str, page: int, page_size: int, more: bool) -> str | None:
|
|
32
|
+
if not more:
|
|
33
|
+
return None
|
|
34
|
+
return json.dumps({"version": 2, "scope": scope, "page": page + 1, "page_size": page_size})
|
repokit/forge/_rest.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""RestClient — the single HTTP transport shared by every forge adapter.
|
|
2
|
+
|
|
3
|
+
The GitHub and GitLab REST surfaces differ only in base URL, auth header, and JSON
|
|
4
|
+
shapes; the request mechanics (urllib, params encoding, JSON body, error typing,
|
|
5
|
+
timeouts) are identical. So this is the ONE place that touches urllib — swap it for the
|
|
6
|
+
SDK / an MCP server later and the adapters stay put. Adapters parametrize it with their
|
|
7
|
+
base URL + headers and speak verbs (`get/post/put/patch`); they never import urllib.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from contextlib import nullcontext
|
|
19
|
+
from email.message import Message
|
|
20
|
+
from email.utils import parsedate_to_datetime
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..budget import current_budget, operation
|
|
24
|
+
from ._http_state import HttpState
|
|
25
|
+
from .model import ForgeAuthError, ForgeError, ForgeNotFound, ForgeOutcomeUnknown, ForgeRateLimited
|
|
26
|
+
|
|
27
|
+
DEFAULT_TIMEOUT = 10
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RestClient:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
base_url: str,
|
|
35
|
+
headers: dict[str, str],
|
|
36
|
+
*,
|
|
37
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
38
|
+
state: HttpState | None = None,
|
|
39
|
+
resource: str = "core",
|
|
40
|
+
):
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self._headers = dict(headers)
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self.state, self.resource = state, resource
|
|
45
|
+
self.requests = 0
|
|
46
|
+
self.not_modified = 0
|
|
47
|
+
|
|
48
|
+
def request(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
path: str,
|
|
52
|
+
*,
|
|
53
|
+
params: dict[str, Any] | None = None,
|
|
54
|
+
body: dict[str, Any] | None = None,
|
|
55
|
+
) -> Any:
|
|
56
|
+
"""`<base_url>/<path>`, returns parsed JSON (None on empty body).
|
|
57
|
+
|
|
58
|
+
`params` list values encode as repeated keys (e.g. GitLab `iids[]`).
|
|
59
|
+
Conditional responses are revalidated on every read. Cooldown is checked
|
|
60
|
+
under the dispatch lock; no automatic retries, including for mutations.
|
|
61
|
+
"""
|
|
62
|
+
url = (
|
|
63
|
+
f"{self.base_url}/{path.lstrip('/')}" if path else self.base_url
|
|
64
|
+
) # "" → repo root, no trailing slash
|
|
65
|
+
if params:
|
|
66
|
+
url += "?" + urllib.parse.urlencode(params, doseq=True)
|
|
67
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
68
|
+
headers = dict(self._headers)
|
|
69
|
+
if data is not None:
|
|
70
|
+
headers.setdefault("Content-Type", "application/json")
|
|
71
|
+
budget = current_budget(self.timeout)
|
|
72
|
+
# A timeout/cancellation before dispatch is a known non-action.
|
|
73
|
+
budget.remaining()
|
|
74
|
+
mutating = method.upper() not in ("GET", "HEAD", "OPTIONS")
|
|
75
|
+
if (
|
|
76
|
+
body
|
|
77
|
+
and isinstance(body.get("query"), str)
|
|
78
|
+
and body["query"].lstrip().startswith("query")
|
|
79
|
+
):
|
|
80
|
+
mutating = False
|
|
81
|
+
dispatched = False
|
|
82
|
+
try:
|
|
83
|
+
with self.state.acquire(budget) if self.state else nullcontext():
|
|
84
|
+
if self.state:
|
|
85
|
+
self.state.check(self.resource)
|
|
86
|
+
key = HttpState.cache_key(url, headers)
|
|
87
|
+
cached = self.state.read(key) if self.state and method.upper() == "GET" else {}
|
|
88
|
+
if cached.get("etag"):
|
|
89
|
+
headers["If-None-Match"] = cached["etag"]
|
|
90
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
|
|
91
|
+
try:
|
|
92
|
+
timeout = budget.remaining()
|
|
93
|
+
self.requests += 1
|
|
94
|
+
dispatched = True
|
|
95
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
96
|
+
raw = resp.read((16 << 20) + 1)
|
|
97
|
+
budget.remaining()
|
|
98
|
+
if len(raw) > 16 << 20:
|
|
99
|
+
raise ValueError("response exceeds 16 MiB")
|
|
100
|
+
value = json.loads(raw) if raw else None
|
|
101
|
+
limit = self._limit(resp.headers, value, graphql=body is not None)
|
|
102
|
+
if limit:
|
|
103
|
+
if self.state:
|
|
104
|
+
self.state.defer(limit)
|
|
105
|
+
# A successful final request is usable even when remaining=0.
|
|
106
|
+
if isinstance(value, dict) and value.get("errors"):
|
|
107
|
+
raise limit
|
|
108
|
+
if self.state and method.upper() == "GET" and resp.headers.get("ETag"):
|
|
109
|
+
self.state.write(key, {"etag": resp.headers["ETag"], "body": value})
|
|
110
|
+
if self.state:
|
|
111
|
+
self.state.succeeded()
|
|
112
|
+
logger.info(
|
|
113
|
+
"forge request method=%s path=%s resource=%s status=%s",
|
|
114
|
+
method,
|
|
115
|
+
path,
|
|
116
|
+
self.resource,
|
|
117
|
+
resp.status,
|
|
118
|
+
)
|
|
119
|
+
return value
|
|
120
|
+
except urllib.error.HTTPError as e:
|
|
121
|
+
if e.code == 304 and "body" in cached:
|
|
122
|
+
limit = self._limit(e.headers, {})
|
|
123
|
+
if limit and self.state:
|
|
124
|
+
self.state.defer(limit)
|
|
125
|
+
self.not_modified += 1
|
|
126
|
+
if self.state:
|
|
127
|
+
self.state.succeeded()
|
|
128
|
+
logger.info(
|
|
129
|
+
"forge request method=%s path=%s resource=%s status=304",
|
|
130
|
+
method,
|
|
131
|
+
path,
|
|
132
|
+
self.resource,
|
|
133
|
+
)
|
|
134
|
+
return cached["body"]
|
|
135
|
+
raw_error = e.read(65536)
|
|
136
|
+
try:
|
|
137
|
+
value = json.loads(raw_error)
|
|
138
|
+
except ValueError:
|
|
139
|
+
value = {}
|
|
140
|
+
limit = self._limit(e.headers, value, status=e.code)
|
|
141
|
+
if limit:
|
|
142
|
+
if self.state:
|
|
143
|
+
self.state.defer(limit)
|
|
144
|
+
logger.warning(
|
|
145
|
+
"forge cooldown path=%s resource=%s retry_at=%s secondary=%s",
|
|
146
|
+
path,
|
|
147
|
+
limit.resource,
|
|
148
|
+
limit.retry_at,
|
|
149
|
+
limit.secondary,
|
|
150
|
+
)
|
|
151
|
+
raise limit from e
|
|
152
|
+
if e.code in (401, 403):
|
|
153
|
+
raise ForgeAuthError(f"{method} {path} → HTTP {e.code}") from e
|
|
154
|
+
if e.code == 404:
|
|
155
|
+
raise ForgeNotFound(f"{method} {path} → 404") from e
|
|
156
|
+
error = ForgeOutcomeUnknown if mutating and e.code >= 500 else ForgeError
|
|
157
|
+
raise error(
|
|
158
|
+
f"{method} {path} → HTTP {e.code}"
|
|
159
|
+
+ (
|
|
160
|
+
"; inspect remote state before retrying"
|
|
161
|
+
if error is ForgeOutcomeUnknown
|
|
162
|
+
else ""
|
|
163
|
+
)
|
|
164
|
+
) from e
|
|
165
|
+
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
|
166
|
+
error = ForgeOutcomeUnknown if mutating and dispatched else ForgeError
|
|
167
|
+
raise error(
|
|
168
|
+
f"{method} {path} → {e}"
|
|
169
|
+
+ ("; inspect remote state before retrying" if error is ForgeOutcomeUnknown else "")
|
|
170
|
+
) from e
|
|
171
|
+
|
|
172
|
+
def _limit(
|
|
173
|
+
self, headers: Message, value: Any, *, status: int = 200, graphql: bool = False
|
|
174
|
+
) -> ForgeRateLimited | None:
|
|
175
|
+
remaining = headers.get("x-ratelimit-remaining")
|
|
176
|
+
reset = headers.get("x-ratelimit-reset")
|
|
177
|
+
retry = headers.get("retry-after")
|
|
178
|
+
message = str(value.get("message", "")).lower() if isinstance(value, dict) else ""
|
|
179
|
+
errors = (value.get("errors") or []) if graphql and isinstance(value, dict) else []
|
|
180
|
+
limited = any(isinstance(e, dict) and e.get("type") == "RATE_LIMITED" for e in errors)
|
|
181
|
+
if (
|
|
182
|
+
remaining != "0"
|
|
183
|
+
and not limited
|
|
184
|
+
and not (status in (403, 429) and (status == 429 or retry or "rate limit" in message))
|
|
185
|
+
):
|
|
186
|
+
return None
|
|
187
|
+
now = time.time()
|
|
188
|
+
retry_at = now + 60
|
|
189
|
+
if reset and remaining == "0":
|
|
190
|
+
retry_at = max(now + 1, float(reset))
|
|
191
|
+
if retry:
|
|
192
|
+
try:
|
|
193
|
+
retry_at = max(retry_at if remaining == "0" else now, now + float(retry))
|
|
194
|
+
except ValueError:
|
|
195
|
+
retry_at = max(retry_at, parsedate_to_datetime(retry).timestamp())
|
|
196
|
+
return ForgeRateLimited(
|
|
197
|
+
headers.get("x-ratelimit-resource", self.resource),
|
|
198
|
+
retry_at,
|
|
199
|
+
secondary=remaining != "0" and not limited,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def get(self, path: str, **params: Any) -> Any:
|
|
203
|
+
return self.request("GET", path, params=params or None)
|
|
204
|
+
|
|
205
|
+
def get_all(self, path: str, *, per_page: int = 100, **params: Any) -> list[Any]:
|
|
206
|
+
"""Fetch every page from a list endpoint using the page/per_page convention shared
|
|
207
|
+
by GitHub and GitLab. Keeping the loop here makes "all" a transport guarantee instead
|
|
208
|
+
of an adapter promise that silently stops at its first page."""
|
|
209
|
+
if per_page <= 0 or per_page > 100:
|
|
210
|
+
raise ValueError("per_page must be between 1 and 100")
|
|
211
|
+
with operation(timeout=self.timeout):
|
|
212
|
+
out: list[Any] = []
|
|
213
|
+
page = 1
|
|
214
|
+
while True:
|
|
215
|
+
current_budget(self.timeout).remaining()
|
|
216
|
+
batch = self.get(path, **params, page=page, per_page=per_page)
|
|
217
|
+
if not isinstance(batch, list):
|
|
218
|
+
raise ForgeError(f"GET {path} page {page}: expected a list response")
|
|
219
|
+
out.extend(batch)
|
|
220
|
+
if len(out) > 10_000:
|
|
221
|
+
raise ForgeError(f"GET {path}: inventory exceeds 10000 entries")
|
|
222
|
+
if len(batch) < per_page:
|
|
223
|
+
return out
|
|
224
|
+
page += 1
|
|
225
|
+
|
|
226
|
+
def post(self, path: str, body: dict[str, Any]) -> Any:
|
|
227
|
+
return self.request("POST", path, body=body)
|
|
228
|
+
|
|
229
|
+
def put(self, path: str, body: dict[str, Any]) -> Any:
|
|
230
|
+
return self.request("PUT", path, body=body)
|
|
231
|
+
|
|
232
|
+
def patch(self, path: str, body: dict[str, Any]) -> Any:
|
|
233
|
+
return self.request("PATCH", path, body=body)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Resolve and create a pull request using the existing Forge operation contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from harness_common import Repository
|
|
9
|
+
|
|
10
|
+
from .model import Forge, ForgeError, PullRequest
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PullRequestCreation:
|
|
17
|
+
pull_request: PullRequest
|
|
18
|
+
created: bool
|
|
19
|
+
source_repository: Repository
|
|
20
|
+
target_repository: Repository
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def ensure_pull_request(
|
|
24
|
+
forge: Forge,
|
|
25
|
+
*,
|
|
26
|
+
source_branch: str,
|
|
27
|
+
title: str,
|
|
28
|
+
body: str = "",
|
|
29
|
+
target_repository: str | None = None,
|
|
30
|
+
target_branch: str | None = None,
|
|
31
|
+
) -> PullRequestCreation:
|
|
32
|
+
"""Reuse an unambiguous open PR, or create it in the explicit destination.
|
|
33
|
+
|
|
34
|
+
@spec Omitted destination means the source repository, never its fork parent.
|
|
35
|
+
Omitted base preserves an existing PR's base; only creation resolves the default.
|
|
36
|
+
Reuse does not edit title/body. Ambiguous reads and uncertain writes propagate;
|
|
37
|
+
query-before-create is not an exactly-once guarantee across concurrent callers.
|
|
38
|
+
@link component://docs/pr.md
|
|
39
|
+
"""
|
|
40
|
+
if not source_branch or target_branch == "":
|
|
41
|
+
raise ValueError("pull request branches must be nonempty")
|
|
42
|
+
source = forge.repository()
|
|
43
|
+
destination = source
|
|
44
|
+
if target_repository is not None:
|
|
45
|
+
name, separator, path = target_repository.partition("/")
|
|
46
|
+
if not separator or not path or name != source.forge.name:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"target repository {target_repository!r} must be a Repository ID "
|
|
49
|
+
f"within Forge {source.forge.name!r}"
|
|
50
|
+
)
|
|
51
|
+
destination = Repository(source.forge, path)
|
|
52
|
+
client = forge if destination.id() == source.id() else forge.for_repository(destination.path)
|
|
53
|
+
candidates = [
|
|
54
|
+
pr
|
|
55
|
+
for pr in client.prs_for_branch(source_branch, source_repository=source)
|
|
56
|
+
if pr.is_open and (target_branch is None or pr.target_branch == target_branch)
|
|
57
|
+
]
|
|
58
|
+
if len(candidates) > 1:
|
|
59
|
+
raise ForgeError(
|
|
60
|
+
f"multiple open pull requests for {source.id()}:{source_branch} "
|
|
61
|
+
f"in {destination.id()}: {[pr.number for pr in candidates]}"
|
|
62
|
+
)
|
|
63
|
+
if candidates:
|
|
64
|
+
pr = candidates[0]
|
|
65
|
+
created = False
|
|
66
|
+
else:
|
|
67
|
+
base = target_branch or client.default_branch()
|
|
68
|
+
if not base:
|
|
69
|
+
raise ForgeError(f"cannot determine default branch for {destination.id()}")
|
|
70
|
+
pr = client.create(
|
|
71
|
+
source_repository=source,
|
|
72
|
+
source_branch=source_branch,
|
|
73
|
+
target_repository=destination,
|
|
74
|
+
target_branch=base,
|
|
75
|
+
title=title,
|
|
76
|
+
body=body,
|
|
77
|
+
)
|
|
78
|
+
created = True
|
|
79
|
+
logger.info(
|
|
80
|
+
"pull request %s source=%s:%s target=%s:%s number=%s",
|
|
81
|
+
"created" if created else "reused",
|
|
82
|
+
source.id(),
|
|
83
|
+
source_branch,
|
|
84
|
+
destination.id(),
|
|
85
|
+
pr.target_branch,
|
|
86
|
+
pr.number,
|
|
87
|
+
)
|
|
88
|
+
return PullRequestCreation(pr, created, source, destination)
|