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,283 @@
|
|
|
1
|
+
"""Native PR observations with durable terminal facts and bounded maintenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from threading import Event, Thread
|
|
11
|
+
from typing import Literal, cast
|
|
12
|
+
|
|
13
|
+
from harness_common import Forge as ForgeIdentity
|
|
14
|
+
from harness_common import Repository
|
|
15
|
+
|
|
16
|
+
from ...budget import current_budget, operation
|
|
17
|
+
from ..model import Forge, ForgeError, ForgeRateLimited, PullRequest
|
|
18
|
+
from .model import PullRequestKey, PullRequestStatus, PullRequestStatusUnavailable
|
|
19
|
+
from .store import JSON, ObservationStorageError, ObservationStore, digest
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def repository_json(repo: Repository) -> JSON:
|
|
25
|
+
return {"forge": {"name": repo.forge.name}, "path": repo.path}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def key_json(key: PullRequestKey) -> JSON:
|
|
29
|
+
return {"repository": repository_json(key.repository), "number": key.number}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def key_from_json(value: JSON) -> PullRequestKey:
|
|
33
|
+
repo = cast(JSON, value["repository"])
|
|
34
|
+
forge = cast(JSON, repo["forge"])
|
|
35
|
+
return PullRequestKey(
|
|
36
|
+
Repository(ForgeIdentity(str(forge["name"])), str(repo["path"])), int(str(value["number"]))
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def iso(at: float) -> str:
|
|
41
|
+
return datetime.fromtimestamp(at, UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def epoch(value: object) -> float:
|
|
45
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() if value else 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PullRequestObserver:
|
|
49
|
+
"""Query state; the observer owns persistence, freshness, retry and maintenance.
|
|
50
|
+
|
|
51
|
+
@spec Merged is immutable and is excluded from routine maintenance.
|
|
52
|
+
@spec Closed remains observable and can reopen. Failures do not erase successful facts.
|
|
53
|
+
@link component://docs/pr.md
|
|
54
|
+
@case tests/test_pr_observation.py
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
reader: Forge,
|
|
60
|
+
*,
|
|
61
|
+
forge: str,
|
|
62
|
+
state_dir: str | Path,
|
|
63
|
+
connection: str,
|
|
64
|
+
open_interval: float = 60,
|
|
65
|
+
closed_interval: float = 600,
|
|
66
|
+
retry_interval: float = 30,
|
|
67
|
+
timeout: float = 20,
|
|
68
|
+
now: Callable[[], float] = time.time,
|
|
69
|
+
):
|
|
70
|
+
if (
|
|
71
|
+
not forge
|
|
72
|
+
or not connection
|
|
73
|
+
or min(open_interval, closed_interval, retry_interval, timeout) <= 0
|
|
74
|
+
):
|
|
75
|
+
raise ValueError("nonempty identities and positive intervals are required")
|
|
76
|
+
self.reader, self.forge = reader, forge
|
|
77
|
+
self.store = ObservationStore(state_dir)
|
|
78
|
+
self.connection = digest([forge, connection])
|
|
79
|
+
self.open_interval, self.closed_interval = open_interval, closed_interval
|
|
80
|
+
self.retry_interval, self.timeout, self.now = retry_interval, timeout, now
|
|
81
|
+
self._stop = Event()
|
|
82
|
+
self._thread: Thread | None = None
|
|
83
|
+
|
|
84
|
+
def path(self, key: PullRequestKey) -> Path:
|
|
85
|
+
if key.repository.forge.name != self.forge or not key.repository.path or key.number < 1:
|
|
86
|
+
raise ValueError("invalid PR identity for this observer")
|
|
87
|
+
return (
|
|
88
|
+
self.store.root
|
|
89
|
+
/ "repositories"
|
|
90
|
+
/ digest([self.forge, key.repository.path])
|
|
91
|
+
/ "prs"
|
|
92
|
+
/ str(key.number)
|
|
93
|
+
/ "observation.json"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _stored(self, path: Path) -> JSON | None:
|
|
97
|
+
record = self.store.read(path)
|
|
98
|
+
if record and (
|
|
99
|
+
record.get("version") != 1
|
|
100
|
+
or not isinstance(record.get("observation"), dict)
|
|
101
|
+
or not isinstance(record.get("scopeIds"), list)
|
|
102
|
+
or type(record.get("failures")) is not int
|
|
103
|
+
):
|
|
104
|
+
raise ObservationStorageError(f"Invalid PR observation {path}")
|
|
105
|
+
return record if record and record.get("connection") == self.connection else None
|
|
106
|
+
|
|
107
|
+
def _get(self, key: PullRequestKey, *, verify: bool = False) -> JSON:
|
|
108
|
+
with operation(timeout=self.timeout, cancel=self._stop):
|
|
109
|
+
budget = current_budget(self.timeout)
|
|
110
|
+
path = self.path(key)
|
|
111
|
+
with self.store.locked(path) as owned:
|
|
112
|
+
previous = self._stored(path)
|
|
113
|
+
old = cast(JSON, previous["observation"]) if previous else {}
|
|
114
|
+
if old and old.get("key") != key_json(key):
|
|
115
|
+
raise ObservationStorageError(f"PR identity mismatch at {path}")
|
|
116
|
+
snapshot = cast(JSON, old.get("snapshot", {}))
|
|
117
|
+
# The terminal lifecycle survives even explicit association verification.
|
|
118
|
+
if snapshot.get("state") == "merged":
|
|
119
|
+
return old
|
|
120
|
+
if not verify and epoch(old.get("nextRefreshAt")) > self.now():
|
|
121
|
+
if old.get("lastError"):
|
|
122
|
+
raise PullRequestStatusUnavailable(
|
|
123
|
+
f"PR retry pending: {key.repository.path}#{key.number}"
|
|
124
|
+
)
|
|
125
|
+
if snapshot:
|
|
126
|
+
return old
|
|
127
|
+
attempted = iso(self.now())
|
|
128
|
+
try:
|
|
129
|
+
pr = self.reader.for_repository(key.repository.path).get(key.number)
|
|
130
|
+
budget.remaining()
|
|
131
|
+
if (
|
|
132
|
+
pr.number != key.number
|
|
133
|
+
or pr.state not in {"open", "closed", "merged"}
|
|
134
|
+
or pr.target_repository != key.repository.path
|
|
135
|
+
):
|
|
136
|
+
raise ForgeError("Forge returned invalid PR identity or lifecycle")
|
|
137
|
+
snapshot = {
|
|
138
|
+
**key_json(key),
|
|
139
|
+
"state": pr.state,
|
|
140
|
+
"title": pr.title,
|
|
141
|
+
"url": pr.web_url,
|
|
142
|
+
"headSha": pr.sha,
|
|
143
|
+
"sourceBranch": pr.source_branch,
|
|
144
|
+
"targetBranch": pr.target_branch,
|
|
145
|
+
}
|
|
146
|
+
if pr.source_repository:
|
|
147
|
+
snapshot["sourceRepository"] = repository_json(
|
|
148
|
+
Repository(key.repository.forge, pr.source_repository)
|
|
149
|
+
)
|
|
150
|
+
observation: JSON = {
|
|
151
|
+
"key": key_json(key),
|
|
152
|
+
"snapshot": snapshot,
|
|
153
|
+
"observedAt": iso(self.now()),
|
|
154
|
+
"attemptedAt": attempted,
|
|
155
|
+
}
|
|
156
|
+
if pr.state != "merged":
|
|
157
|
+
interval = (
|
|
158
|
+
self.closed_interval if pr.state == "closed" else self.open_interval
|
|
159
|
+
)
|
|
160
|
+
observation["nextRefreshAt"] = iso(self.now() + interval)
|
|
161
|
+
failures = 0
|
|
162
|
+
except (ForgeError, OSError) as exc:
|
|
163
|
+
if self._stop.is_set() or isinstance(exc, InterruptedError):
|
|
164
|
+
raise
|
|
165
|
+
failures = int(str(previous["failures"])) + 1 if previous else 1
|
|
166
|
+
delay = min(3600, self.retry_interval * 2 ** min(failures - 1, 10))
|
|
167
|
+
if isinstance(exc, ForgeRateLimited):
|
|
168
|
+
delay = max(delay, exc.retry_at - self.now())
|
|
169
|
+
observation = {
|
|
170
|
+
**old,
|
|
171
|
+
"key": key_json(key),
|
|
172
|
+
"attemptedAt": attempted,
|
|
173
|
+
"nextRefreshAt": iso(self.now() + delay),
|
|
174
|
+
"lastError": {
|
|
175
|
+
"stage": "refresh",
|
|
176
|
+
"kind": "rate_limit"
|
|
177
|
+
if isinstance(exc, ForgeRateLimited)
|
|
178
|
+
else "transport",
|
|
179
|
+
"message": str(exc),
|
|
180
|
+
"key": key_json(key),
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
logger.warning(
|
|
184
|
+
"PR observation failed repository=%s number=%s error=%s",
|
|
185
|
+
key.repository.path,
|
|
186
|
+
key.number,
|
|
187
|
+
exc,
|
|
188
|
+
)
|
|
189
|
+
record: JSON = {
|
|
190
|
+
"version": 1,
|
|
191
|
+
"connection": self.connection,
|
|
192
|
+
"observation": observation,
|
|
193
|
+
"scopeIds": previous["scopeIds"] if previous else [],
|
|
194
|
+
"failures": failures,
|
|
195
|
+
}
|
|
196
|
+
self.store.write(
|
|
197
|
+
path.parent.parent.parent / "repository.json",
|
|
198
|
+
repository_json(key.repository),
|
|
199
|
+
owned,
|
|
200
|
+
)
|
|
201
|
+
self.store.write(path, record, owned)
|
|
202
|
+
if observation.get("lastError"):
|
|
203
|
+
raise PullRequestStatusUnavailable(
|
|
204
|
+
f"PR lifecycle unavailable: {key.repository.path}#{key.number}"
|
|
205
|
+
)
|
|
206
|
+
return observation
|
|
207
|
+
|
|
208
|
+
def get_pull_request(self, key: PullRequestKey) -> PullRequestStatus:
|
|
209
|
+
observed = self._get(key)
|
|
210
|
+
snapshot = cast(JSON, observed["snapshot"])
|
|
211
|
+
return PullRequestStatus(
|
|
212
|
+
key,
|
|
213
|
+
cast(Literal["open", "closed", "merged"], snapshot["state"]),
|
|
214
|
+
str(observed["observedAt"]),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _snapshot(self, key: PullRequestKey, *, verify: bool = False) -> PullRequest:
|
|
218
|
+
value = cast(JSON, self._get(key, verify=verify)["snapshot"])
|
|
219
|
+
source = cast(JSON, value.get("sourceRepository", {}))
|
|
220
|
+
return PullRequest(
|
|
221
|
+
number=key.number,
|
|
222
|
+
state=str(value["state"]),
|
|
223
|
+
title=str(value.get("title", "")),
|
|
224
|
+
source_branch=str(value.get("sourceBranch", "")),
|
|
225
|
+
target_branch=str(value.get("targetBranch", "")),
|
|
226
|
+
web_url=str(value.get("url", "")),
|
|
227
|
+
sha=str(value.get("headSha", "")),
|
|
228
|
+
source_repository=str(source.get("path", "")),
|
|
229
|
+
target_repository=key.repository.path,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def maintain(self, *, limit: int = 5) -> tuple[str, ...]:
|
|
233
|
+
"""One bounded round; failed objects cannot monopolize healthy observations."""
|
|
234
|
+
if limit < 1:
|
|
235
|
+
raise ValueError("limit must be positive")
|
|
236
|
+
due: list[tuple[float, PullRequestKey]] = []
|
|
237
|
+
issues: list[str] = []
|
|
238
|
+
for path in self.store.root.glob("repositories/*/prs/*/observation.json"):
|
|
239
|
+
try:
|
|
240
|
+
record = self._stored(path)
|
|
241
|
+
if not record:
|
|
242
|
+
continue
|
|
243
|
+
observation = cast(JSON, record["observation"])
|
|
244
|
+
if cast(JSON, observation.get("snapshot", {})).get("state") == "merged":
|
|
245
|
+
continue
|
|
246
|
+
at = epoch(observation.get("nextRefreshAt"))
|
|
247
|
+
if at <= self.now():
|
|
248
|
+
due.append((at, key_from_json(cast(JSON, observation["key"]))))
|
|
249
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
250
|
+
issues.append(str(exc))
|
|
251
|
+
for _, key in sorted(
|
|
252
|
+
due, key=lambda item: (item[0], item[1].repository.path, item[1].number)
|
|
253
|
+
)[:limit]:
|
|
254
|
+
try:
|
|
255
|
+
self.get_pull_request(key)
|
|
256
|
+
except (ForgeError, OSError) as exc:
|
|
257
|
+
if self._stop.is_set():
|
|
258
|
+
break
|
|
259
|
+
issues.append(str(exc))
|
|
260
|
+
return tuple(issues)
|
|
261
|
+
|
|
262
|
+
def start(self) -> None:
|
|
263
|
+
"""Opt in to periodic maintenance; close cancels cooperative work and joins it."""
|
|
264
|
+
if self._stop.is_set():
|
|
265
|
+
raise RuntimeError("observer is closed")
|
|
266
|
+
if self._thread is not None:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
def run() -> None:
|
|
270
|
+
while not self._stop.is_set():
|
|
271
|
+
try:
|
|
272
|
+
self.maintain()
|
|
273
|
+
except (ForgeError, OSError) as exc:
|
|
274
|
+
logger.warning("PR maintenance failed: %s", exc)
|
|
275
|
+
self._stop.wait(min(self.open_interval, self.closed_interval, self.retry_interval))
|
|
276
|
+
|
|
277
|
+
self._thread = Thread(target=run, name="repokit-pr-observer", daemon=True)
|
|
278
|
+
self._thread.start()
|
|
279
|
+
|
|
280
|
+
def close(self) -> None:
|
|
281
|
+
self._stop.set()
|
|
282
|
+
if self._thread:
|
|
283
|
+
self._thread.join()
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Atomic records and the shared proper-lockfile directory lease protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable, Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from threading import Event, RLock, Thread
|
|
14
|
+
from typing import cast
|
|
15
|
+
|
|
16
|
+
from ...budget import current_budget
|
|
17
|
+
|
|
18
|
+
JSON = dict[str, object]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def digest(value: object) -> str:
|
|
22
|
+
return hashlib.sha256(
|
|
23
|
+
json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
|
24
|
+
).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ObservationStorageError(OSError):
|
|
28
|
+
"""A successful observation could not be durably read or published."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ObservationStore:
|
|
32
|
+
def __init__(self, root: str | Path):
|
|
33
|
+
if not str(root).strip():
|
|
34
|
+
raise ValueError("state_dir must be nonempty")
|
|
35
|
+
self.root = Path(root).expanduser().resolve()
|
|
36
|
+
|
|
37
|
+
def read(self, path: Path) -> JSON | None:
|
|
38
|
+
try:
|
|
39
|
+
value = json.loads(path.read_text())
|
|
40
|
+
if not isinstance(value, dict):
|
|
41
|
+
raise ValueError("expected an object")
|
|
42
|
+
return cast(JSON, value)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
return None
|
|
45
|
+
except (ValueError, OSError) as exc:
|
|
46
|
+
raise ObservationStorageError(f"Cannot read PR state {path}") from exc
|
|
47
|
+
|
|
48
|
+
def write(self, path: Path, value: JSON, assert_owned: Callable[[], None]) -> None:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
50
|
+
temporary = path.with_name(f"{path.name}.{uuid.uuid4()}.tmp")
|
|
51
|
+
try:
|
|
52
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
53
|
+
with os.fdopen(fd, "w") as stream:
|
|
54
|
+
json.dump(value, stream, ensure_ascii=False, separators=(",", ":"))
|
|
55
|
+
stream.flush()
|
|
56
|
+
os.fsync(stream.fileno())
|
|
57
|
+
assert_owned()
|
|
58
|
+
os.replace(temporary, path)
|
|
59
|
+
fd = os.open(path.parent, os.O_RDONLY)
|
|
60
|
+
try:
|
|
61
|
+
os.fsync(fd)
|
|
62
|
+
finally:
|
|
63
|
+
os.close(fd)
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise ObservationStorageError(f"Cannot persist PR state {path}") from exc
|
|
66
|
+
finally:
|
|
67
|
+
temporary.unlink(missing_ok=True)
|
|
68
|
+
|
|
69
|
+
@contextmanager
|
|
70
|
+
def locked(self, path: Path) -> Iterator[Callable[[], None]]:
|
|
71
|
+
"""An empty .lock directory, stale after 30s, renewed every 10s.
|
|
72
|
+
|
|
73
|
+
@spec Python and TypeScript serialize refresh and publication using the same lease.
|
|
74
|
+
@link component://docs/pr.md
|
|
75
|
+
No owner files: proper-lockfile releases with rmdir. The inode and last
|
|
76
|
+
mtime fence this owner after a lease has been reclaimed by another process.
|
|
77
|
+
"""
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
79
|
+
lock = Path(f"{path}.lock")
|
|
80
|
+
budget = current_budget(30)
|
|
81
|
+
while True:
|
|
82
|
+
budget.remaining()
|
|
83
|
+
try:
|
|
84
|
+
lock.mkdir(mode=0o700)
|
|
85
|
+
break
|
|
86
|
+
except FileExistsError:
|
|
87
|
+
try:
|
|
88
|
+
if time.time() - lock.stat().st_mtime > 30:
|
|
89
|
+
lock.rmdir()
|
|
90
|
+
continue
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
continue
|
|
93
|
+
time.sleep(min(0.025, budget.remaining()))
|
|
94
|
+
identity = lock.stat()
|
|
95
|
+
stamp = identity.st_mtime_ns
|
|
96
|
+
stopped = Event()
|
|
97
|
+
compromised: list[OSError] = []
|
|
98
|
+
mutex = RLock()
|
|
99
|
+
|
|
100
|
+
def owned() -> None:
|
|
101
|
+
with mutex:
|
|
102
|
+
if compromised:
|
|
103
|
+
raise ObservationStorageError("PR state lease compromised") from compromised[0]
|
|
104
|
+
current = lock.stat()
|
|
105
|
+
if (
|
|
106
|
+
current.st_ino != identity.st_ino
|
|
107
|
+
or current.st_mtime_ns != stamp
|
|
108
|
+
or time.time() - current.st_mtime > 30
|
|
109
|
+
):
|
|
110
|
+
raise ObservationStorageError("PR state lease replaced or expired")
|
|
111
|
+
|
|
112
|
+
def renew() -> None:
|
|
113
|
+
nonlocal stamp
|
|
114
|
+
while not stopped.wait(10):
|
|
115
|
+
try:
|
|
116
|
+
with mutex:
|
|
117
|
+
owned()
|
|
118
|
+
os.utime(lock, None)
|
|
119
|
+
stamp = lock.stat().st_mtime_ns
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
compromised.append(exc)
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
thread = Thread(target=renew, name="repokit-pr-lease", daemon=True)
|
|
125
|
+
thread.start()
|
|
126
|
+
try:
|
|
127
|
+
yield owned
|
|
128
|
+
finally:
|
|
129
|
+
stopped.set()
|
|
130
|
+
thread.join()
|
|
131
|
+
try:
|
|
132
|
+
owned()
|
|
133
|
+
lock.rmdir()
|
|
134
|
+
except OSError:
|
|
135
|
+
# A reclaimed lease belongs to the next owner; never remove it.
|
|
136
|
+
pass
|