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.
@@ -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)