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.
repokit/_process.py ADDED
@@ -0,0 +1,135 @@
1
+ """Bounded subprocesses sharing an operation deadline."""
2
+
3
+ import math
4
+ import os
5
+ import selectors
6
+ import signal
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+ from threading import Event
11
+
12
+
13
+ class Budget:
14
+ def __init__(self, timeout: float, cancel: Event | None):
15
+ if not math.isfinite(timeout) or timeout <= 0:
16
+ raise ValueError("timeout must be positive and finite")
17
+ self.parent: Budget | None = None
18
+ self.deadline = time.monotonic() + timeout
19
+ self.cancel = cancel
20
+
21
+ def remaining(self) -> float:
22
+ if self.parent is not None:
23
+ self.parent.remaining()
24
+ if self.cancel is not None and self.cancel.is_set():
25
+ raise InterruptedError("operation cancelled")
26
+ remaining = self.deadline - time.monotonic()
27
+ if remaining <= 0:
28
+ raise TimeoutError("operation timed out")
29
+ return remaining
30
+
31
+
32
+ def run(
33
+ root: Path,
34
+ args: list[str],
35
+ budget: Budget,
36
+ *,
37
+ input: bytes = b"",
38
+ allow_missing: bool = False,
39
+ max_output: int = 16 << 20,
40
+ ) -> bytes:
41
+ return run_command(
42
+ ["git", "-C", str(root), *args],
43
+ budget,
44
+ input=input,
45
+ allow_missing=allow_missing,
46
+ max_output=max_output,
47
+ ).stdout
48
+
49
+
50
+ def run_command(
51
+ command: list[str],
52
+ budget: Budget,
53
+ *,
54
+ input: bytes = b"",
55
+ allow_missing: bool = False,
56
+ max_output: int = 16 << 20,
57
+ env: dict[str, str] | None = None,
58
+ cwd: Path | None = None,
59
+ process_group: bool = False,
60
+ ) -> subprocess.CompletedProcess[bytes]:
61
+ budget.remaining()
62
+ process = subprocess.Popen(
63
+ command,
64
+ stdin=subprocess.PIPE,
65
+ stdout=subprocess.PIPE,
66
+ stderr=subprocess.PIPE,
67
+ env=env,
68
+ cwd=cwd,
69
+ start_new_session=process_group,
70
+ )
71
+ assert process.stdin is not None and process.stdout is not None and process.stderr is not None
72
+ stdout, stderr = bytearray(), bytearray()
73
+ pending = memoryview(input)
74
+ try:
75
+ # Drain both outputs while feeding a batch. communicate() buffers without a
76
+ # capacity bound; waiting before draining can deadlock on a full pipe.
77
+ with selectors.DefaultSelector() as selector:
78
+ for pipe, target in ((process.stdout, stdout), (process.stderr, stderr)):
79
+ os.set_blocking(pipe.fileno(), False)
80
+ selector.register(pipe, selectors.EVENT_READ, target)
81
+ if pending:
82
+ os.set_blocking(process.stdin.fileno(), False)
83
+ selector.register(process.stdin, selectors.EVENT_WRITE)
84
+ else:
85
+ process.stdin.close()
86
+ while selector.get_map():
87
+ remaining = budget.remaining()
88
+ for key, event in selector.select(min(remaining, 0.05)):
89
+ if event == selectors.EVENT_WRITE:
90
+ try:
91
+ used = os.write(key.fd, pending[:65536])
92
+ pending = pending[used:]
93
+ except BrokenPipeError:
94
+ pending = memoryview(b"")
95
+ if not pending:
96
+ selector.unregister(key.fileobj)
97
+ process.stdin.close()
98
+ else:
99
+ chunk = os.read(key.fd, 65536)
100
+ if not chunk:
101
+ selector.unregister(key.fileobj)
102
+ continue
103
+ target = key.data
104
+ limit = max_output if target is stdout else 65536
105
+ if len(target) + len(chunk) > limit:
106
+ raise ValueError(f"Process output exceeds {limit} bytes")
107
+ target.extend(chunk)
108
+ while process.poll() is None:
109
+ remaining = budget.remaining()
110
+ try:
111
+ process.wait(timeout=min(remaining, 0.05))
112
+ except subprocess.TimeoutExpired:
113
+ continue
114
+ budget.remaining()
115
+ if process.returncode != 0 and not (allow_missing and process.returncode == 1):
116
+ raise subprocess.CalledProcessError(
117
+ process.returncode, command, bytes(stdout), bytes(stderr)
118
+ )
119
+ return subprocess.CompletedProcess(
120
+ command, process.returncode, bytes(stdout), bytes(stderr)
121
+ )
122
+ finally:
123
+ # Timeout, cancellation and output overflow must not leave Git running.
124
+ if process_group:
125
+ # Package-manager lifecycle scripts may outlive their parent on cancellation.
126
+ try:
127
+ os.killpg(process.pid, signal.SIGKILL)
128
+ except ProcessLookupError:
129
+ pass
130
+ elif process.poll() is None:
131
+ process.kill()
132
+ process.wait()
133
+ process.stdin.close()
134
+ process.stdout.close()
135
+ process.stderr.close()
repokit/_staging.py ADDED
@@ -0,0 +1,59 @@
1
+ """Prepare and validate an index while holding Git's native writer lock."""
2
+
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+ from .git import GitResult, git
10
+ from .git_index import IndexView, index_view
11
+ from .git_state import git_path
12
+
13
+
14
+ def stage_validated(
15
+ repo: str | Path, paths: list[str], validate: Callable[[IndexView], None]
16
+ ) -> GitResult:
17
+ index = git_path(repo, "index")
18
+ lock = index.with_name(index.name + ".lock")
19
+ try:
20
+ lock_file = lock.open("xb")
21
+ except OSError as exc:
22
+ return GitResult(-1, "", f"cannot lock Git index: {exc}")
23
+ installed = False
24
+ try:
25
+ # why: hold the native lock from snapshot through replacement, so another
26
+ # Git writer cannot have its index update silently overwritten.
27
+ with (
28
+ lock_file,
29
+ tempfile.TemporaryDirectory(prefix="repokit-index-", dir=index.parent) as tmp,
30
+ ):
31
+ prepared = Path(tmp) / "index"
32
+ if index.exists():
33
+ shutil.copy2(index, prepared)
34
+ result = GitResult(0, "", "")
35
+ if paths:
36
+ result = git(
37
+ repo,
38
+ "--literal-pathspecs",
39
+ "add",
40
+ "--",
41
+ *paths,
42
+ timeout=30,
43
+ index_file=prepared,
44
+ )
45
+ if not result.ok:
46
+ return result
47
+ validate(index_view(repo, index_file=prepared))
48
+ if prepared.exists():
49
+ # Use the lock file as the replacement, following Git's index protocol.
50
+ with prepared.open("rb") as source:
51
+ shutil.copyfileobj(source, lock_file)
52
+ lock_file.flush()
53
+ os.fchmod(lock_file.fileno(), prepared.stat().st_mode & 0o777)
54
+ os.replace(lock, index)
55
+ installed = True
56
+ return result
57
+ finally:
58
+ if not installed:
59
+ lock.unlink(missing_ok=True)
repokit/budget.py ADDED
@@ -0,0 +1,40 @@
1
+ """Caller-owned operation scope shared by Git and Forge transport calls."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+ from threading import Event
7
+
8
+ from ._process import Budget
9
+
10
+ _current: ContextVar[Budget | None] = ContextVar("repokit_operation", default=None)
11
+
12
+
13
+ @contextmanager
14
+ def operation(*, timeout: float = 30, cancel: Event | None = None) -> Iterator[None]:
15
+ """Bound a compound library operation; nested scopes never extend the outer deadline.
16
+
17
+ Git cancellation kills and reaps its process. HTTP cancellation is checked between
18
+ requests/reads; an in-flight socket remains bounded by its request timeout.
19
+ This is an execution scope, not a transaction or a retry policy.
20
+ """
21
+ budget = Budget(timeout, cancel)
22
+ outer = _current.get()
23
+ if outer is not None:
24
+ budget.deadline = min(budget.deadline, outer.deadline)
25
+ budget.parent = outer
26
+ token = _current.set(budget)
27
+ try:
28
+ budget.remaining()
29
+ yield
30
+ finally:
31
+ _current.reset(token)
32
+
33
+
34
+ def current_budget(timeout: float) -> Budget:
35
+ budget = Budget(timeout, None)
36
+ outer = _current.get()
37
+ if outer is not None:
38
+ budget.deadline = min(budget.deadline, outer.deadline)
39
+ budget.parent = outer
40
+ return budget
@@ -0,0 +1,378 @@
1
+ """Observe checkout-local dependencies and explicitly materialize locked installations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import stat
9
+ import subprocess
10
+ import threading
11
+ import tomllib
12
+ from dataclasses import dataclass, replace
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ import yaml
17
+
18
+ from . import _process
19
+ from ._inspect import inspect
20
+ from .budget import current_budget
21
+ from .model import InspectReport, owner
22
+
23
+ Status = Literal["ready", "present", "missing", "stale", "unsupported"]
24
+ _RECEIPT = ".repokit-dependencies.json"
25
+ _NODE_LOCKS = {
26
+ "npm": ("npm-shrinkwrap.json", "package-lock.json"),
27
+ "pnpm": ("pnpm-lock.yaml",),
28
+ "yarn": ("yarn.lock",),
29
+ "bun": ("bun.lock", "bun.lockb"),
30
+ }
31
+ _LOCKS: dict[Path, threading.Lock] = {}
32
+ _LOCKS_GUARD = threading.Lock()
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class DependencyEnvironment:
37
+ """An installation belongs to a checkout and may serve multiple workspace Components.
38
+
39
+ present means local dependencies exist without a matching preparation receipt;
40
+ it is not proof that a user-managed environment matches the lockfile.
41
+ missing describes an absent directory, independently of whether command provides
42
+ a supported locked installation. unsupported describes an unusable local layout.
43
+ """
44
+
45
+ component: Path
46
+ checkout: Path
47
+ root: Path
48
+ manager: str
49
+ directory: Path
50
+ command: tuple[str, ...]
51
+ inputs: tuple[Path, ...]
52
+ fingerprint: str
53
+ status: Status
54
+ reason: str = ""
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class DependencyPreparation:
59
+ environment: DependencyEnvironment
60
+ status: Literal["ready", "unsupported", "failed", "uncertain"]
61
+ executed: bool = False
62
+ exit_code: int | None = None
63
+ stdout: str = ""
64
+ stderr: str = ""
65
+ reason: str = ""
66
+
67
+
68
+ def _json(path: Path) -> dict[str, object]:
69
+ value = json.loads(path.read_text())
70
+ if not isinstance(value, dict):
71
+ raise ValueError(f"Expected an object: {path}")
72
+ return value
73
+
74
+
75
+ def _patterns(root: Path, python: bool) -> tuple[str, ...]:
76
+ values: object
77
+ if python:
78
+ manifest = root / "pyproject.toml"
79
+ if not manifest.is_file():
80
+ return ()
81
+ workspace = (
82
+ tomllib.loads(manifest.read_text()).get("tool", {}).get("uv", {}).get("workspace", {})
83
+ )
84
+ values = [*workspace.get("members", []), *("!" + p for p in workspace.get("exclude", []))]
85
+ elif (root / "pnpm-workspace.yaml").is_file():
86
+ value = yaml.safe_load((root / "pnpm-workspace.yaml").read_text())
87
+ values = value.get("packages", []) if isinstance(value, dict) else []
88
+ elif (root / "package.json").is_file():
89
+ value = _json(root / "package.json").get("workspaces", [])
90
+ values = value.get("packages", []) if isinstance(value, dict) else value
91
+ else:
92
+ return ()
93
+ if not isinstance(values, list) or any(not isinstance(p, str) for p in values):
94
+ raise ValueError(f"Invalid workspace members: {root}")
95
+ return tuple(values)
96
+
97
+
98
+ def _members(root: Path, python: bool) -> tuple[Path, ...]:
99
+ included: set[Path] = set()
100
+ excluded: set[Path] = set()
101
+ manifest = "pyproject.toml" if python else "package.json"
102
+ for pattern in _patterns(root, python):
103
+ negative = pattern.startswith("!")
104
+ glob = pattern[1:] if negative else pattern
105
+ if Path(glob).is_absolute() or ".." in Path(glob).parts:
106
+ raise ValueError("Workspace members must remain inside their installation root")
107
+ # Do not silently interpret unsupported shell/YAML glob syntax as a different workspace.
108
+ if any(char in glob for char in "{}()"):
109
+ raise ValueError(f"Unsupported workspace glob: {glob}")
110
+ for path in root.glob(glob):
111
+ if not (path / manifest).is_file():
112
+ continue
113
+ if not path.resolve().is_relative_to(root):
114
+ raise ValueError("Workspace member resolves outside its installation root")
115
+ (excluded if negative else included).add(path.resolve())
116
+ if len(included) + len(excluded) > 4096:
117
+ raise ValueError("Workspace exceeds 4096 dependency manifests")
118
+ return tuple(sorted(included - excluded))
119
+
120
+
121
+ def _root(component: Path, checkout: Path, python: bool) -> Path:
122
+ selected = component
123
+ for parent in component.parents:
124
+ if not parent.is_relative_to(checkout):
125
+ break
126
+ if selected in _members(parent, python):
127
+ selected = parent
128
+ return selected
129
+
130
+
131
+ def _fingerprint(root: Path, inputs: tuple[Path, ...]) -> str:
132
+ digest = hashlib.sha256(str(root).encode())
133
+ for path in inputs:
134
+ digest.update(str(path.relative_to(root)).encode())
135
+ digest.update(b"\0")
136
+ digest.update(path.read_bytes())
137
+ digest.update(b"\0")
138
+ return digest.hexdigest()
139
+
140
+
141
+ def _observe(environment: DependencyEnvironment) -> DependencyEnvironment:
142
+ directory = environment.directory
143
+ # Observe the entry itself: a dangling symlink is an unsupported installation,
144
+ # while absence says nothing about whether a locked installer is available.
145
+ # Permission and other observation errors propagate instead of claiming missing.
146
+ try:
147
+ mode = directory.lstat().st_mode
148
+ except FileNotFoundError:
149
+ return replace(environment, status="missing", reason="Local dependencies are missing")
150
+ if stat.S_ISLNK(mode):
151
+ return replace(
152
+ environment,
153
+ status="unsupported",
154
+ reason="Dependency directory is a symlink; refusing to reuse another installation",
155
+ )
156
+ if not stat.S_ISDIR(mode):
157
+ return replace(
158
+ environment,
159
+ status="unsupported",
160
+ reason="Dependency path is not a directory",
161
+ )
162
+ receipt = directory / _RECEIPT
163
+ if not receipt.exists():
164
+ return replace(
165
+ environment,
166
+ status="present",
167
+ reason="Local dependencies exist without a receipt; consistency is unverified",
168
+ )
169
+ try:
170
+ prepared = _json(receipt)
171
+ except (ValueError, OSError):
172
+ return replace(
173
+ environment, status="stale", reason="Dependency preparation receipt is unreadable"
174
+ )
175
+ if prepared.get("fingerprint") != environment.fingerprint:
176
+ return replace(
177
+ environment,
178
+ status="stale",
179
+ reason="Dependency manifests, lockfile or installation path changed",
180
+ )
181
+ return replace(environment, status="ready", reason="")
182
+
183
+
184
+ def inspect_dependencies(
185
+ path: str | Path, *, inspection: InspectReport | None = None
186
+ ) -> tuple[DependencyEnvironment, ...]:
187
+ """Read-only dependency observations; never install during inspect or worktree creation.
188
+
189
+ An optional complete working-tree inspection avoids repeating repository discovery.
190
+ Installation roots are resolved only within that checkout, never through its parent.
191
+ """
192
+ component = Path(path).resolve(strict=True)
193
+ report = inspection or inspect(component)
194
+ if not report.complete or report.input != "working_tree":
195
+ raise ValueError("Dependency preparation requires complete working-tree inspection")
196
+ checkout = Path(report.checkout).resolve()
197
+ binding = owner(report, component.relative_to(checkout).as_posix())
198
+ if binding is None:
199
+ raise ValueError("No Component owns the requested dependency path")
200
+ component = checkout / binding.root
201
+ environments: list[DependencyEnvironment] = []
202
+ kinds = []
203
+ if (component / "package.json").is_file():
204
+ kinds.append(False)
205
+ if (component / "pyproject.toml").is_file():
206
+ kinds.append(True)
207
+ for python in kinds:
208
+ root = _root(component, checkout, python)
209
+ manifest = root / ("pyproject.toml" if python else "package.json")
210
+ members = _members(root, python)
211
+ inputs = {manifest, *(member / manifest.name for member in members)}
212
+ command: tuple[str, ...] = ()
213
+ if python:
214
+ manager = "uv"
215
+ lock = root / "uv.lock"
216
+ if not lock.is_file():
217
+ # Legacy/unmanaged Python projects do not imply a uv environment.
218
+ continue
219
+ command = (
220
+ ("uv", "sync", "--locked", "--all-packages")
221
+ if members
222
+ else ("uv", "sync", "--locked")
223
+ )
224
+ inputs.add(lock)
225
+ directory = root / ".venv"
226
+ else:
227
+ declared = _json(manifest).get("packageManager", "")
228
+ if not isinstance(declared, str):
229
+ raise ValueError("packageManager must be a string")
230
+ manager = declared.partition("@")[0]
231
+ if not manager:
232
+ candidates = [
233
+ name
234
+ for name, locks in _NODE_LOCKS.items()
235
+ if any((root / lock).is_file() for lock in locks)
236
+ ]
237
+ if len(candidates) > 1:
238
+ raise ValueError(
239
+ "Ambiguous package manager; declare packageManager in package.json"
240
+ )
241
+ manager = candidates[0] if candidates else "node"
242
+ locks = _NODE_LOCKS.get(manager, ())
243
+ node_lock = next((root / name for name in locks if (root / name).is_file()), None)
244
+ if node_lock:
245
+ inputs.add(node_lock)
246
+ if manager == "npm":
247
+ command = ("npm", "ci", "--prefer-offline")
248
+ elif manager in {"bun", "pnpm"}:
249
+ command = (manager, "install", "--frozen-lockfile")
250
+ elif manager == "yarn":
251
+ classic = declared.startswith("yarn@1.") or node_lock.read_text().startswith(
252
+ "# yarn lockfile v1"
253
+ )
254
+ command = ("yarn", "install", "--frozen-lockfile" if classic else "--immutable")
255
+ directory = root / "node_modules"
256
+ if manager == "yarn" and (root / ".pnp.cjs").exists():
257
+ # A PnP installation is not a missing node_modules installation.
258
+ # No receipt directory or repair strategy is claimed for this mode yet.
259
+ raise ValueError("Yarn PnP dependency preparation is not supported")
260
+ for config in ("pnpm-workspace.yaml", ".npmrc", ".yarnrc.yml", "bunfig.toml", "uv.toml"):
261
+ if (root / config).is_file():
262
+ inputs.add(root / config)
263
+ files = tuple(sorted(inputs))
264
+ environment = DependencyEnvironment(
265
+ component,
266
+ checkout,
267
+ root,
268
+ manager,
269
+ directory,
270
+ command,
271
+ files,
272
+ _fingerprint(root, files),
273
+ "missing",
274
+ )
275
+ environments.append(_observe(environment))
276
+ return tuple(environments)
277
+
278
+
279
+ def prepare_dependencies(
280
+ environment: DependencyEnvironment, *, timeout: float = 600
281
+ ) -> DependencyPreparation:
282
+ """Explicit locked installation, serialized per installation root within this process.
283
+
284
+ Does not install runtimes, upgrade dependencies, edit locks, or own validation policy.
285
+ Failures retain process evidence; interruption after launch is uncertain, never ready.
286
+ """
287
+ budget = current_budget(timeout)
288
+ with _LOCKS_GUARD:
289
+ lock = _LOCKS.setdefault(environment.root, threading.Lock())
290
+ try:
291
+ while not lock.acquire(timeout=min(0.05, budget.remaining())):
292
+ pass
293
+ except (TimeoutError, InterruptedError) as error:
294
+ return DependencyPreparation(environment, "failed", reason=str(error))
295
+ try:
296
+ current = next(
297
+ (
298
+ item
299
+ for item in inspect_dependencies(environment.component)
300
+ if item.manager == environment.manager and item.root == environment.root
301
+ ),
302
+ None,
303
+ )
304
+ if current is None or current.fingerprint != environment.fingerprint:
305
+ return DependencyPreparation(
306
+ environment,
307
+ "failed",
308
+ reason="Dependency inputs changed; inspect again before preparation",
309
+ )
310
+ if current.status == "ready":
311
+ return DependencyPreparation(current, "ready")
312
+ if not current.command or current.status == "unsupported":
313
+ return DependencyPreparation(
314
+ current, "unsupported", reason=current.reason or "No supported locked installation"
315
+ )
316
+ # uv must not inherit a path that redirects this checkout's editable installation.
317
+ env = dict(os.environ)
318
+ if current.manager == "uv":
319
+ env.pop("VIRTUAL_ENV", None)
320
+ env.pop("UV_PROJECT_ENVIRONMENT", None)
321
+ try:
322
+ budget.remaining()
323
+ except (TimeoutError, InterruptedError) as error:
324
+ return DependencyPreparation(current, "failed", reason=str(error))
325
+ try:
326
+ result = _process.run_command(
327
+ list(current.command), budget, cwd=current.root, env=env, process_group=True
328
+ )
329
+ except subprocess.CalledProcessError as error:
330
+ return DependencyPreparation(
331
+ current,
332
+ "failed",
333
+ True,
334
+ error.returncode,
335
+ os.fsdecode(error.output or b""),
336
+ os.fsdecode(error.stderr or b""),
337
+ "Locked dependency installation failed",
338
+ )
339
+ except (TimeoutError, InterruptedError, ValueError) as error:
340
+ return DependencyPreparation(current, "uncertain", True, reason=str(error))
341
+ except OSError as error:
342
+ return DependencyPreparation(current, "failed", reason=str(error))
343
+ refreshed = next(
344
+ (
345
+ item
346
+ for item in inspect_dependencies(current.component)
347
+ if item.manager == current.manager and item.root == current.root
348
+ ),
349
+ None,
350
+ )
351
+ if refreshed is None or refreshed.fingerprint != current.fingerprint:
352
+ return DependencyPreparation(
353
+ current,
354
+ "failed",
355
+ True,
356
+ reason="Dependency inputs changed during installation; no ready receipt written",
357
+ )
358
+ if not current.directory.is_dir() or current.directory.is_symlink():
359
+ return DependencyPreparation(
360
+ current,
361
+ "failed",
362
+ True,
363
+ reason="Installer did not materialize checkout-local dependencies",
364
+ )
365
+ receipt = current.directory / _RECEIPT
366
+ receipt.write_text(
367
+ json.dumps({"fingerprint": current.fingerprint, "manager": current.manager}) + "\n"
368
+ )
369
+ return DependencyPreparation(
370
+ _observe(current),
371
+ "ready",
372
+ True,
373
+ result.returncode,
374
+ os.fsdecode(result.stdout),
375
+ os.fsdecode(result.stderr),
376
+ )
377
+ finally:
378
+ lock.release()
@@ -0,0 +1,13 @@
1
+ """Forge clients accept explicit endpoint and credentials; no application configuration."""
2
+
3
+ from .creation import PullRequestCreation as PullRequestCreation
4
+ from .creation import ensure_pull_request as ensure_pull_request
5
+ from .github import GitHubForge as GitHubForge
6
+ from .gitlab import GitLabForge as GitLabForge
7
+ from .model import Page as Page
8
+ from .observation import BranchPullRequests as BranchPullRequests
9
+ from .observation import PullRequestContext as PullRequestContext
10
+ from .observation import PullRequestKey as PullRequestKey
11
+ from .observation import PullRequestObserver as PullRequestObserver
12
+ from .observation import PullRequestStatus as PullRequestStatus
13
+ from .observation import PullRequestStatusUnavailable as PullRequestStatusUnavailable