@homericintelligence/athena-opencode 0.5.0 → 0.5.2
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 +33 -22
- package/skills/_plugin.json +1 -0
- package/skills/_support/docs/dependency-resolution.md +203 -0
- package/skills/_support/docs/policies/development.md +131 -0
- package/skills/_support/docs/policies/evidence-integrity.md +40 -0
- package/skills/_support/docs/principles/README.md +565 -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 +131 -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 +63 -0
- package/skills/_support/docs/review/behavior-first-testing.md +78 -0
- package/skills/_support/docs/review/common.md +400 -0
- package/skills/_support/docs/review/design-docs.md +74 -0
- package/skills/_support/docs/review/issue-planning.md +247 -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 +108 -74
- package/skills/advise/scripts/list_retrievable_skills.py +20 -8
- package/skills/advise/scripts/resolve_knowledge_checkout.py +533 -0
- package/skills/brainstorm/SKILL.md +163 -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 +144 -84
- package/skills/finalize-plan/SKILL.md +205 -139
- package/skills/git-worktrees/SKILL.md +100 -80
- package/skills/git-worktrees/scripts/prepare_worktree.py +34 -15
- package/skills/issue-review/SKILL.md +136 -79
- package/skills/learn/SKILL.md +359 -192
- package/skills/myrmidon-swarm/SKILL.md +126 -86
- package/skills/plan-issue/SKILL.md +122 -81
- package/skills/pr-review/SKILL.md +184 -116
- package/skills/pr-review/references/criteria.md +61 -37
- package/skills/pr-review/references/delivery.md +277 -101
- package/skills/pr-review/references/evidence.md +346 -149
- package/skills/pr-review/references/prevalidated.md +93 -61
- package/skills/pr-review/scripts/collect_evidence.py +312 -162
- package/skills/pr-review/scripts/deliver_go.py +701 -0
- package/skills/pr-review/scripts/diff_context.py +34 -15
- package/skills/pr-review/scripts/materialize_snapshot.py +141 -130
- package/skills/pr-review/scripts/pr_identity.py +27 -15
- package/skills/pr-review/scripts/resolve_pr.py +77 -41
- package/skills/realign/SKILL.md +516 -0
- package/skills/realign/references/aislop-integration.md +215 -0
- package/skills/realign/references/architecture-and-structure.md +271 -0
- package/skills/realign/references/control-flow-and-errors.md +344 -0
- package/skills/realign/references/tests-dependencies-and-security.md +261 -0
- package/skills/realign/scripts/resolve_assessment.py +1525 -0
- package/skills/repo-review/SKILL.md +202 -131
- package/skills/simplify/SKILL.md +174 -0
- package/skills/systematic-debugging/SKILL.md +212 -192
- package/skills/systematic-debugging/scripts/repository_evidence.py +26 -8
- package/skills/test-driven-development/SKILL.md +126 -96
- package/skills/tidy/SKILL.md +104 -59
- package/skills/tidy/scripts/run_tidy.py +54 -6
|
@@ -1,26 +1,43 @@
|
|
|
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
|
|
|
6
|
+
import importlib.util
|
|
6
7
|
import json
|
|
7
8
|
import sys
|
|
8
9
|
from collections.abc import Sequence
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
if __package__ in {None, ""}:
|
|
12
|
-
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
13
12
|
|
|
14
13
|
from pr_identity import require_commit_oid
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
if TYPE_CHECKING or __package__ not in {None, ""}:
|
|
16
|
+
from skills._cli import (
|
|
17
|
+
argument_parser,
|
|
18
|
+
git_read_arguments,
|
|
19
|
+
git_read_environment,
|
|
20
|
+
require_complete_git_history,
|
|
21
|
+
require_unambiguous_git_merge_base,
|
|
22
|
+
run_command,
|
|
23
|
+
)
|
|
24
|
+
else:
|
|
25
|
+
_cli_path = Path(__file__).resolve().parents[2] / "_cli.py"
|
|
26
|
+
_cli_spec = importlib.util.spec_from_file_location(
|
|
27
|
+
"athena_installed_cli", _cli_path
|
|
28
|
+
)
|
|
29
|
+
if _cli_spec is None or _cli_spec.loader is None:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
f"The installed Athena CLI helper is unavailable: '{_cli_path}'."
|
|
32
|
+
)
|
|
33
|
+
_cli = importlib.util.module_from_spec(_cli_spec)
|
|
34
|
+
_cli_spec.loader.exec_module(_cli)
|
|
35
|
+
argument_parser = _cli.argument_parser
|
|
36
|
+
git_read_arguments = _cli.git_read_arguments
|
|
37
|
+
git_read_environment = _cli.git_read_environment
|
|
38
|
+
require_complete_git_history = _cli.require_complete_git_history
|
|
39
|
+
require_unambiguous_git_merge_base = _cli.require_unambiguous_git_merge_base
|
|
40
|
+
run_command = _cli.run_command
|
|
24
41
|
|
|
25
42
|
|
|
26
43
|
def git(*arguments: str) -> str:
|
|
@@ -32,7 +49,9 @@ def git(*arguments: str) -> str:
|
|
|
32
49
|
check=False,
|
|
33
50
|
)
|
|
34
51
|
if result.returncode != 0:
|
|
35
|
-
raise RuntimeError(
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
result.stderr.strip() or f"The git {' '.join(arguments)} command failed."
|
|
54
|
+
)
|
|
36
55
|
return result.stdout.strip()
|
|
37
56
|
|
|
38
57
|
|
|
@@ -42,8 +61,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
42
61
|
parser.add_argument("head_ref", metavar="HEAD_OID")
|
|
43
62
|
arguments = parser.parse_args(argv)
|
|
44
63
|
try:
|
|
45
|
-
base_ref = require_commit_oid(arguments.base_ref, "base
|
|
46
|
-
head_ref = require_commit_oid(arguments.head_ref, "head
|
|
64
|
+
base_ref = require_commit_oid(arguments.base_ref, "base object identifier")
|
|
65
|
+
head_ref = require_commit_oid(arguments.head_ref, "head object identifier")
|
|
47
66
|
require_complete_git_history()
|
|
48
67
|
git("rev-parse", "--verify", f"{base_ref}^{{commit}}")
|
|
49
68
|
git("rev-parse", "--verify", f"{head_ref}^{{commit}}")
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import importlib.util
|
|
7
|
+
import inspect
|
|
6
8
|
import json
|
|
7
9
|
import shutil
|
|
8
10
|
import stat
|
|
@@ -12,26 +14,44 @@ import tempfile
|
|
|
12
14
|
from collections.abc import Sequence
|
|
13
15
|
from dataclasses import dataclass
|
|
14
16
|
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
if __package__ in {None, ""}:
|
|
17
|
-
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
18
|
|
|
19
19
|
from pr_identity import COMMIT_OID, require_commit_oid, require_github_repository
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
if TYPE_CHECKING or __package__ not in {None, ""}:
|
|
22
|
+
from skills._cli import (
|
|
23
|
+
argument_parser,
|
|
24
|
+
git_read_arguments,
|
|
25
|
+
git_read_environment,
|
|
26
|
+
run_command,
|
|
27
|
+
)
|
|
28
|
+
else:
|
|
29
|
+
_cli_path = Path(__file__).resolve().parents[2] / "_cli.py"
|
|
30
|
+
_cli_spec = importlib.util.spec_from_file_location(
|
|
31
|
+
"athena_installed_cli", _cli_path
|
|
32
|
+
)
|
|
33
|
+
if _cli_spec is None or _cli_spec.loader is None:
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
f"The installed Athena CLI helper is unavailable: '{_cli_path}'."
|
|
36
|
+
)
|
|
37
|
+
_cli = importlib.util.module_from_spec(_cli_spec)
|
|
38
|
+
_cli_spec.loader.exec_module(_cli)
|
|
39
|
+
argument_parser = _cli.argument_parser
|
|
40
|
+
git_read_arguments = _cli.git_read_arguments
|
|
41
|
+
git_read_environment = _cli.git_read_environment
|
|
42
|
+
run_command = _cli.run_command
|
|
27
43
|
|
|
28
44
|
SNAPSHOT_COMMAND_TIMEOUT_SECONDS = 30.0
|
|
29
45
|
BOUNDED_MATERIALIZE_TIMEOUT_SECONDS = 600.0
|
|
46
|
+
MATERIALIZE_ERROR = "The helper cannot materialize the immutable pull-request snapshot."
|
|
47
|
+
QUOTA_ERROR = "The host cannot enforce the immutable pull-request snapshot size limit."
|
|
48
|
+
REMOVE_ERROR = "The helper cannot remove the immutable pull-request snapshot."
|
|
49
|
+
UNMANAGED_ROOT_ERROR = "The helper does not permit snapshot operations outside the managed temporary directory."
|
|
30
50
|
|
|
31
51
|
|
|
32
52
|
@dataclass(frozen=True)
|
|
33
53
|
class MaterializedSnapshot:
|
|
34
|
-
"""
|
|
54
|
+
"""This record binds a detached source tree to one reviewed GitHub pull request."""
|
|
35
55
|
|
|
36
56
|
root: Path
|
|
37
57
|
source_path: Path
|
|
@@ -78,11 +98,9 @@ def _git(
|
|
|
78
98
|
["git", *git_read_arguments(), *arguments], **command_options
|
|
79
99
|
)
|
|
80
100
|
except subprocess.SubprocessError as error:
|
|
81
|
-
raise RuntimeError(
|
|
82
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
83
|
-
) from error
|
|
101
|
+
raise RuntimeError(MATERIALIZE_ERROR) from error
|
|
84
102
|
if result.returncode not in accepted_codes:
|
|
85
|
-
raise RuntimeError(
|
|
103
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
86
104
|
return result.stdout.strip() if isinstance(result.stdout, str) else ""
|
|
87
105
|
|
|
88
106
|
|
|
@@ -97,32 +115,31 @@ def _hdiutil(*arguments: str) -> None:
|
|
|
97
115
|
check=False,
|
|
98
116
|
)
|
|
99
117
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
100
|
-
raise RuntimeError(
|
|
101
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
102
|
-
) from error
|
|
118
|
+
raise RuntimeError(QUOTA_ERROR) from error
|
|
103
119
|
if result.returncode != 0:
|
|
104
|
-
raise RuntimeError(
|
|
105
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
106
|
-
)
|
|
120
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
107
121
|
|
|
108
122
|
|
|
109
123
|
def _mount_tmpfs(source: Path, maximum_bytes: int) -> bool:
|
|
110
124
|
"""Mount a Linux tmpfs whose total capacity is the snapshot quota.
|
|
111
125
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
bounded user
|
|
126
|
+
Return True only when the mount enforces the quota. Do not use
|
|
127
|
+
``source.is_mount()`` elsewhere to infer this result. If the host cannot
|
|
128
|
+
find or run ``mount``, return False. If the host rejects the mount, return
|
|
129
|
+
False. The caller then uses the bounded user and mount namespace or fails
|
|
130
|
+
closed.
|
|
116
131
|
"""
|
|
117
132
|
if sys.platform != "linux" or shutil.which("mount") is None:
|
|
118
133
|
return False
|
|
119
134
|
maximum_kibibytes = maximum_bytes // 1024
|
|
120
135
|
if maximum_kibibytes < 1:
|
|
121
|
-
raise RuntimeError(
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"The immutable pull-request snapshot has no usable disk space."
|
|
138
|
+
)
|
|
122
139
|
try:
|
|
123
140
|
source.mkdir()
|
|
124
141
|
except FileExistsError:
|
|
125
|
-
#
|
|
142
|
+
# If a prior materialization created the directory, use it again.
|
|
126
143
|
pass
|
|
127
144
|
except OSError:
|
|
128
145
|
return False
|
|
@@ -150,15 +167,17 @@ def _mount_tmpfs(source: Path, maximum_bytes: int) -> bool:
|
|
|
150
167
|
def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
151
168
|
"""Return a bounded source directory, or None for the Linux bounded fallback.
|
|
152
169
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
170
|
+
On macOS, attach a sparse HFS+ volume whose total capacity is the quota.
|
|
171
|
+
On Linux, mount a tmpfs when the host has the required authority. Otherwise,
|
|
172
|
+
return None so the caller can materialize the snapshot inside a bounded user
|
|
173
|
+
and mount namespace. On other systems, fail closed because the host cannot
|
|
174
|
+
enforce the snapshot size limit.
|
|
158
175
|
"""
|
|
159
176
|
maximum_kibibytes = maximum_bytes // 1024
|
|
160
177
|
if maximum_kibibytes < 1:
|
|
161
|
-
raise RuntimeError(
|
|
178
|
+
raise RuntimeError(
|
|
179
|
+
"The immutable pull-request snapshot has no usable disk space."
|
|
180
|
+
)
|
|
162
181
|
if sys.platform == "darwin":
|
|
163
182
|
image = root / "snapshot.sparseimage"
|
|
164
183
|
source = root / "source"
|
|
@@ -173,7 +192,7 @@ def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
|
173
192
|
"-fs",
|
|
174
193
|
"Case-sensitive HFS+",
|
|
175
194
|
"-volname",
|
|
176
|
-
"Athena
|
|
195
|
+
"Athena pull-request review",
|
|
177
196
|
"-nospotlight",
|
|
178
197
|
str(image),
|
|
179
198
|
)
|
|
@@ -186,9 +205,7 @@ def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
|
186
205
|
str(image),
|
|
187
206
|
)
|
|
188
207
|
if not source.is_mount():
|
|
189
|
-
raise RuntimeError(
|
|
190
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
191
|
-
)
|
|
208
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
192
209
|
return source
|
|
193
210
|
if sys.platform.startswith("linux"):
|
|
194
211
|
source = root / "source"
|
|
@@ -197,13 +214,11 @@ def _create_quota_volume(root: Path, maximum_bytes: int) -> Path | None:
|
|
|
197
214
|
try:
|
|
198
215
|
source.rmdir()
|
|
199
216
|
except OSError:
|
|
200
|
-
# The mount
|
|
201
|
-
# rmdir
|
|
217
|
+
# The mount can create the directory before it fails.
|
|
218
|
+
# If rmdir fails, the caller uses None to select the other method.
|
|
202
219
|
pass
|
|
203
220
|
return None
|
|
204
|
-
raise RuntimeError(
|
|
205
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
206
|
-
)
|
|
221
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
207
222
|
|
|
208
223
|
|
|
209
224
|
def _detach_volume(source: Path) -> None:
|
|
@@ -229,41 +244,38 @@ def _detach_volume(source: Path) -> None:
|
|
|
229
244
|
check=False,
|
|
230
245
|
)
|
|
231
246
|
if result.returncode != 0:
|
|
232
|
-
raise RuntimeError(
|
|
247
|
+
raise RuntimeError(REMOVE_ERROR)
|
|
233
248
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
234
|
-
raise RuntimeError(
|
|
235
|
-
"cannot remove the immutable pull-request snapshot"
|
|
236
|
-
) from error
|
|
249
|
+
raise RuntimeError(REMOVE_ERROR) from error
|
|
237
250
|
return
|
|
238
|
-
raise RuntimeError(
|
|
251
|
+
raise RuntimeError(REMOVE_ERROR)
|
|
239
252
|
|
|
240
253
|
|
|
241
254
|
def _detach_best_effort(source: Path) -> None:
|
|
242
|
-
"""Detach
|
|
255
|
+
"""Detach the quota volume. Do not let a detach failure hide an earlier failure."""
|
|
243
256
|
try:
|
|
244
257
|
_detach_volume(source)
|
|
245
258
|
except RuntimeError:
|
|
246
|
-
#
|
|
247
|
-
# secondary cleanup error.
|
|
259
|
+
# Do not let this cleanup error hide an earlier failure.
|
|
248
260
|
pass
|
|
249
261
|
|
|
250
262
|
|
|
251
263
|
def _require_base_ref(base_ref: str) -> str:
|
|
252
264
|
"""Validate the GitHub base branch before it becomes a fetch refspec."""
|
|
253
265
|
if not base_ref or base_ref.startswith("-") or ".." in base_ref:
|
|
254
|
-
raise RuntimeError("GitHub returned
|
|
266
|
+
raise RuntimeError("GitHub returned a pull-request base ref that is not valid.")
|
|
255
267
|
_git("check-ref-format", "--branch", base_ref)
|
|
256
268
|
return base_ref
|
|
257
269
|
|
|
258
270
|
|
|
259
271
|
def _repository_size(path: Path, *, maximum_bytes: int | None = None) -> int:
|
|
260
|
-
"""Return the
|
|
272
|
+
"""Return the repository size and enforce the applicable size limit."""
|
|
261
273
|
if maximum_bytes is None:
|
|
262
274
|
try:
|
|
263
275
|
maximum_bytes = (shutil.disk_usage(path).free * 9) // 10
|
|
264
276
|
except OSError as error:
|
|
265
277
|
raise RuntimeError(
|
|
266
|
-
"cannot inspect the immutable pull-request snapshot"
|
|
278
|
+
"The helper cannot inspect the immutable pull-request snapshot."
|
|
267
279
|
) from error
|
|
268
280
|
total = 0
|
|
269
281
|
for entry in path.rglob("*"):
|
|
@@ -271,13 +283,13 @@ def _repository_size(path: Path, *, maximum_bytes: int | None = None) -> int:
|
|
|
271
283
|
details = entry.lstat()
|
|
272
284
|
except OSError as error:
|
|
273
285
|
raise RuntimeError(
|
|
274
|
-
"cannot inspect the immutable pull-request snapshot"
|
|
286
|
+
"The helper cannot inspect the immutable pull-request snapshot."
|
|
275
287
|
) from error
|
|
276
288
|
if stat.S_ISREG(details.st_mode):
|
|
277
289
|
total += details.st_size
|
|
278
290
|
if total > maximum_bytes:
|
|
279
291
|
raise RuntimeError(
|
|
280
|
-
"immutable pull-request snapshot exceeds the safe size limit"
|
|
292
|
+
"The immutable pull-request snapshot exceeds the safe size limit."
|
|
281
293
|
)
|
|
282
294
|
return total
|
|
283
295
|
|
|
@@ -296,7 +308,7 @@ def _verify_no_promisor_configuration(repository: Path) -> None:
|
|
|
296
308
|
)
|
|
297
309
|
if value:
|
|
298
310
|
raise RuntimeError(
|
|
299
|
-
"immutable pull-request snapshot must not use partial
|
|
311
|
+
"The immutable pull-request snapshot must not use partial-clone configuration."
|
|
300
312
|
)
|
|
301
313
|
promisor = _git(
|
|
302
314
|
"config",
|
|
@@ -309,12 +321,12 @@ def _verify_no_promisor_configuration(repository: Path) -> None:
|
|
|
309
321
|
)
|
|
310
322
|
if promisor:
|
|
311
323
|
raise RuntimeError(
|
|
312
|
-
"immutable pull-request snapshot must not use promisor configuration"
|
|
324
|
+
"The immutable pull-request snapshot must not use promisor configuration."
|
|
313
325
|
)
|
|
314
326
|
|
|
315
327
|
|
|
316
328
|
def _require_commit(repository: Path, revision: str, label: str) -> str:
|
|
317
|
-
"""Verify one fetched ref resolves
|
|
329
|
+
"""Verify that one fetched ref resolves to its captured commit object identifier."""
|
|
318
330
|
resolved = _git(
|
|
319
331
|
"rev-parse",
|
|
320
332
|
"--verify",
|
|
@@ -326,7 +338,7 @@ def _require_commit(repository: Path, revision: str, label: str) -> str:
|
|
|
326
338
|
|
|
327
339
|
|
|
328
340
|
def _make_read_only(root: Path) -> None:
|
|
329
|
-
"""Remove write bits from the completed snapshot without following
|
|
341
|
+
"""Remove write bits from the completed snapshot without following symbolic links."""
|
|
330
342
|
entries = sorted(root.rglob("*"), key=lambda entry: len(entry.parts), reverse=True)
|
|
331
343
|
for entry in entries:
|
|
332
344
|
if entry.is_symlink():
|
|
@@ -339,7 +351,7 @@ def _make_read_only(root: Path) -> None:
|
|
|
339
351
|
entry.chmod(0o555 if mode & stat.S_IXUSR else 0o444)
|
|
340
352
|
except OSError as error:
|
|
341
353
|
raise RuntimeError(
|
|
342
|
-
"cannot make the immutable pull-request snapshot read-only"
|
|
354
|
+
"The helper cannot make the immutable pull-request snapshot read-only."
|
|
343
355
|
) from error
|
|
344
356
|
root.chmod(0o555)
|
|
345
357
|
|
|
@@ -356,11 +368,11 @@ def _acquire_into(
|
|
|
356
368
|
template: Path,
|
|
357
369
|
maximum_bytes: int,
|
|
358
370
|
) -> tuple[str, str]:
|
|
359
|
-
"""Fetch only the captured base branch and
|
|
371
|
+
"""Fetch only the captured base branch and pull-request head.
|
|
360
372
|
|
|
361
|
-
The
|
|
362
|
-
|
|
363
|
-
|
|
373
|
+
The caller provides a source directory in a quota volume or bounded tmpfs.
|
|
374
|
+
Verify each immutable binding against the captured object identifiers.
|
|
375
|
+
Then, return ``(merge_base, tree_oid)``.
|
|
364
376
|
"""
|
|
365
377
|
_git(
|
|
366
378
|
"-c",
|
|
@@ -405,15 +417,26 @@ def _acquire_into(
|
|
|
405
417
|
_git("rev-parse", "--is-shallow-repository", cwd=source, capture_output=True)
|
|
406
418
|
!= "false"
|
|
407
419
|
):
|
|
408
|
-
raise RuntimeError(
|
|
409
|
-
|
|
410
|
-
|
|
420
|
+
raise RuntimeError(
|
|
421
|
+
"The immutable pull-request snapshot requires complete history."
|
|
422
|
+
)
|
|
423
|
+
if (
|
|
424
|
+
_require_commit(source, "refs/athena/base", "fetched base object identifier")
|
|
425
|
+
!= base_oid
|
|
426
|
+
):
|
|
427
|
+
raise RuntimeError(
|
|
428
|
+
"The fetched base ref does not match the captured base object identifier."
|
|
429
|
+
)
|
|
411
430
|
if (
|
|
412
|
-
_require_commit(
|
|
431
|
+
_require_commit(
|
|
432
|
+
source,
|
|
433
|
+
f"refs/athena/pr/{number}/head",
|
|
434
|
+
"fetched head object identifier",
|
|
435
|
+
)
|
|
413
436
|
!= head_oid
|
|
414
437
|
):
|
|
415
438
|
raise RuntimeError(
|
|
416
|
-
"fetched pull-request ref does not match the captured head
|
|
439
|
+
"The fetched pull-request ref does not match the captured head object identifier."
|
|
417
440
|
)
|
|
418
441
|
merge_bases = _git(
|
|
419
442
|
"merge-base",
|
|
@@ -425,15 +448,15 @@ def _acquire_into(
|
|
|
425
448
|
).splitlines()
|
|
426
449
|
if len(merge_bases) != 1:
|
|
427
450
|
raise RuntimeError(
|
|
428
|
-
"immutable pull-request snapshot requires one unambiguous merge base"
|
|
451
|
+
"The immutable pull-request snapshot requires one unambiguous merge base."
|
|
429
452
|
)
|
|
430
453
|
merge_base = require_commit_oid(merge_bases[0], "immutable merge base")
|
|
431
|
-
tree_oid = _require_commit(source, head_oid, "fetched head
|
|
454
|
+
tree_oid = _require_commit(source, head_oid, "fetched head object identifier")
|
|
432
455
|
tree_oid = _git(
|
|
433
456
|
"rev-parse", f"{tree_oid}^{{tree}}", cwd=source, capture_output=True
|
|
434
457
|
)
|
|
435
458
|
if COMMIT_OID.fullmatch(tree_oid) is None:
|
|
436
|
-
raise RuntimeError("Git returned an
|
|
459
|
+
raise RuntimeError("Git returned an immutable head tree that is not valid.")
|
|
437
460
|
_git(
|
|
438
461
|
"-c",
|
|
439
462
|
f"core.hooksPath={hooks}",
|
|
@@ -450,15 +473,16 @@ def _acquire_into(
|
|
|
450
473
|
|
|
451
474
|
|
|
452
475
|
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
|
|
476
|
+
"""Materialize inside a bounded user and mount namespace.
|
|
477
|
+
|
|
478
|
+
This helper runs as the child of ``unshare -rm`` on Linux. The helper mounts
|
|
479
|
+
a tmpfs whose total capacity is the snapshot quota. It materializes the
|
|
480
|
+
snapshot in the size-limited file system. Then, it copies the verified
|
|
481
|
+
read-only tree to a path that the host can read. Exit 2 means that the helper
|
|
482
|
+
could not establish the quota boundary. Exit 1 means that materialization
|
|
483
|
+
failed.
|
|
460
484
|
"""
|
|
461
|
-
parser = argument_parser(description="
|
|
485
|
+
parser = argument_parser(description="Materialize a bounded pull-request snapshot.")
|
|
462
486
|
parser.add_argument("--root", required=True, metavar="ROOT")
|
|
463
487
|
parser.add_argument("--repository-url", required=True, metavar="URL")
|
|
464
488
|
parser.add_argument("--pr-number", required=True, type=int, metavar="NUMBER")
|
|
@@ -470,22 +494,20 @@ def _bounded_materialize_main(arguments: Sequence[str]) -> int:
|
|
|
470
494
|
root = Path(parsed.root).resolve()
|
|
471
495
|
temporary_root = Path(tempfile.gettempdir()).resolve()
|
|
472
496
|
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
|
-
)
|
|
497
|
+
print(UNMANAGED_ROOT_ERROR, file=sys.stderr)
|
|
477
498
|
return 1
|
|
478
499
|
try:
|
|
479
|
-
canonical_base = require_commit_oid(
|
|
480
|
-
|
|
500
|
+
canonical_base = require_commit_oid(
|
|
501
|
+
parsed.base_oid, "captured base object identifier"
|
|
502
|
+
)
|
|
503
|
+
canonical_head = require_commit_oid(
|
|
504
|
+
parsed.head_oid, "captured head object identifier"
|
|
505
|
+
)
|
|
481
506
|
canonical_base_ref = _require_base_ref(parsed.base_ref)
|
|
482
507
|
bounded = root / "bounded"
|
|
483
508
|
bounded.mkdir()
|
|
484
509
|
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
|
-
)
|
|
510
|
+
print(QUOTA_ERROR, file=sys.stderr)
|
|
489
511
|
return 2
|
|
490
512
|
merge_base, tree_oid = _acquire_into(
|
|
491
513
|
bounded,
|
|
@@ -528,20 +550,18 @@ def _bounded_materialize(
|
|
|
528
550
|
base_oid: str,
|
|
529
551
|
head_oid: str,
|
|
530
552
|
) -> MaterializedSnapshot:
|
|
531
|
-
"""Materialize a snapshot inside a bounded Linux user
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
553
|
+
"""Materialize a snapshot inside a bounded Linux user and mount namespace.
|
|
554
|
+
|
|
555
|
+
Run this helper under ``unshare -rm``. The child has root authority only in
|
|
556
|
+
the new namespace. The child mounts a tmpfs whose total capacity is the
|
|
557
|
+
snapshot quota. The file system bounds each fetch and checkout write. Before
|
|
558
|
+
the namespace exits, the child copies the verified tree to a read-only path
|
|
559
|
+
that the host can read. If ``unshare`` or a tmpfs mount is not available,
|
|
560
|
+
the helper fails closed.
|
|
539
561
|
"""
|
|
540
562
|
unshare = shutil.which("unshare")
|
|
541
563
|
if unshare is None:
|
|
542
|
-
raise RuntimeError(
|
|
543
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
544
|
-
)
|
|
564
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
545
565
|
command = [
|
|
546
566
|
unshare,
|
|
547
567
|
"-rm",
|
|
@@ -573,26 +593,20 @@ def _bounded_materialize(
|
|
|
573
593
|
timeout=BOUNDED_MATERIALIZE_TIMEOUT_SECONDS,
|
|
574
594
|
)
|
|
575
595
|
except subprocess.TimeoutExpired as error:
|
|
576
|
-
raise RuntimeError(
|
|
577
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
578
|
-
) from error
|
|
596
|
+
raise RuntimeError(MATERIALIZE_ERROR) from error
|
|
579
597
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
|
580
|
-
raise RuntimeError(
|
|
581
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
582
|
-
) from error
|
|
598
|
+
raise RuntimeError(QUOTA_ERROR) from error
|
|
583
599
|
if result.returncode == 2:
|
|
584
|
-
raise RuntimeError(
|
|
585
|
-
"host cannot enforce the immutable pull-request snapshot size limit"
|
|
586
|
-
)
|
|
600
|
+
raise RuntimeError(QUOTA_ERROR)
|
|
587
601
|
if result.returncode != 0:
|
|
588
|
-
raise RuntimeError(
|
|
602
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
589
603
|
try:
|
|
590
604
|
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
591
605
|
if not lines:
|
|
592
|
-
raise RuntimeError(
|
|
606
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
593
607
|
record = json.loads(lines[-1])
|
|
594
608
|
if not isinstance(record, dict):
|
|
595
|
-
raise TypeError(
|
|
609
|
+
raise TypeError(MATERIALIZE_ERROR)
|
|
596
610
|
source_path = Path(str(record["source_path"]))
|
|
597
611
|
merge_base = require_commit_oid(record["merge_base"], "immutable merge base")
|
|
598
612
|
tree_oid = require_commit_oid(record["tree_oid"], "immutable head tree")
|
|
@@ -603,11 +617,9 @@ def _bounded_materialize(
|
|
|
603
617
|
json.JSONDecodeError,
|
|
604
618
|
RuntimeError,
|
|
605
619
|
) as error:
|
|
606
|
-
raise RuntimeError(
|
|
607
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
608
|
-
) from error
|
|
620
|
+
raise RuntimeError(MATERIALIZE_ERROR) from error
|
|
609
621
|
if source_path != root / "source" or not source_path.is_dir():
|
|
610
|
-
raise RuntimeError(
|
|
622
|
+
raise RuntimeError(MATERIALIZE_ERROR)
|
|
611
623
|
return MaterializedSnapshot(
|
|
612
624
|
root=root, source_path=source_path, merge_base=merge_base, tree_oid=tree_oid
|
|
613
625
|
)
|
|
@@ -616,12 +628,12 @@ def _bounded_materialize(
|
|
|
616
628
|
def materialize_snapshot(
|
|
617
629
|
*, repository: str, number: int, base_ref: str, base_oid: str, head_oid: str
|
|
618
630
|
) -> MaterializedSnapshot:
|
|
619
|
-
"""
|
|
631
|
+
"""Materialize a captured base branch and pull-request head in a new repository."""
|
|
620
632
|
canonical_repository = require_github_repository(repository, "GitHub repository")
|
|
621
633
|
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
|
|
634
|
+
raise RuntimeError("The pull-request number must be positive.")
|
|
635
|
+
canonical_base = require_commit_oid(base_oid, "captured base object identifier")
|
|
636
|
+
canonical_head = require_commit_oid(head_oid, "captured head object identifier")
|
|
625
637
|
canonical_base_ref = _require_base_ref(base_ref)
|
|
626
638
|
root = Path(tempfile.mkdtemp(prefix="athena-pr-review-"))
|
|
627
639
|
template = root / "empty-template"
|
|
@@ -632,7 +644,7 @@ def materialize_snapshot(
|
|
|
632
644
|
maximum_snapshot_bytes = (shutil.disk_usage(root).free * 9) // 10
|
|
633
645
|
if maximum_snapshot_bytes < 1:
|
|
634
646
|
raise RuntimeError(
|
|
635
|
-
"immutable pull-request snapshot has no usable disk space"
|
|
647
|
+
"The immutable pull-request snapshot has no usable disk space."
|
|
636
648
|
)
|
|
637
649
|
source = _create_quota_volume(root, maximum_snapshot_bytes)
|
|
638
650
|
if source is None:
|
|
@@ -659,9 +671,7 @@ def materialize_snapshot(
|
|
|
659
671
|
_make_read_only(root)
|
|
660
672
|
except (OSError, subprocess.TimeoutExpired):
|
|
661
673
|
remove_snapshot(root)
|
|
662
|
-
raise RuntimeError(
|
|
663
|
-
"cannot materialize the immutable pull-request snapshot"
|
|
664
|
-
) from None
|
|
674
|
+
raise RuntimeError(MATERIALIZE_ERROR) from None
|
|
665
675
|
except BaseException:
|
|
666
676
|
remove_snapshot(root)
|
|
667
677
|
raise
|
|
@@ -671,15 +681,13 @@ def materialize_snapshot(
|
|
|
671
681
|
|
|
672
682
|
|
|
673
683
|
def remove_snapshot(root: Path) -> None:
|
|
674
|
-
"""
|
|
684
|
+
"""After host inspection ends, remove a snapshot that this helper materialized."""
|
|
675
685
|
resolved = root.resolve()
|
|
676
686
|
temporary_root = Path(tempfile.gettempdir()).resolve()
|
|
677
687
|
if resolved.parent != temporary_root or not resolved.name.startswith(
|
|
678
688
|
"athena-pr-review-"
|
|
679
689
|
):
|
|
680
|
-
raise RuntimeError(
|
|
681
|
-
"refusing to remove a snapshot outside the managed temporary directory"
|
|
682
|
-
)
|
|
690
|
+
raise RuntimeError(UNMANAGED_ROOT_ERROR)
|
|
683
691
|
source = resolved / "source"
|
|
684
692
|
if source.is_mount():
|
|
685
693
|
_detach_volume(source)
|
|
@@ -690,10 +698,13 @@ def remove_snapshot(root: Path) -> None:
|
|
|
690
698
|
if candidate.exists() and not candidate.is_symlink():
|
|
691
699
|
candidate.chmod(0o700)
|
|
692
700
|
if not callable(function):
|
|
693
|
-
raise TypeError(
|
|
701
|
+
raise TypeError(REMOVE_ERROR)
|
|
694
702
|
function(path)
|
|
695
703
|
|
|
696
|
-
shutil.rmtree
|
|
704
|
+
if "onexc" in inspect.signature(shutil.rmtree).parameters:
|
|
705
|
+
shutil.rmtree(resolved, onexc=make_removable)
|
|
706
|
+
return
|
|
707
|
+
shutil.rmtree(resolved, onerror=make_removable)
|
|
697
708
|
|
|
698
709
|
|
|
699
710
|
def main(argv: Sequence[str] | None = None) -> int:
|