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,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