@homericintelligence/athena-opencode 0.4.4
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.
- package/LICENSE +29 -0
- package/NOTICE +22 -0
- package/README.md +45 -0
- package/package.json +29 -0
- package/plugin.js +54 -0
- package/skills/THIRD_PARTY_LICENSES.md +50 -0
- package/skills/_cli.py +152 -0
- package/skills/advise/SKILL.md +62 -0
- package/skills/advise/scripts/list_retrievable_skills.py +49 -0
- package/skills/brainstorm/SKILL.md +110 -0
- package/skills/change-review/SKILL.md +68 -0
- package/skills/change-review/references/scope-resolution.md +52 -0
- package/skills/change-review/scripts/resolve_scope.py +1219 -0
- package/skills/finalize-plan/SKILL.md +129 -0
- package/skills/git-worktrees/SKILL.md +113 -0
- package/skills/git-worktrees/scripts/prepare_worktree.py +153 -0
- package/skills/issue-review/SKILL.md +67 -0
- package/skills/learn/SKILL.md +208 -0
- package/skills/myrmidon-swarm/SKILL.md +93 -0
- package/skills/plan-issue/SKILL.md +70 -0
- package/skills/pr-review/SKILL.md +114 -0
- package/skills/pr-review/references/criteria.md +26 -0
- package/skills/pr-review/references/delivery.md +135 -0
- package/skills/pr-review/references/evidence.md +233 -0
- package/skills/pr-review/references/prevalidated.md +155 -0
- package/skills/pr-review/scripts/collect_evidence.py +1478 -0
- package/skills/pr-review/scripts/diff_context.py +74 -0
- package/skills/pr-review/scripts/materialize_snapshot.py +731 -0
- package/skills/pr-review/scripts/pr_identity.py +80 -0
- package/skills/pr-review/scripts/resolve_pr.py +258 -0
- package/skills/repo-review/SKILL.md +119 -0
- package/skills/systematic-debugging/SKILL.md +199 -0
- package/skills/systematic-debugging/scripts/repository_evidence.py +77 -0
- package/skills/test-driven-development/SKILL.md +75 -0
- package/skills/tidy/SKILL.md +71 -0
- package/skills/tidy/scripts/run_tidy.py +43 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Materialize one immutable GitHub pull-request snapshot in an isolated repository."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
if __package__ in {None, ""}:
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
18
|
+
|
|
19
|
+
from pr_identity import COMMIT_OID, require_commit_oid, require_github_repository
|
|
20
|
+
|
|
21
|
+
from skills._cli import (
|
|
22
|
+
argument_parser,
|
|
23
|
+
git_read_arguments,
|
|
24
|
+
git_read_environment,
|
|
25
|
+
run_command,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
SNAPSHOT_COMMAND_TIMEOUT_SECONDS = 30.0
|
|
29
|
+
BOUNDED_MATERIALIZE_TIMEOUT_SECONDS = 600.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class MaterializedSnapshot:
|
|
34
|
+
"""A detached source tree bound to one reviewed GitHub pull request."""
|
|
35
|
+
|
|
36
|
+
root: Path
|
|
37
|
+
source_path: Path
|
|
38
|
+
merge_base: str
|
|
39
|
+
tree_oid: str
|
|
40
|
+
|
|
41
|
+
def as_json(self) -> dict[str, str]:
|
|
42
|
+
"""Return the snapshot fields a host needs for immutable inspection."""
|
|
43
|
+
return {
|
|
44
|
+
"merge_base": self.merge_base,
|
|
45
|
+
"root": str(self.root),
|
|
46
|
+
"source_path": str(self.source_path),
|
|
47
|
+
"tree_oid": self.tree_oid,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def canonical_repository_url(repository: str) -> str:
|
|
52
|
+
"""Return the sole permitted acquisition endpoint for a GitHub repository."""
|
|
53
|
+
return f"https://github.com/{repository}.git"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git(
|
|
57
|
+
*arguments: str,
|
|
58
|
+
cwd: Path | None = None,
|
|
59
|
+
capture_output: bool = False,
|
|
60
|
+
accepted_codes: tuple[int, ...] = (0,),
|
|
61
|
+
temporary_directory: Path | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Run a bounded isolated-repository Git command without ambient config."""
|
|
64
|
+
environment = git_read_environment()
|
|
65
|
+
if temporary_directory is not None:
|
|
66
|
+
environment["TMPDIR"] = str(temporary_directory)
|
|
67
|
+
command_options: dict[str, object] = {
|
|
68
|
+
"cwd": cwd,
|
|
69
|
+
"stdout": subprocess.PIPE if capture_output else subprocess.DEVNULL,
|
|
70
|
+
"stderr": subprocess.DEVNULL,
|
|
71
|
+
"env": environment,
|
|
72
|
+
"text": True,
|
|
73
|
+
"check": False,
|
|
74
|
+
"timeout": SNAPSHOT_COMMAND_TIMEOUT_SECONDS,
|
|
75
|
+
}
|
|
76
|
+
try:
|
|
77
|
+
result = run_command(
|
|
78
|
+
["git", *git_read_arguments(), *arguments], **command_options
|
|
79
|
+
)
|
|
80
|
+
except subprocess.SubprocessError as error:
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
"cannot materialize the immutable pull-request snapshot"
|
|
83
|
+
) from error
|
|
84
|
+
if result.returncode not in accepted_codes:
|
|
85
|
+
raise RuntimeError("cannot materialize the immutable pull-request snapshot")
|
|
86
|
+
return result.stdout.strip() if isinstance(result.stdout, str) else ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hdiutil(*arguments: str) -> None:
|
|
90
|
+
"""Run the macOS disk-image tool or fail closed when it cannot enforce a quota."""
|
|
91
|
+
try:
|
|
92
|
+
result = run_command(
|
|
93
|
+
["hdiutil", *arguments],
|
|
94
|
+
stdout=subprocess.DEVNULL,
|
|
95
|
+
stderr=subprocess.DEVNULL,
|
|
96
|
+
text=True,
|
|
97
|
+
check=False,
|
|
98
|
+
)
|
|
99
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
102
|
+
) from error
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _mount_tmpfs(source: Path, maximum_bytes: int) -> bool:
|
|
110
|
+
"""Mount a Linux tmpfs whose total capacity is the snapshot quota.
|
|
111
|
+
|
|
112
|
+
Returns True only when the mount is actually enforced; the caller must
|
|
113
|
+
verify ``source.is_mount()`` is not relied on elsewhere. On any failure
|
|
114
|
+
(no ``mount`` binary, no privilege, mount rejection) the caller uses the
|
|
115
|
+
bounded user/mount-namespace fallback or fails closed.
|
|
116
|
+
"""
|
|
117
|
+
if sys.platform != "linux" or shutil.which("mount") is None:
|
|
118
|
+
return False
|
|
119
|
+
maximum_kibibytes = maximum_bytes // 1024
|
|
120
|
+
if maximum_kibibytes < 1:
|
|
121
|
+
raise RuntimeError("immutable pull-request snapshot has no usable disk space")
|
|
122
|
+
try:
|
|
123
|
+
source.mkdir()
|
|
124
|
+
except FileExistsError:
|
|
125
|
+
# Directory already exists from a prior materialization; reuse it.
|
|
126
|
+
pass
|
|
127
|
+
except OSError:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
result = run_command(
|
|
131
|
+
[
|
|
132
|
+
"mount",
|
|
133
|
+
"-t",
|
|
134
|
+
"tmpfs",
|
|
135
|
+
"-o",
|
|
136
|
+
f"size={maximum_kibibytes}k",
|
|
137
|
+
"tmpfs",
|
|
138
|
+
str(source),
|
|
139
|
+
],
|
|
140
|
+
stdout=subprocess.DEVNULL,
|
|
141
|
+
stderr=subprocess.DEVNULL,
|
|
142
|
+
text=True,
|
|
143
|
+
check=False,
|
|
144
|
+
)
|
|
145
|
+
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
146
|
+
return False
|
|
147
|
+
return result.returncode == 0 and source.is_mount()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
151
|
+
"""Return a bounded source directory, or None for the Linux bounded fallback.
|
|
152
|
+
|
|
153
|
+
darwin: attach a sparse HFS+ volume whose total capacity is the quota.
|
|
154
|
+
linux: mount a tmpfs whose total capacity is the quota when the host is
|
|
155
|
+
privileged; otherwise return None so the caller materializes the
|
|
156
|
+
snapshot inside a bounded user/mount namespace.
|
|
157
|
+
other: fail closed — the host cannot enforce the snapshot size limit.
|
|
158
|
+
"""
|
|
159
|
+
maximum_kibibytes = maximum_bytes // 1024
|
|
160
|
+
if maximum_kibibytes < 1:
|
|
161
|
+
raise RuntimeError("immutable pull-request snapshot has no usable disk space")
|
|
162
|
+
if sys.platform == "darwin":
|
|
163
|
+
image = root / "snapshot.sparseimage"
|
|
164
|
+
source = root / "source"
|
|
165
|
+
source.mkdir()
|
|
166
|
+
_hdiutil(
|
|
167
|
+
"create",
|
|
168
|
+
"-quiet",
|
|
169
|
+
"-type",
|
|
170
|
+
"SPARSE",
|
|
171
|
+
"-size",
|
|
172
|
+
f"{maximum_kibibytes}k",
|
|
173
|
+
"-fs",
|
|
174
|
+
"Case-sensitive HFS+",
|
|
175
|
+
"-volname",
|
|
176
|
+
"Athena PR Review",
|
|
177
|
+
"-nospotlight",
|
|
178
|
+
str(image),
|
|
179
|
+
)
|
|
180
|
+
_hdiutil(
|
|
181
|
+
"attach",
|
|
182
|
+
"-quiet",
|
|
183
|
+
"-nobrowse",
|
|
184
|
+
"-mountpoint",
|
|
185
|
+
str(source),
|
|
186
|
+
str(image),
|
|
187
|
+
)
|
|
188
|
+
if not source.is_mount():
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
191
|
+
)
|
|
192
|
+
return source
|
|
193
|
+
if sys.platform.startswith("linux"):
|
|
194
|
+
source = root / "source"
|
|
195
|
+
if _mount_tmpfs(source, maximum_bytes):
|
|
196
|
+
return source
|
|
197
|
+
try:
|
|
198
|
+
source.rmdir()
|
|
199
|
+
except OSError:
|
|
200
|
+
# The mount may have partially created the directory; a failed
|
|
201
|
+
# rmdir is not fatal - the caller treats None as a fallback.
|
|
202
|
+
pass
|
|
203
|
+
return None
|
|
204
|
+
raise RuntimeError(
|
|
205
|
+
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _detach_volume(source: Path) -> None:
|
|
210
|
+
"""Detach the platform quota volume from its mount point."""
|
|
211
|
+
if sys.platform == "darwin":
|
|
212
|
+
_hdiutil("detach", "-force", "-quiet", str(source))
|
|
213
|
+
return
|
|
214
|
+
if sys.platform.startswith("linux"):
|
|
215
|
+
try:
|
|
216
|
+
result = run_command(
|
|
217
|
+
["umount", str(source)],
|
|
218
|
+
stdout=subprocess.DEVNULL,
|
|
219
|
+
stderr=subprocess.DEVNULL,
|
|
220
|
+
text=True,
|
|
221
|
+
check=False,
|
|
222
|
+
)
|
|
223
|
+
if result.returncode != 0:
|
|
224
|
+
result = run_command(
|
|
225
|
+
["umount", "-l", str(source)],
|
|
226
|
+
stdout=subprocess.DEVNULL,
|
|
227
|
+
stderr=subprocess.DEVNULL,
|
|
228
|
+
text=True,
|
|
229
|
+
check=False,
|
|
230
|
+
)
|
|
231
|
+
if result.returncode != 0:
|
|
232
|
+
raise RuntimeError("cannot remove the immutable pull-request snapshot")
|
|
233
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
"cannot remove the immutable pull-request snapshot"
|
|
236
|
+
) from error
|
|
237
|
+
return
|
|
238
|
+
raise RuntimeError("cannot remove the immutable pull-request snapshot")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _detach_best_effort(source: Path) -> None:
|
|
242
|
+
"""Detach a quota volume without masking a prior failure."""
|
|
243
|
+
try:
|
|
244
|
+
_detach_volume(source)
|
|
245
|
+
except RuntimeError:
|
|
246
|
+
# Detach is best-effort; a prior failure must not be masked by a
|
|
247
|
+
# secondary cleanup error.
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _require_base_ref(base_ref: str) -> str:
|
|
252
|
+
"""Validate the GitHub base branch before it becomes a fetch refspec."""
|
|
253
|
+
if not base_ref or base_ref.startswith("-") or ".." in base_ref:
|
|
254
|
+
raise RuntimeError("GitHub returned an invalid pull-request base ref")
|
|
255
|
+
_git("check-ref-format", "--branch", base_ref)
|
|
256
|
+
return base_ref
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _repository_size(path: Path, *, maximum_bytes: int | None = None) -> int:
|
|
260
|
+
"""Return the size of a repository within 90 percent of free disk space."""
|
|
261
|
+
if maximum_bytes is None:
|
|
262
|
+
try:
|
|
263
|
+
maximum_bytes = (shutil.disk_usage(path).free * 9) // 10
|
|
264
|
+
except OSError as error:
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
"cannot inspect the immutable pull-request snapshot"
|
|
267
|
+
) from error
|
|
268
|
+
total = 0
|
|
269
|
+
for entry in path.rglob("*"):
|
|
270
|
+
try:
|
|
271
|
+
details = entry.lstat()
|
|
272
|
+
except OSError as error:
|
|
273
|
+
raise RuntimeError(
|
|
274
|
+
"cannot inspect the immutable pull-request snapshot"
|
|
275
|
+
) from error
|
|
276
|
+
if stat.S_ISREG(details.st_mode):
|
|
277
|
+
total += details.st_size
|
|
278
|
+
if total > maximum_bytes:
|
|
279
|
+
raise RuntimeError(
|
|
280
|
+
"immutable pull-request snapshot exceeds the safe size limit"
|
|
281
|
+
)
|
|
282
|
+
return total
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _verify_no_promisor_configuration(repository: Path) -> None:
|
|
286
|
+
"""Reject partial-clone configuration before any immutable object reads."""
|
|
287
|
+
for key in ("extensions.partialClone",):
|
|
288
|
+
value = _git(
|
|
289
|
+
"config",
|
|
290
|
+
"--local",
|
|
291
|
+
"--get",
|
|
292
|
+
key,
|
|
293
|
+
cwd=repository,
|
|
294
|
+
capture_output=True,
|
|
295
|
+
accepted_codes=(0, 1),
|
|
296
|
+
)
|
|
297
|
+
if value:
|
|
298
|
+
raise RuntimeError(
|
|
299
|
+
"immutable pull-request snapshot must not use partial clone configuration"
|
|
300
|
+
)
|
|
301
|
+
promisor = _git(
|
|
302
|
+
"config",
|
|
303
|
+
"--local",
|
|
304
|
+
"--get-regexp",
|
|
305
|
+
r"^remote\..*\.(promisor|partialclonefilter)$",
|
|
306
|
+
cwd=repository,
|
|
307
|
+
capture_output=True,
|
|
308
|
+
accepted_codes=(0, 1),
|
|
309
|
+
)
|
|
310
|
+
if promisor:
|
|
311
|
+
raise RuntimeError(
|
|
312
|
+
"immutable pull-request snapshot must not use promisor configuration"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _require_commit(repository: Path, revision: str, label: str) -> str:
|
|
317
|
+
"""Verify one fetched ref resolves exactly to its captured commit OID."""
|
|
318
|
+
resolved = _git(
|
|
319
|
+
"rev-parse",
|
|
320
|
+
"--verify",
|
|
321
|
+
f"{revision}^{{commit}}",
|
|
322
|
+
cwd=repository,
|
|
323
|
+
capture_output=True,
|
|
324
|
+
)
|
|
325
|
+
return require_commit_oid(resolved, label)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _make_read_only(root: Path) -> None:
|
|
329
|
+
"""Remove write bits from the completed snapshot without following symlinks."""
|
|
330
|
+
entries = sorted(root.rglob("*"), key=lambda entry: len(entry.parts), reverse=True)
|
|
331
|
+
for entry in entries:
|
|
332
|
+
if entry.is_symlink():
|
|
333
|
+
continue
|
|
334
|
+
try:
|
|
335
|
+
mode = entry.stat(follow_symlinks=False).st_mode
|
|
336
|
+
if stat.S_ISDIR(mode):
|
|
337
|
+
entry.chmod(0o555)
|
|
338
|
+
else:
|
|
339
|
+
entry.chmod(0o555 if mode & stat.S_IXUSR else 0o444)
|
|
340
|
+
except OSError as error:
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
"cannot make the immutable pull-request snapshot read-only"
|
|
343
|
+
) from error
|
|
344
|
+
root.chmod(0o555)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _acquire_into(
|
|
348
|
+
source: Path,
|
|
349
|
+
*,
|
|
350
|
+
repository_url: str,
|
|
351
|
+
number: int,
|
|
352
|
+
base_ref: str,
|
|
353
|
+
base_oid: str,
|
|
354
|
+
head_oid: str,
|
|
355
|
+
hooks: Path,
|
|
356
|
+
template: Path,
|
|
357
|
+
maximum_bytes: int,
|
|
358
|
+
) -> tuple[str, str]:
|
|
359
|
+
"""Fetch only the captured base branch and PR head into a source directory.
|
|
360
|
+
|
|
361
|
+
The source directory is provided by the caller (a quota volume or a
|
|
362
|
+
bounded tmpfs). Returns ``(merge_base, tree_oid)`` after verifying every
|
|
363
|
+
immutable binding against the captured OIDs.
|
|
364
|
+
"""
|
|
365
|
+
_git(
|
|
366
|
+
"-c",
|
|
367
|
+
f"core.hooksPath={hooks}",
|
|
368
|
+
"-c",
|
|
369
|
+
"init.defaultBranch=athena-review",
|
|
370
|
+
"init",
|
|
371
|
+
"--quiet",
|
|
372
|
+
f"--template={template}",
|
|
373
|
+
"--initial-branch=athena-review",
|
|
374
|
+
str(source),
|
|
375
|
+
temporary_directory=source,
|
|
376
|
+
)
|
|
377
|
+
base_refspec = f"+refs/heads/{base_ref}:refs/athena/base"
|
|
378
|
+
head_refspec = f"+refs/pull/{number}/head:refs/athena/pr/{number}/head"
|
|
379
|
+
_git(
|
|
380
|
+
"-c",
|
|
381
|
+
f"core.hooksPath={hooks}",
|
|
382
|
+
"-c",
|
|
383
|
+
"remote.origin.fetch=",
|
|
384
|
+
"-c",
|
|
385
|
+
"fetch.writeCommitGraph=false",
|
|
386
|
+
"-c",
|
|
387
|
+
"fetch.fsckObjects=true",
|
|
388
|
+
"-c",
|
|
389
|
+
"transfer.fsckObjects=true",
|
|
390
|
+
"fetch",
|
|
391
|
+
"--quiet",
|
|
392
|
+
"--no-tags",
|
|
393
|
+
"--no-write-fetch-head",
|
|
394
|
+
"--no-recurse-submodules",
|
|
395
|
+
"--refmap=",
|
|
396
|
+
repository_url,
|
|
397
|
+
base_refspec,
|
|
398
|
+
head_refspec,
|
|
399
|
+
cwd=source,
|
|
400
|
+
temporary_directory=source,
|
|
401
|
+
)
|
|
402
|
+
_repository_size(source / ".git", maximum_bytes=maximum_bytes)
|
|
403
|
+
_verify_no_promisor_configuration(source)
|
|
404
|
+
if (
|
|
405
|
+
_git("rev-parse", "--is-shallow-repository", cwd=source, capture_output=True)
|
|
406
|
+
!= "false"
|
|
407
|
+
):
|
|
408
|
+
raise RuntimeError("immutable pull-request snapshot requires complete history")
|
|
409
|
+
if _require_commit(source, "refs/athena/base", "fetched base OID") != base_oid:
|
|
410
|
+
raise RuntimeError("fetched base ref does not match the captured base OID")
|
|
411
|
+
if (
|
|
412
|
+
_require_commit(source, f"refs/athena/pr/{number}/head", "fetched head OID")
|
|
413
|
+
!= head_oid
|
|
414
|
+
):
|
|
415
|
+
raise RuntimeError(
|
|
416
|
+
"fetched pull-request ref does not match the captured head OID"
|
|
417
|
+
)
|
|
418
|
+
merge_bases = _git(
|
|
419
|
+
"merge-base",
|
|
420
|
+
"--all",
|
|
421
|
+
base_oid,
|
|
422
|
+
head_oid,
|
|
423
|
+
cwd=source,
|
|
424
|
+
capture_output=True,
|
|
425
|
+
).splitlines()
|
|
426
|
+
if len(merge_bases) != 1:
|
|
427
|
+
raise RuntimeError(
|
|
428
|
+
"immutable pull-request snapshot requires one unambiguous merge base"
|
|
429
|
+
)
|
|
430
|
+
merge_base = require_commit_oid(merge_bases[0], "immutable merge base")
|
|
431
|
+
tree_oid = _require_commit(source, head_oid, "fetched head OID")
|
|
432
|
+
tree_oid = _git(
|
|
433
|
+
"rev-parse", f"{tree_oid}^{{tree}}", cwd=source, capture_output=True
|
|
434
|
+
)
|
|
435
|
+
if COMMIT_OID.fullmatch(tree_oid) is None:
|
|
436
|
+
raise RuntimeError("Git returned an invalid immutable head tree")
|
|
437
|
+
_git(
|
|
438
|
+
"-c",
|
|
439
|
+
f"core.hooksPath={hooks}",
|
|
440
|
+
"checkout",
|
|
441
|
+
"--quiet",
|
|
442
|
+
"--detach",
|
|
443
|
+
"--no-recurse-submodules",
|
|
444
|
+
head_oid,
|
|
445
|
+
cwd=source,
|
|
446
|
+
temporary_directory=source,
|
|
447
|
+
)
|
|
448
|
+
_repository_size(source, maximum_bytes=maximum_bytes)
|
|
449
|
+
return merge_base, tree_oid
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _bounded_materialize_main(arguments: Sequence[str]) -> int:
|
|
453
|
+
"""Materialize inside a bounded user/mount namespace (internal entry).
|
|
454
|
+
|
|
455
|
+
This runs as the child of ``unshare -rm`` on Linux. It mounts a tmpfs
|
|
456
|
+
whose total capacity is the snapshot quota, acquires the snapshot inside
|
|
457
|
+
that bound, copies the verified read-only tree to a host-visible path, and
|
|
458
|
+
prints the result as JSON. Exit 2 means the quota boundary itself could
|
|
459
|
+
not be established; exit 1 means materialization failed.
|
|
460
|
+
"""
|
|
461
|
+
parser = argument_parser(description="Bounded snapshot materialization.")
|
|
462
|
+
parser.add_argument("--root", required=True, metavar="ROOT")
|
|
463
|
+
parser.add_argument("--repository-url", required=True, metavar="URL")
|
|
464
|
+
parser.add_argument("--pr-number", required=True, type=int, metavar="NUMBER")
|
|
465
|
+
parser.add_argument("--base-ref", required=True, metavar="BRANCH")
|
|
466
|
+
parser.add_argument("--base-oid", required=True, metavar="BASE_OID")
|
|
467
|
+
parser.add_argument("--head-oid", required=True, metavar="HEAD_OID")
|
|
468
|
+
parser.add_argument("--maximum-bytes", required=True, type=int, metavar="BYTES")
|
|
469
|
+
parsed = parser.parse_args(arguments)
|
|
470
|
+
root = Path(parsed.root).resolve()
|
|
471
|
+
temporary_root = Path(tempfile.gettempdir()).resolve()
|
|
472
|
+
if root.parent != temporary_root or not root.name.startswith("athena-pr-review-"):
|
|
473
|
+
print(
|
|
474
|
+
"refusing to materialize outside the managed temporary directory",
|
|
475
|
+
file=sys.stderr,
|
|
476
|
+
)
|
|
477
|
+
return 1
|
|
478
|
+
try:
|
|
479
|
+
canonical_base = require_commit_oid(parsed.base_oid, "captured base OID")
|
|
480
|
+
canonical_head = require_commit_oid(parsed.head_oid, "captured head OID")
|
|
481
|
+
canonical_base_ref = _require_base_ref(parsed.base_ref)
|
|
482
|
+
bounded = root / "bounded"
|
|
483
|
+
bounded.mkdir()
|
|
484
|
+
if not _mount_tmpfs(bounded, parsed.maximum_bytes):
|
|
485
|
+
print(
|
|
486
|
+
"host cannot enforce the immutable pull-request snapshot size limit",
|
|
487
|
+
file=sys.stderr,
|
|
488
|
+
)
|
|
489
|
+
return 2
|
|
490
|
+
merge_base, tree_oid = _acquire_into(
|
|
491
|
+
bounded,
|
|
492
|
+
repository_url=parsed.repository_url,
|
|
493
|
+
number=parsed.pr_number,
|
|
494
|
+
base_ref=canonical_base_ref,
|
|
495
|
+
base_oid=canonical_base,
|
|
496
|
+
head_oid=canonical_head,
|
|
497
|
+
hooks=root / "empty-hooks",
|
|
498
|
+
template=root / "empty-template",
|
|
499
|
+
maximum_bytes=parsed.maximum_bytes,
|
|
500
|
+
)
|
|
501
|
+
shutil.copytree(bounded, root / "source", symlinks=True)
|
|
502
|
+
_detach_best_effort(bounded)
|
|
503
|
+
_make_read_only(root)
|
|
504
|
+
except (OSError, subprocess.SubprocessError, RuntimeError) as error:
|
|
505
|
+
_detach_best_effort(root / "bounded")
|
|
506
|
+
print(str(error), file=sys.stderr)
|
|
507
|
+
return 1
|
|
508
|
+
print(
|
|
509
|
+
json.dumps(
|
|
510
|
+
{
|
|
511
|
+
"source_path": str(root / "source"),
|
|
512
|
+
"merge_base": merge_base,
|
|
513
|
+
"tree_oid": tree_oid,
|
|
514
|
+
},
|
|
515
|
+
sort_keys=True,
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
return 0
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _bounded_materialize(
|
|
522
|
+
root: Path,
|
|
523
|
+
maximum_bytes: int,
|
|
524
|
+
*,
|
|
525
|
+
repository: str,
|
|
526
|
+
number: int,
|
|
527
|
+
base_ref: str,
|
|
528
|
+
base_oid: str,
|
|
529
|
+
head_oid: str,
|
|
530
|
+
) -> MaterializedSnapshot:
|
|
531
|
+
"""Materialize a snapshot inside a bounded Linux user/mount namespace.
|
|
532
|
+
|
|
533
|
+
Spawns this helper under ``unshare -rm`` (rootful within the new namespace
|
|
534
|
+
but unprivileged on the host), where the child mounts a tmpfs whose total
|
|
535
|
+
capacity is the snapshot quota. Every fetch/checkout write is bounded by
|
|
536
|
+
that filesystem; the verified tree is copied to a host-visible read-only
|
|
537
|
+
path before the namespace exits. Fails closed when ``unshare`` or a tmpfs
|
|
538
|
+
mount is unavailable.
|
|
539
|
+
"""
|
|
540
|
+
unshare = shutil.which("unshare")
|
|
541
|
+
if unshare is None:
|
|
542
|
+
raise RuntimeError(
|
|
543
|
+
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
544
|
+
)
|
|
545
|
+
command = [
|
|
546
|
+
unshare,
|
|
547
|
+
"-rm",
|
|
548
|
+
"--",
|
|
549
|
+
sys.executable,
|
|
550
|
+
str(Path(__file__).resolve()),
|
|
551
|
+
"--bounded-materialize",
|
|
552
|
+
"--root",
|
|
553
|
+
str(root),
|
|
554
|
+
"--repository-url",
|
|
555
|
+
canonical_repository_url(repository),
|
|
556
|
+
"--pr-number",
|
|
557
|
+
str(number),
|
|
558
|
+
"--base-ref",
|
|
559
|
+
base_ref,
|
|
560
|
+
"--base-oid",
|
|
561
|
+
base_oid,
|
|
562
|
+
"--head-oid",
|
|
563
|
+
head_oid,
|
|
564
|
+
"--maximum-bytes",
|
|
565
|
+
str(maximum_bytes),
|
|
566
|
+
]
|
|
567
|
+
try:
|
|
568
|
+
result = run_command(
|
|
569
|
+
command,
|
|
570
|
+
capture_output=True,
|
|
571
|
+
text=True,
|
|
572
|
+
check=False,
|
|
573
|
+
timeout=BOUNDED_MATERIALIZE_TIMEOUT_SECONDS,
|
|
574
|
+
)
|
|
575
|
+
except subprocess.TimeoutExpired as error:
|
|
576
|
+
raise RuntimeError(
|
|
577
|
+
"cannot materialize the immutable pull-request snapshot"
|
|
578
|
+
) from error
|
|
579
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
580
|
+
raise RuntimeError(
|
|
581
|
+
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
582
|
+
) from error
|
|
583
|
+
if result.returncode == 2:
|
|
584
|
+
raise RuntimeError(
|
|
585
|
+
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
586
|
+
)
|
|
587
|
+
if result.returncode != 0:
|
|
588
|
+
raise RuntimeError("cannot materialize the immutable pull-request snapshot")
|
|
589
|
+
try:
|
|
590
|
+
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
591
|
+
if not lines:
|
|
592
|
+
raise RuntimeError("cannot materialize the immutable pull-request snapshot")
|
|
593
|
+
record = json.loads(lines[-1])
|
|
594
|
+
if not isinstance(record, dict):
|
|
595
|
+
raise TypeError("cannot materialize the immutable pull-request snapshot")
|
|
596
|
+
source_path = Path(str(record["source_path"]))
|
|
597
|
+
merge_base = require_commit_oid(record["merge_base"], "immutable merge base")
|
|
598
|
+
tree_oid = require_commit_oid(record["tree_oid"], "immutable head tree")
|
|
599
|
+
except (
|
|
600
|
+
KeyError,
|
|
601
|
+
TypeError,
|
|
602
|
+
ValueError,
|
|
603
|
+
json.JSONDecodeError,
|
|
604
|
+
RuntimeError,
|
|
605
|
+
) as error:
|
|
606
|
+
raise RuntimeError(
|
|
607
|
+
"cannot materialize the immutable pull-request snapshot"
|
|
608
|
+
) from error
|
|
609
|
+
if source_path != root / "source" or not source_path.is_dir():
|
|
610
|
+
raise RuntimeError("cannot materialize the immutable pull-request snapshot")
|
|
611
|
+
return MaterializedSnapshot(
|
|
612
|
+
root=root, source_path=source_path, merge_base=merge_base, tree_oid=tree_oid
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def materialize_snapshot(
|
|
617
|
+
*, repository: str, number: int, base_ref: str, base_oid: str, head_oid: str
|
|
618
|
+
) -> MaterializedSnapshot:
|
|
619
|
+
"""Fetch only a captured base branch and PR head into a fresh repository."""
|
|
620
|
+
canonical_repository = require_github_repository(repository, "GitHub repository")
|
|
621
|
+
if isinstance(number, bool) or not isinstance(number, int) or number < 1:
|
|
622
|
+
raise RuntimeError("pull-request number must be positive")
|
|
623
|
+
canonical_base = require_commit_oid(base_oid, "captured base OID")
|
|
624
|
+
canonical_head = require_commit_oid(head_oid, "captured head OID")
|
|
625
|
+
canonical_base_ref = _require_base_ref(base_ref)
|
|
626
|
+
root = Path(tempfile.mkdtemp(prefix="athena-pr-review-"))
|
|
627
|
+
template = root / "empty-template"
|
|
628
|
+
hooks = root / "empty-hooks"
|
|
629
|
+
template.mkdir()
|
|
630
|
+
hooks.mkdir()
|
|
631
|
+
try:
|
|
632
|
+
maximum_snapshot_bytes = (shutil.disk_usage(root).free * 9) // 10
|
|
633
|
+
if maximum_snapshot_bytes < 1:
|
|
634
|
+
raise RuntimeError(
|
|
635
|
+
"immutable pull-request snapshot has no usable disk space"
|
|
636
|
+
)
|
|
637
|
+
source = _create_quota_volume(root, maximum_snapshot_bytes)
|
|
638
|
+
if source is None:
|
|
639
|
+
return _bounded_materialize(
|
|
640
|
+
root,
|
|
641
|
+
maximum_snapshot_bytes,
|
|
642
|
+
repository=canonical_repository,
|
|
643
|
+
number=number,
|
|
644
|
+
base_ref=canonical_base_ref,
|
|
645
|
+
base_oid=canonical_base,
|
|
646
|
+
head_oid=canonical_head,
|
|
647
|
+
)
|
|
648
|
+
merge_base, tree_oid = _acquire_into(
|
|
649
|
+
source,
|
|
650
|
+
repository_url=canonical_repository_url(canonical_repository),
|
|
651
|
+
number=number,
|
|
652
|
+
base_ref=canonical_base_ref,
|
|
653
|
+
base_oid=canonical_base,
|
|
654
|
+
head_oid=canonical_head,
|
|
655
|
+
hooks=hooks,
|
|
656
|
+
template=template,
|
|
657
|
+
maximum_bytes=maximum_snapshot_bytes,
|
|
658
|
+
)
|
|
659
|
+
_make_read_only(root)
|
|
660
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
661
|
+
remove_snapshot(root)
|
|
662
|
+
raise RuntimeError(
|
|
663
|
+
"cannot materialize the immutable pull-request snapshot"
|
|
664
|
+
) from None
|
|
665
|
+
except BaseException:
|
|
666
|
+
remove_snapshot(root)
|
|
667
|
+
raise
|
|
668
|
+
return MaterializedSnapshot(
|
|
669
|
+
root=root, source_path=source, merge_base=merge_base, tree_oid=tree_oid
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def remove_snapshot(root: Path) -> None:
|
|
674
|
+
"""Remove a snapshot that this helper created after host inspection ends."""
|
|
675
|
+
resolved = root.resolve()
|
|
676
|
+
temporary_root = Path(tempfile.gettempdir()).resolve()
|
|
677
|
+
if resolved.parent != temporary_root or not resolved.name.startswith(
|
|
678
|
+
"athena-pr-review-"
|
|
679
|
+
):
|
|
680
|
+
raise RuntimeError(
|
|
681
|
+
"refusing to remove a snapshot outside the managed temporary directory"
|
|
682
|
+
)
|
|
683
|
+
source = resolved / "source"
|
|
684
|
+
if source.is_mount():
|
|
685
|
+
_detach_volume(source)
|
|
686
|
+
|
|
687
|
+
def make_removable(function: object, path: str, _: object) -> None:
|
|
688
|
+
candidate = Path(path)
|
|
689
|
+
candidate.parent.chmod(0o700)
|
|
690
|
+
if candidate.exists() and not candidate.is_symlink():
|
|
691
|
+
candidate.chmod(0o700)
|
|
692
|
+
if not callable(function):
|
|
693
|
+
raise TypeError("cannot remove the immutable pull-request snapshot")
|
|
694
|
+
function(path)
|
|
695
|
+
|
|
696
|
+
shutil.rmtree(resolved, onexc=make_removable)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
700
|
+
command_arguments = list(sys.argv[1:] if argv is None else argv)
|
|
701
|
+
if "--bounded-materialize" in command_arguments:
|
|
702
|
+
child_arguments = [
|
|
703
|
+
argument
|
|
704
|
+
for argument in command_arguments
|
|
705
|
+
if argument != "--bounded-materialize"
|
|
706
|
+
]
|
|
707
|
+
return _bounded_materialize_main(child_arguments)
|
|
708
|
+
parser = argument_parser(description=__doc__)
|
|
709
|
+
parser.add_argument("--repository", required=True, metavar="OWNER/REPOSITORY")
|
|
710
|
+
parser.add_argument("--pr-number", required=True, type=int, metavar="NUMBER")
|
|
711
|
+
parser.add_argument("--base-ref", required=True, metavar="BRANCH")
|
|
712
|
+
parser.add_argument("--base-oid", required=True, metavar="BASE_OID")
|
|
713
|
+
parser.add_argument("--head-oid", required=True, metavar="HEAD_OID")
|
|
714
|
+
arguments = parser.parse_args(command_arguments)
|
|
715
|
+
try:
|
|
716
|
+
snapshot = materialize_snapshot(
|
|
717
|
+
repository=arguments.repository,
|
|
718
|
+
number=arguments.pr_number,
|
|
719
|
+
base_ref=arguments.base_ref,
|
|
720
|
+
base_oid=arguments.base_oid,
|
|
721
|
+
head_oid=arguments.head_oid,
|
|
722
|
+
)
|
|
723
|
+
except RuntimeError as error:
|
|
724
|
+
print(error, file=sys.stderr)
|
|
725
|
+
return 1
|
|
726
|
+
print(json.dumps(snapshot.as_json(), sort_keys=True))
|
|
727
|
+
return 0
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
if __name__ == "__main__":
|
|
731
|
+
raise SystemExit(main())
|