@homericintelligence/athena-opencode 0.5.0 → 0.5.1
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/README.md +30 -17
- package/package.json +2 -2
- package/plugin.js +20 -5
- package/skills/TECHNICAL_ENGLISH.md +78 -0
- package/skills/_cli.py +26 -18
- package/skills/_support/docs/dependency-resolution.md +192 -0
- package/skills/_support/docs/policies/development.md +117 -0
- package/skills/_support/docs/policies/evidence-integrity.md +40 -0
- package/skills/_support/docs/principles/README.md +542 -0
- package/skills/_support/docs/principles/details/p001-kiss.md +114 -0
- package/skills/_support/docs/principles/details/p002-yagni.md +109 -0
- package/skills/_support/docs/principles/details/p003-dry.md +126 -0
- package/skills/_support/docs/principles/details/p004-solid.md +165 -0
- package/skills/_support/docs/principles/details/p005-modularity.md +123 -0
- package/skills/_support/docs/principles/details/p006-principle-of-least-astonishment.md +115 -0
- package/skills/_support/docs/principles/details/p007-subtraction-over-addition.md +113 -0
- package/skills/_support/docs/principles/details/p008-understand-before-subtracting.md +115 -0
- package/skills/_support/docs/principles/details/p009-general-mechanisms-over-special-cases.md +108 -0
- package/skills/_support/docs/principles/details/p010-scope-fidelity.md +117 -0
- package/skills/_support/docs/principles/details/p011-minimal-coherent-change.md +128 -0
- package/skills/_support/docs/principles/details/p012-evidence-before-modification.md +115 -0
- package/skills/_support/docs/principles/details/p013-avoid-hasty-abstractions.md +112 -0
- package/skills/_support/docs/principles/details/p014-preserve-unrequested-behavior.md +110 -0
- package/skills/_support/docs/principles/details/p015-architecture-conformance.md +124 -0
- package/skills/_support/docs/principles/details/p016-separation-of-concerns.md +125 -0
- package/skills/_support/docs/principles/details/p017-high-cohesion-low-coupling.md +130 -0
- package/skills/_support/docs/principles/details/p018-information-hiding.md +128 -0
- package/skills/_support/docs/principles/details/p019-explicit-contracts.md +134 -0
- package/skills/_support/docs/principles/details/p020-executable-architecture.md +130 -0
- package/skills/_support/docs/principles/details/p021-evolutionary-and-reversible-design.md +126 -0
- package/skills/_support/docs/principles/details/p022-test-behavior-not-implementation.md +129 -0
- package/skills/_support/docs/principles/details/p023-parameterized-table-driven-testing.md +129 -0
- package/skills/_support/docs/principles/details/p024-boundary-value-testing.md +128 -0
- package/skills/_support/docs/principles/details/p025-property-based-testing-for-invariants.md +133 -0
- package/skills/_support/docs/principles/details/p026-regression-before-repair.md +134 -0
- package/skills/_support/docs/principles/details/p027-deterministic-and-hermetic-tests.md +128 -0
- package/skills/_support/docs/principles/details/p028-test-failure-paths.md +133 -0
- package/skills/_support/docs/principles/details/p029-generalize-error-policy-preserve-specific-cause.md +131 -0
- package/skills/_support/docs/principles/details/p030-nearest-responsible-error-boundary.md +133 -0
- package/skills/_support/docs/principles/details/p031-propagate-rather-than-swallow.md +131 -0
- package/skills/_support/docs/principles/details/p032-handle-once-preserve-causality.md +137 -0
- package/skills/_support/docs/principles/details/p033-state-safe-failure-semantics.md +140 -0
- package/skills/_support/docs/principles/details/p034-fail-fast.md +132 -0
- package/skills/_support/docs/principles/details/p035-fail-secure-fail-closed.md +137 -0
- package/skills/_support/docs/principles/details/p036-graceful-degradation.md +137 -0
- package/skills/_support/docs/principles/details/p037-idempotency-before-retry.md +139 -0
- package/skills/_support/docs/principles/details/p038-bounded-retry.md +145 -0
- package/skills/_support/docs/principles/details/p039-bounded-waiting.md +151 -0
- package/skills/_support/docs/principles/details/p040-bounded-resources.md +138 -0
- package/skills/_support/docs/principles/details/p041-backpressure-and-load-shedding.md +141 -0
- package/skills/_support/docs/principles/details/p042-fault-isolation-bulkheads.md +140 -0
- package/skills/_support/docs/principles/details/p043-circuit-breakers.md +148 -0
- package/skills/_support/docs/principles/details/p044-atomicity-where-possible.md +141 -0
- package/skills/_support/docs/principles/details/p045-compensation-where-atomicity-is-impossible.md +163 -0
- package/skills/_support/docs/principles/details/p046-resumability.md +138 -0
- package/skills/_support/docs/principles/details/p047-observability-is-part-of-correctness.md +134 -0
- package/skills/_support/docs/principles/details/p048-secure-by-design.md +132 -0
- package/skills/_support/docs/principles/details/p049-secure-by-default.md +126 -0
- package/skills/_support/docs/principles/details/p050-least-privilege.md +123 -0
- package/skills/_support/docs/principles/details/p051-complete-mediation.md +124 -0
- package/skills/_support/docs/principles/details/p052-separation-of-duties.md +129 -0
- package/skills/_support/docs/principles/details/p053-validate-at-trust-boundaries.md +126 -0
- package/skills/_support/docs/principles/details/p054-defense-in-depth.md +135 -0
- package/skills/_support/docs/principles/details/p055-minimize-attack-surface.md +133 -0
- package/skills/_support/docs/principles/details/p056-secrets-stay-out-of-code-and-context.md +137 -0
- package/skills/_support/docs/principles/details/p057-supply-chain-integrity.md +135 -0
- package/skills/_support/docs/principles/details/p058-bounded-agent-authority.md +137 -0
- package/skills/_support/docs/principles/details/p059-data-is-not-instruction.md +130 -0
- package/skills/_support/docs/principles/details/p060-constrain-sub-agents.md +136 -0
- package/skills/_support/docs/principles/details/p061-separate-decision-from-high-impact-execution.md +131 -0
- package/skills/_support/docs/principles/details/p062-human-approval-for-irreversible-or-high-risk-actions.md +129 -0
- package/skills/_support/docs/principles/details/p063-requirement-to-code-traceability.md +116 -0
- package/skills/_support/docs/principles/details/p064-requirement-to-test-traceability.md +116 -0
- package/skills/_support/docs/principles/details/p065-verify-before-claiming-completion.md +129 -0
- package/skills/_support/docs/principles/details/p066-preserve-existing-work.md +114 -0
- package/skills/_support/docs/principles/details/p067-no-test-cheating.md +126 -0
- package/skills/_support/docs/principles/details/p068-no-validation-bypass.md +119 -0
- package/skills/_support/docs/principles/details/p069-independent-review-for-high-risk-changes.md +129 -0
- package/skills/_support/docs/principles/details/p070-code-health-must-not-regress.md +116 -0
- package/skills/_support/docs/principles/details/p071-consistency-over-personal-preference.md +117 -0
- package/skills/_support/docs/principles/details/p072-technical-evidence-over-preference.md +122 -0
- package/skills/_support/docs/principles/details/p073-optimize-only-with-evidence.md +123 -0
- package/skills/_support/docs/principles/details/p074-prefer-existing-mechanisms.md +121 -0
- package/skills/_support/docs/principles/details/p075-make-invalid-states-hard-to-represent.md +128 -0
- package/skills/_support/docs/principles/details/p076-parse-then-validate-then-operate.md +133 -0
- package/skills/_support/docs/principles/details/p077-separate-policy-from-mechanism.md +112 -0
- package/skills/_support/docs/principles/details/p078-single-source-of-truth.md +131 -0
- package/skills/_support/docs/principles/details/p079-explicit-ownership-and-lifetimes.md +124 -0
- package/skills/_support/docs/principles/details/p080-make-concurrency-deliberate.md +122 -0
- package/skills/_support/docs/principles/details/p081-forward-progress-with-safety.md +125 -0
- package/skills/_support/docs/principles/details/p082-design-for-cancellation.md +133 -0
- package/skills/_support/docs/principles/details/p083-irreversible-actions-last.md +124 -0
- package/skills/_support/docs/principles/details/p084-prefer-local-reasoning.md +131 -0
- package/skills/_support/docs/principles/details/p085-explicit-is-better-than-implicit.md +123 -0
- package/skills/_support/docs/principles/details/p086-readability-counts.md +113 -0
- package/skills/_support/docs/principles/details/p087-comments-explain-why-code-explains-what.md +120 -0
- package/skills/_support/docs/principles/details/p088-delete-dead-code.md +125 -0
- package/skills/_support/docs/principles/details/p089-delete-obsolete-configuration-and-dependencies.md +125 -0
- package/skills/_support/docs/principles/details/p090-prefer-negative-code.md +134 -0
- package/skills/_support/docs/principles/details/p091-test-driven-development.md +149 -0
- package/skills/_support/docs/review/README.md +59 -0
- package/skills/_support/docs/review/behavior-first-testing.md +73 -0
- package/skills/_support/docs/review/common.md +365 -0
- package/skills/_support/docs/review/design-docs.md +74 -0
- package/skills/_support/docs/review/issue-planning.md +220 -0
- package/skills/_support/docs/review/language-routing.md +141 -0
- package/skills/_support/docs/review/repository-scorecard.md +170 -0
- package/skills/advise/SKILL.md +93 -67
- package/skills/advise/scripts/list_retrievable_skills.py +3 -3
- package/skills/brainstorm/SKILL.md +160 -127
- package/skills/change-review/SKILL.md +91 -71
- package/skills/change-review/references/scope-resolution.md +69 -46
- package/skills/change-review/scripts/resolve_scope.py +119 -73
- package/skills/finalize-plan/SKILL.md +198 -139
- package/skills/git-worktrees/SKILL.md +100 -80
- package/skills/git-worktrees/scripts/prepare_worktree.py +16 -10
- package/skills/issue-review/SKILL.md +136 -79
- package/skills/learn/SKILL.md +276 -186
- package/skills/myrmidon-swarm/SKILL.md +126 -86
- package/skills/plan-issue/SKILL.md +122 -81
- package/skills/pr-review/SKILL.md +164 -114
- package/skills/pr-review/references/criteria.md +58 -37
- package/skills/pr-review/references/delivery.md +156 -98
- package/skills/pr-review/references/evidence.md +265 -148
- package/skills/pr-review/references/prevalidated.md +93 -61
- package/skills/pr-review/scripts/collect_evidence.py +211 -140
- package/skills/pr-review/scripts/diff_context.py +6 -4
- package/skills/pr-review/scripts/materialize_snapshot.py +112 -120
- package/skills/pr-review/scripts/pr_identity.py +27 -15
- package/skills/pr-review/scripts/resolve_pr.py +53 -31
- package/skills/repo-review/SKILL.md +202 -131
- package/skills/systematic-debugging/SKILL.md +210 -192
- package/skills/systematic-debugging/scripts/repository_evidence.py +9 -4
- package/skills/test-driven-development/SKILL.md +126 -96
- package/skills/tidy/SKILL.md +92 -59
- package/skills/tidy/scripts/run_tidy.py +3 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Calculate the two Git ranges for a pull-request review."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
@@ -32,7 +32,9 @@ def git(*arguments: str) -> str:
|
|
|
32
32
|
check=False,
|
|
33
33
|
)
|
|
34
34
|
if result.returncode != 0:
|
|
35
|
-
raise RuntimeError(
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
result.stderr.strip() or f"The git {' '.join(arguments)} command failed."
|
|
37
|
+
)
|
|
36
38
|
return result.stdout.strip()
|
|
37
39
|
|
|
38
40
|
|
|
@@ -42,8 +44,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
42
44
|
parser.add_argument("head_ref", metavar="HEAD_OID")
|
|
43
45
|
arguments = parser.parse_args(argv)
|
|
44
46
|
try:
|
|
45
|
-
base_ref = require_commit_oid(arguments.base_ref, "base
|
|
46
|
-
head_ref = require_commit_oid(arguments.head_ref, "head
|
|
47
|
+
base_ref = require_commit_oid(arguments.base_ref, "base object identifier")
|
|
48
|
+
head_ref = require_commit_oid(arguments.head_ref, "head object identifier")
|
|
47
49
|
require_complete_git_history()
|
|
48
50
|
git("rev-parse", "--verify", f"{base_ref}^{{commit}}")
|
|
49
51
|
git("rev-parse", "--verify", f"{head_ref}^{{commit}}")
|
|
@@ -27,11 +27,15 @@ from skills._cli import (
|
|
|
27
27
|
|
|
28
28
|
SNAPSHOT_COMMAND_TIMEOUT_SECONDS = 30.0
|
|
29
29
|
BOUNDED_MATERIALIZE_TIMEOUT_SECONDS = 600.0
|
|
30
|
+
MATERIALIZE_ERROR = "The helper cannot materialize the immutable pull-request snapshot."
|
|
31
|
+
QUOTA_ERROR = "The host cannot enforce the immutable pull-request snapshot size limit."
|
|
32
|
+
REMOVE_ERROR = "The helper cannot remove the immutable pull-request snapshot."
|
|
33
|
+
UNMANAGED_ROOT_ERROR = "The helper does not permit snapshot operations outside the managed temporary directory."
|
|
30
34
|
|
|
31
35
|
|
|
32
36
|
@dataclass(frozen=True)
|
|
33
37
|
class MaterializedSnapshot:
|
|
34
|
-
"""
|
|
38
|
+
"""This record binds a detached source tree to one reviewed GitHub pull request."""
|
|
35
39
|
|
|
36
40
|
root: Path
|
|
37
41
|
source_path: Path
|
|
@@ -78,11 +82,9 @@ def _git(
|
|
|
78
82
|
["git", *git_read_arguments(), *arguments], **command_options
|
|
79
83
|
)
|
|
80
84
|
except subprocess.SubprocessError as error:
|
|
81
|
-
raise RuntimeError(
|
|
82
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
83
|
-
) from error
|
|
85
|
+
raise RuntimeError(MATERIALIZE_ERROR) from error
|
|
84
86
|
if result.returncode not in accepted_codes:
|
|
85
|
-
raise RuntimeError(
|
|
87
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
86
88
|
return result.stdout.strip() if isinstance(result.stdout, str) else ""
|
|
87
89
|
|
|
88
90
|
|
|
@@ -97,32 +99,31 @@ def _hdiutil(*arguments: str) -> None:
|
|
|
97
99
|
check=False,
|
|
98
100
|
)
|
|
99
101
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
100
|
-
raise RuntimeError(
|
|
101
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
102
|
-
) from error
|
|
102
|
+
raise RuntimeError(QUOTA_ERROR) from error
|
|
103
103
|
if result.returncode != 0:
|
|
104
|
-
raise RuntimeError(
|
|
105
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
106
|
-
)
|
|
104
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
107
105
|
|
|
108
106
|
|
|
109
107
|
def _mount_tmpfs(source: Path, maximum_bytes: int) -> bool:
|
|
110
108
|
"""Mount a Linux tmpfs whose total capacity is the snapshot quota.
|
|
111
109
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
bounded user
|
|
110
|
+
Return True only when the mount enforces the quota. Do not use
|
|
111
|
+
``source.is_mount()`` elsewhere to infer this result. If the host cannot
|
|
112
|
+
find or run ``mount``, return False. If the host rejects the mount, return
|
|
113
|
+
False. The caller then uses the bounded user and mount namespace or fails
|
|
114
|
+
closed.
|
|
116
115
|
"""
|
|
117
116
|
if sys.platform != "linux" or shutil.which("mount") is None:
|
|
118
117
|
return False
|
|
119
118
|
maximum_kibibytes = maximum_bytes // 1024
|
|
120
119
|
if maximum_kibibytes < 1:
|
|
121
|
-
raise RuntimeError(
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
"The immutable pull-request snapshot has no usable disk space."
|
|
122
|
+
)
|
|
122
123
|
try:
|
|
123
124
|
source.mkdir()
|
|
124
125
|
except FileExistsError:
|
|
125
|
-
#
|
|
126
|
+
# If a prior materialization created the directory, use it again.
|
|
126
127
|
pass
|
|
127
128
|
except OSError:
|
|
128
129
|
return False
|
|
@@ -150,15 +151,17 @@ def _mount_tmpfs(source: Path, maximum_bytes: int) -> bool:
|
|
|
150
151
|
def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
151
152
|
"""Return a bounded source directory, or None for the Linux bounded fallback.
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
On macOS, attach a sparse HFS+ volume whose total capacity is the quota.
|
|
155
|
+
On Linux, mount a tmpfs when the host has the required authority. Otherwise,
|
|
156
|
+
return None so the caller can materialize the snapshot inside a bounded user
|
|
157
|
+
and mount namespace. On other systems, fail closed because the host cannot
|
|
158
|
+
enforce the snapshot size limit.
|
|
158
159
|
"""
|
|
159
160
|
maximum_kibibytes = maximum_bytes // 1024
|
|
160
161
|
if maximum_kibibytes < 1:
|
|
161
|
-
raise RuntimeError(
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
"The immutable pull-request snapshot has no usable disk space."
|
|
164
|
+
)
|
|
162
165
|
if sys.platform == "darwin":
|
|
163
166
|
image = root / "snapshot.sparseimage"
|
|
164
167
|
source = root / "source"
|
|
@@ -173,7 +176,7 @@ def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
|
173
176
|
"-fs",
|
|
174
177
|
"Case-sensitive HFS+",
|
|
175
178
|
"-volname",
|
|
176
|
-
"Athena
|
|
179
|
+
"Athena pull-request review",
|
|
177
180
|
"-nospotlight",
|
|
178
181
|
str(image),
|
|
179
182
|
)
|
|
@@ -186,9 +189,7 @@ def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
|
186
189
|
str(image),
|
|
187
190
|
)
|
|
188
191
|
if not source.is_mount():
|
|
189
|
-
raise RuntimeError(
|
|
190
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
191
|
-
)
|
|
192
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
192
193
|
return source
|
|
193
194
|
if sys.platform.startswith("linux"):
|
|
194
195
|
source = root / "source"
|
|
@@ -197,13 +198,11 @@ def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
|
197
198
|
try:
|
|
198
199
|
source.rmdir()
|
|
199
200
|
except OSError:
|
|
200
|
-
# The mount
|
|
201
|
-
# rmdir
|
|
201
|
+
# The mount can create the directory before it fails.
|
|
202
|
+
# If rmdir fails, the caller uses None to select the other method.
|
|
202
203
|
pass
|
|
203
204
|
return None
|
|
204
|
-
raise RuntimeError(
|
|
205
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
206
|
-
)
|
|
205
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
207
206
|
|
|
208
207
|
|
|
209
208
|
def _detach_volume(source: Path) -> None:
|
|
@@ -229,41 +228,38 @@ def _detach_volume(source: Path) -> None:
|
|
|
229
228
|
check=False,
|
|
230
229
|
)
|
|
231
230
|
if result.returncode != 0:
|
|
232
|
-
raise RuntimeError(
|
|
231
|
+
raise RuntimeError(REMOVE_ERROR)
|
|
233
232
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
234
|
-
raise RuntimeError(
|
|
235
|
-
"cannot remove the immutable pull-request snapshot"
|
|
236
|
-
) from error
|
|
233
|
+
raise RuntimeError(REMOVE_ERROR) from error
|
|
237
234
|
return
|
|
238
|
-
raise RuntimeError(
|
|
235
|
+
raise RuntimeError(REMOVE_ERROR)
|
|
239
236
|
|
|
240
237
|
|
|
241
238
|
def _detach_best_effort(source: Path) -> None:
|
|
242
|
-
"""Detach
|
|
239
|
+
"""Detach the quota volume. Do not let a detach failure hide an earlier failure."""
|
|
243
240
|
try:
|
|
244
241
|
_detach_volume(source)
|
|
245
242
|
except RuntimeError:
|
|
246
|
-
#
|
|
247
|
-
# secondary cleanup error.
|
|
243
|
+
# Do not let this cleanup error hide an earlier failure.
|
|
248
244
|
pass
|
|
249
245
|
|
|
250
246
|
|
|
251
247
|
def _require_base_ref(base_ref: str) -> str:
|
|
252
248
|
"""Validate the GitHub base branch before it becomes a fetch refspec."""
|
|
253
249
|
if not base_ref or base_ref.startswith("-") or ".." in base_ref:
|
|
254
|
-
raise RuntimeError("GitHub returned
|
|
250
|
+
raise RuntimeError("GitHub returned a pull-request base ref that is not valid.")
|
|
255
251
|
_git("check-ref-format", "--branch", base_ref)
|
|
256
252
|
return base_ref
|
|
257
253
|
|
|
258
254
|
|
|
259
255
|
def _repository_size(path: Path, *, maximum_bytes: int | None = None) -> int:
|
|
260
|
-
"""Return the
|
|
256
|
+
"""Return the repository size and enforce the applicable size limit."""
|
|
261
257
|
if maximum_bytes is None:
|
|
262
258
|
try:
|
|
263
259
|
maximum_bytes = (shutil.disk_usage(path).free * 9) // 10
|
|
264
260
|
except OSError as error:
|
|
265
261
|
raise RuntimeError(
|
|
266
|
-
"cannot inspect the immutable pull-request snapshot"
|
|
262
|
+
"The helper cannot inspect the immutable pull-request snapshot."
|
|
267
263
|
) from error
|
|
268
264
|
total = 0
|
|
269
265
|
for entry in path.rglob("*"):
|
|
@@ -271,13 +267,13 @@ def _repository_size(path: Path, *, maximum_bytes: int | None = None) -> int:
|
|
|
271
267
|
details = entry.lstat()
|
|
272
268
|
except OSError as error:
|
|
273
269
|
raise RuntimeError(
|
|
274
|
-
"cannot inspect the immutable pull-request snapshot"
|
|
270
|
+
"The helper cannot inspect the immutable pull-request snapshot."
|
|
275
271
|
) from error
|
|
276
272
|
if stat.S_ISREG(details.st_mode):
|
|
277
273
|
total += details.st_size
|
|
278
274
|
if total > maximum_bytes:
|
|
279
275
|
raise RuntimeError(
|
|
280
|
-
"immutable pull-request snapshot exceeds the safe size limit"
|
|
276
|
+
"The immutable pull-request snapshot exceeds the safe size limit."
|
|
281
277
|
)
|
|
282
278
|
return total
|
|
283
279
|
|
|
@@ -296,7 +292,7 @@ def _verify_no_promisor_configuration(repository: Path) -> None:
|
|
|
296
292
|
)
|
|
297
293
|
if value:
|
|
298
294
|
raise RuntimeError(
|
|
299
|
-
"immutable pull-request snapshot must not use partial
|
|
295
|
+
"The immutable pull-request snapshot must not use partial-clone configuration."
|
|
300
296
|
)
|
|
301
297
|
promisor = _git(
|
|
302
298
|
"config",
|
|
@@ -309,12 +305,12 @@ def _verify_no_promisor_configuration(repository: Path) -> None:
|
|
|
309
305
|
)
|
|
310
306
|
if promisor:
|
|
311
307
|
raise RuntimeError(
|
|
312
|
-
"immutable pull-request snapshot must not use promisor configuration"
|
|
308
|
+
"The immutable pull-request snapshot must not use promisor configuration."
|
|
313
309
|
)
|
|
314
310
|
|
|
315
311
|
|
|
316
312
|
def _require_commit(repository: Path, revision: str, label: str) -> str:
|
|
317
|
-
"""Verify one fetched ref resolves
|
|
313
|
+
"""Verify that one fetched ref resolves to its captured commit object identifier."""
|
|
318
314
|
resolved = _git(
|
|
319
315
|
"rev-parse",
|
|
320
316
|
"--verify",
|
|
@@ -326,7 +322,7 @@ def _require_commit(repository: Path, revision: str, label: str) -> str:
|
|
|
326
322
|
|
|
327
323
|
|
|
328
324
|
def _make_read_only(root: Path) -> None:
|
|
329
|
-
"""Remove write bits from the completed snapshot without following
|
|
325
|
+
"""Remove write bits from the completed snapshot without following symbolic links."""
|
|
330
326
|
entries = sorted(root.rglob("*"), key=lambda entry: len(entry.parts), reverse=True)
|
|
331
327
|
for entry in entries:
|
|
332
328
|
if entry.is_symlink():
|
|
@@ -339,7 +335,7 @@ def _make_read_only(root: Path) -> None:
|
|
|
339
335
|
entry.chmod(0o555 if mode & stat.S_IXUSR else 0o444)
|
|
340
336
|
except OSError as error:
|
|
341
337
|
raise RuntimeError(
|
|
342
|
-
"cannot make the immutable pull-request snapshot read-only"
|
|
338
|
+
"The helper cannot make the immutable pull-request snapshot read-only."
|
|
343
339
|
) from error
|
|
344
340
|
root.chmod(0o555)
|
|
345
341
|
|
|
@@ -356,11 +352,11 @@ def _acquire_into(
|
|
|
356
352
|
template: Path,
|
|
357
353
|
maximum_bytes: int,
|
|
358
354
|
) -> tuple[str, str]:
|
|
359
|
-
"""Fetch only the captured base branch and
|
|
355
|
+
"""Fetch only the captured base branch and pull-request head.
|
|
360
356
|
|
|
361
|
-
The
|
|
362
|
-
|
|
363
|
-
|
|
357
|
+
The caller provides a source directory in a quota volume or bounded tmpfs.
|
|
358
|
+
Verify each immutable binding against the captured object identifiers.
|
|
359
|
+
Then, return ``(merge_base, tree_oid)``.
|
|
364
360
|
"""
|
|
365
361
|
_git(
|
|
366
362
|
"-c",
|
|
@@ -405,15 +401,26 @@ def _acquire_into(
|
|
|
405
401
|
_git("rev-parse", "--is-shallow-repository", cwd=source, capture_output=True)
|
|
406
402
|
!= "false"
|
|
407
403
|
):
|
|
408
|
-
raise RuntimeError(
|
|
409
|
-
|
|
410
|
-
|
|
404
|
+
raise RuntimeError(
|
|
405
|
+
"The immutable pull-request snapshot requires complete history."
|
|
406
|
+
)
|
|
407
|
+
if (
|
|
408
|
+
_require_commit(source, "refs/athena/base", "fetched base object identifier")
|
|
409
|
+
!= base_oid
|
|
410
|
+
):
|
|
411
|
+
raise RuntimeError(
|
|
412
|
+
"The fetched base ref does not match the captured base object identifier."
|
|
413
|
+
)
|
|
411
414
|
if (
|
|
412
|
-
_require_commit(
|
|
415
|
+
_require_commit(
|
|
416
|
+
source,
|
|
417
|
+
f"refs/athena/pr/{number}/head",
|
|
418
|
+
"fetched head object identifier",
|
|
419
|
+
)
|
|
413
420
|
!= head_oid
|
|
414
421
|
):
|
|
415
422
|
raise RuntimeError(
|
|
416
|
-
"fetched pull-request ref does not match the captured head
|
|
423
|
+
"The fetched pull-request ref does not match the captured head object identifier."
|
|
417
424
|
)
|
|
418
425
|
merge_bases = _git(
|
|
419
426
|
"merge-base",
|
|
@@ -425,15 +432,15 @@ def _acquire_into(
|
|
|
425
432
|
).splitlines()
|
|
426
433
|
if len(merge_bases) != 1:
|
|
427
434
|
raise RuntimeError(
|
|
428
|
-
"immutable pull-request snapshot requires one unambiguous merge base"
|
|
435
|
+
"The immutable pull-request snapshot requires one unambiguous merge base."
|
|
429
436
|
)
|
|
430
437
|
merge_base = require_commit_oid(merge_bases[0], "immutable merge base")
|
|
431
|
-
tree_oid = _require_commit(source, head_oid, "fetched head
|
|
438
|
+
tree_oid = _require_commit(source, head_oid, "fetched head object identifier")
|
|
432
439
|
tree_oid = _git(
|
|
433
440
|
"rev-parse", f"{tree_oid}^{{tree}}", cwd=source, capture_output=True
|
|
434
441
|
)
|
|
435
442
|
if COMMIT_OID.fullmatch(tree_oid) is None:
|
|
436
|
-
raise RuntimeError("Git returned an
|
|
443
|
+
raise RuntimeError("Git returned an immutable head tree that is not valid.")
|
|
437
444
|
_git(
|
|
438
445
|
"-c",
|
|
439
446
|
f"core.hooksPath={hooks}",
|
|
@@ -450,15 +457,16 @@ def _acquire_into(
|
|
|
450
457
|
|
|
451
458
|
|
|
452
459
|
def _bounded_materialize_main(arguments: Sequence[str]) -> int:
|
|
453
|
-
"""Materialize inside a bounded user
|
|
454
|
-
|
|
455
|
-
This runs as the child of ``unshare -rm`` on Linux.
|
|
456
|
-
whose total capacity is the snapshot quota
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
not
|
|
460
|
+
"""Materialize inside a bounded user and mount namespace.
|
|
461
|
+
|
|
462
|
+
This helper runs as the child of ``unshare -rm`` on Linux. The helper mounts
|
|
463
|
+
a tmpfs whose total capacity is the snapshot quota. It materializes the
|
|
464
|
+
snapshot in the size-limited file system. Then, it copies the verified
|
|
465
|
+
read-only tree to a path that the host can read. Exit 2 means that the helper
|
|
466
|
+
could not establish the quota boundary. Exit 1 means that materialization
|
|
467
|
+
failed.
|
|
460
468
|
"""
|
|
461
|
-
parser = argument_parser(description="
|
|
469
|
+
parser = argument_parser(description="Materialize a bounded pull-request snapshot.")
|
|
462
470
|
parser.add_argument("--root", required=True, metavar="ROOT")
|
|
463
471
|
parser.add_argument("--repository-url", required=True, metavar="URL")
|
|
464
472
|
parser.add_argument("--pr-number", required=True, type=int, metavar="NUMBER")
|
|
@@ -470,22 +478,20 @@ def _bounded_materialize_main(arguments: Sequence[str]) -> int:
|
|
|
470
478
|
root = Path(parsed.root).resolve()
|
|
471
479
|
temporary_root = Path(tempfile.gettempdir()).resolve()
|
|
472
480
|
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
|
-
)
|
|
481
|
+
print(UNMANAGED_ROOT_ERROR, file=sys.stderr)
|
|
477
482
|
return 1
|
|
478
483
|
try:
|
|
479
|
-
canonical_base = require_commit_oid(
|
|
480
|
-
|
|
484
|
+
canonical_base = require_commit_oid(
|
|
485
|
+
parsed.base_oid, "captured base object identifier"
|
|
486
|
+
)
|
|
487
|
+
canonical_head = require_commit_oid(
|
|
488
|
+
parsed.head_oid, "captured head object identifier"
|
|
489
|
+
)
|
|
481
490
|
canonical_base_ref = _require_base_ref(parsed.base_ref)
|
|
482
491
|
bounded = root / "bounded"
|
|
483
492
|
bounded.mkdir()
|
|
484
493
|
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
|
-
)
|
|
494
|
+
print(QUOTA_ERROR, file=sys.stderr)
|
|
489
495
|
return 2
|
|
490
496
|
merge_base, tree_oid = _acquire_into(
|
|
491
497
|
bounded,
|
|
@@ -528,20 +534,18 @@ def _bounded_materialize(
|
|
|
528
534
|
base_oid: str,
|
|
529
535
|
head_oid: str,
|
|
530
536
|
) -> MaterializedSnapshot:
|
|
531
|
-
"""Materialize a snapshot inside a bounded Linux user
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
537
|
+
"""Materialize a snapshot inside a bounded Linux user and mount namespace.
|
|
538
|
+
|
|
539
|
+
Run this helper under ``unshare -rm``. The child has root authority only in
|
|
540
|
+
the new namespace. The child mounts a tmpfs whose total capacity is the
|
|
541
|
+
snapshot quota. The file system bounds each fetch and checkout write. Before
|
|
542
|
+
the namespace exits, the child copies the verified tree to a read-only path
|
|
543
|
+
that the host can read. If ``unshare`` or a tmpfs mount is not available,
|
|
544
|
+
the helper fails closed.
|
|
539
545
|
"""
|
|
540
546
|
unshare = shutil.which("unshare")
|
|
541
547
|
if unshare is None:
|
|
542
|
-
raise RuntimeError(
|
|
543
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
544
|
-
)
|
|
548
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
545
549
|
command = [
|
|
546
550
|
unshare,
|
|
547
551
|
"-rm",
|
|
@@ -573,26 +577,20 @@ def _bounded_materialize(
|
|
|
573
577
|
timeout=BOUNDED_MATERIALIZE_TIMEOUT_SECONDS,
|
|
574
578
|
)
|
|
575
579
|
except subprocess.TimeoutExpired as error:
|
|
576
|
-
raise RuntimeError(
|
|
577
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
578
|
-
) from error
|
|
580
|
+
raise RuntimeError(MATERIALIZE_ERROR) from error
|
|
579
581
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
580
|
-
raise RuntimeError(
|
|
581
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
582
|
-
) from error
|
|
582
|
+
raise RuntimeError(QUOTA_ERROR) from error
|
|
583
583
|
if result.returncode == 2:
|
|
584
|
-
raise RuntimeError(
|
|
585
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
586
|
-
)
|
|
584
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
587
585
|
if result.returncode != 0:
|
|
588
|
-
raise RuntimeError(
|
|
586
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
589
587
|
try:
|
|
590
588
|
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
591
589
|
if not lines:
|
|
592
|
-
raise RuntimeError(
|
|
590
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
593
591
|
record = json.loads(lines[-1])
|
|
594
592
|
if not isinstance(record, dict):
|
|
595
|
-
raise TypeError(
|
|
593
|
+
raise TypeError(MATERIALIZE_ERROR)
|
|
596
594
|
source_path = Path(str(record["source_path"]))
|
|
597
595
|
merge_base = require_commit_oid(record["merge_base"], "immutable merge base")
|
|
598
596
|
tree_oid = require_commit_oid(record["tree_oid"], "immutable head tree")
|
|
@@ -603,11 +601,9 @@ def _bounded_materialize(
|
|
|
603
601
|
json.JSONDecodeError,
|
|
604
602
|
RuntimeError,
|
|
605
603
|
) as error:
|
|
606
|
-
raise RuntimeError(
|
|
607
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
608
|
-
) from error
|
|
604
|
+
raise RuntimeError(MATERIALIZE_ERROR) from error
|
|
609
605
|
if source_path != root / "source" or not source_path.is_dir():
|
|
610
|
-
raise RuntimeError(
|
|
606
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
611
607
|
return MaterializedSnapshot(
|
|
612
608
|
root=root, source_path=source_path, merge_base=merge_base, tree_oid=tree_oid
|
|
613
609
|
)
|
|
@@ -616,12 +612,12 @@ def _bounded_materialize(
|
|
|
616
612
|
def materialize_snapshot(
|
|
617
613
|
*, repository: str, number: int, base_ref: str, base_oid: str, head_oid: str
|
|
618
614
|
) -> MaterializedSnapshot:
|
|
619
|
-
"""
|
|
615
|
+
"""Materialize a captured base branch and pull-request head in a new repository."""
|
|
620
616
|
canonical_repository = require_github_repository(repository, "GitHub repository")
|
|
621
617
|
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
|
|
624
|
-
canonical_head = require_commit_oid(head_oid, "captured head
|
|
618
|
+
raise RuntimeError("The pull-request number must be positive.")
|
|
619
|
+
canonical_base = require_commit_oid(base_oid, "captured base object identifier")
|
|
620
|
+
canonical_head = require_commit_oid(head_oid, "captured head object identifier")
|
|
625
621
|
canonical_base_ref = _require_base_ref(base_ref)
|
|
626
622
|
root = Path(tempfile.mkdtemp(prefix="athena-pr-review-"))
|
|
627
623
|
template = root / "empty-template"
|
|
@@ -632,7 +628,7 @@ def materialize_snapshot(
|
|
|
632
628
|
maximum_snapshot_bytes = (shutil.disk_usage(root).free * 9) // 10
|
|
633
629
|
if maximum_snapshot_bytes < 1:
|
|
634
630
|
raise RuntimeError(
|
|
635
|
-
"immutable pull-request snapshot has no usable disk space"
|
|
631
|
+
"The immutable pull-request snapshot has no usable disk space."
|
|
636
632
|
)
|
|
637
633
|
source = _create_quota_volume(root, maximum_snapshot_bytes)
|
|
638
634
|
if source is None:
|
|
@@ -659,9 +655,7 @@ def materialize_snapshot(
|
|
|
659
655
|
_make_read_only(root)
|
|
660
656
|
except (OSError, subprocess.TimeoutExpired):
|
|
661
657
|
remove_snapshot(root)
|
|
662
|
-
raise RuntimeError(
|
|
663
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
664
|
-
) from None
|
|
658
|
+
raise RuntimeError(MATERIALIZE_ERROR) from None
|
|
665
659
|
except BaseException:
|
|
666
660
|
remove_snapshot(root)
|
|
667
661
|
raise
|
|
@@ -671,15 +665,13 @@ def materialize_snapshot(
|
|
|
671
665
|
|
|
672
666
|
|
|
673
667
|
def remove_snapshot(root: Path) -> None:
|
|
674
|
-
"""
|
|
668
|
+
"""After host inspection ends, remove a snapshot that this helper materialized."""
|
|
675
669
|
resolved = root.resolve()
|
|
676
670
|
temporary_root = Path(tempfile.gettempdir()).resolve()
|
|
677
671
|
if resolved.parent != temporary_root or not resolved.name.startswith(
|
|
678
672
|
"athena-pr-review-"
|
|
679
673
|
):
|
|
680
|
-
raise RuntimeError(
|
|
681
|
-
"refusing to remove a snapshot outside the managed temporary directory"
|
|
682
|
-
)
|
|
674
|
+
raise RuntimeError(UNMANAGED_ROOT_ERROR)
|
|
683
675
|
source = resolved / "source"
|
|
684
676
|
if source.is_mount():
|
|
685
677
|
_detach_volume(source)
|
|
@@ -690,7 +682,7 @@ def remove_snapshot(root: Path) -> None:
|
|
|
690
682
|
if candidate.exists() and not candidate.is_symlink():
|
|
691
683
|
candidate.chmod(0o700)
|
|
692
684
|
if not callable(function):
|
|
693
|
-
raise TypeError(
|
|
685
|
+
raise TypeError(REMOVE_ERROR)
|
|
694
686
|
function(path)
|
|
695
687
|
|
|
696
688
|
shutil.rmtree(resolved, onexc=make_removable)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Validate pull-request identifiers shared by
|
|
1
|
+
"""Validate pull-request identifiers shared by pull-request review helpers."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -12,45 +12,51 @@ GITHUB_HOST = "github.com"
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def validate_pr_identifier(value: str) -> None:
|
|
15
|
-
"""Require a positive
|
|
15
|
+
"""Require a positive pull-request number or a canonical GitHub pull-request URL."""
|
|
16
16
|
if (value.isascii() and value.isdigit() and int(value) > 0) or PR_URL.fullmatch(
|
|
17
17
|
value
|
|
18
18
|
):
|
|
19
19
|
return
|
|
20
|
-
raise RuntimeError(f"
|
|
20
|
+
raise RuntimeError(f"The pull-request identifier is not valid: {value!r}.")
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def require_commit_oid(value: object, label: str) -> str:
|
|
24
|
-
"""Return one canonical immutable commit
|
|
24
|
+
"""Return one canonical immutable commit object identifier or fail closed."""
|
|
25
25
|
if not isinstance(value, str) or COMMIT_OID.fullmatch(value) is None:
|
|
26
|
-
raise RuntimeError(
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
f"The value for {label} must be a Git commit object identifier that "
|
|
28
|
+
"contains 40 lowercase hexadecimal characters."
|
|
29
|
+
)
|
|
27
30
|
return value
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
def require_github_repository(value: object, label: str) -> str:
|
|
31
|
-
"""Return a canonical
|
|
34
|
+
"""Return a canonical GitHub repository or reject unsafe input."""
|
|
32
35
|
if not isinstance(value, str) or GITHUB_REPOSITORY.fullmatch(value) is None:
|
|
33
|
-
raise RuntimeError(
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"The value for "
|
|
38
|
+
f"{label} must use the canonical GitHub OWNER/REPOSITORY format."
|
|
39
|
+
)
|
|
34
40
|
return value
|
|
35
41
|
|
|
36
42
|
|
|
37
43
|
def require_github_host(value: object, label: str) -> str:
|
|
38
44
|
"""Return the supported canonical GitHub hostname or fail closed."""
|
|
39
45
|
if value != GITHUB_HOST:
|
|
40
|
-
raise RuntimeError(f"{label} must be {GITHUB_HOST}")
|
|
46
|
+
raise RuntimeError(f"The value for {label} must be '{GITHUB_HOST}'.")
|
|
41
47
|
return GITHUB_HOST
|
|
42
48
|
|
|
43
49
|
|
|
44
50
|
def canonical_pull_request_url(repository: object, number: object) -> str:
|
|
45
|
-
"""Return one exact public
|
|
51
|
+
"""Return one exact public GitHub pull-request URL from a trusted identity."""
|
|
46
52
|
canonical_repository = require_github_repository(repository, "repository")
|
|
47
53
|
if isinstance(number, bool) or not isinstance(number, int) or number < 1:
|
|
48
|
-
raise RuntimeError("pull-request number must be a positive integer")
|
|
54
|
+
raise RuntimeError("The pull-request number must be a positive integer.")
|
|
49
55
|
return f"https://{GITHUB_HOST}/{canonical_repository}/pull/{number}"
|
|
50
56
|
|
|
51
57
|
|
|
52
58
|
def pull_request_number(value: str) -> int:
|
|
53
|
-
"""Return the positive number
|
|
59
|
+
"""Return the positive number in a validated pull-request identifier."""
|
|
54
60
|
validate_pr_identifier(value)
|
|
55
61
|
if value.isdigit():
|
|
56
62
|
return int(value)
|
|
@@ -64,17 +70,23 @@ def require_canonical_pull_request_url(
|
|
|
64
70
|
"""Require the exact canonical URL for a retained pull-request identity."""
|
|
65
71
|
canonical_url = canonical_pull_request_url(repository, number)
|
|
66
72
|
if value != canonical_url:
|
|
67
|
-
raise RuntimeError(
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"The value for {label} must be the canonical GitHub pull-request URL."
|
|
75
|
+
)
|
|
68
76
|
return canonical_url
|
|
69
77
|
|
|
70
78
|
|
|
71
79
|
def repository_from_pr_url(url: str, number: int) -> str:
|
|
72
|
-
"""Return
|
|
80
|
+
"""Return the repository from a canonical pull-request URL and number."""
|
|
73
81
|
parsed_url = urlparse(url)
|
|
74
82
|
path_parts = parsed_url.path.strip("/").split("/")
|
|
75
83
|
if len(path_parts) != 4 or path_parts[2] != "pull" or path_parts[3] != str(number):
|
|
76
|
-
raise RuntimeError(
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
f"GitHub returned a pull-request URL that is not valid: '{url}'."
|
|
86
|
+
)
|
|
77
87
|
repository = "/".join(path_parts[:2])
|
|
78
88
|
if url != canonical_pull_request_url(repository, number):
|
|
79
|
-
raise RuntimeError(
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"GitHub returned a pull-request URL that is not valid: '{url}'."
|
|
91
|
+
)
|
|
80
92
|
return repository
|