ai-push-hooks 0.1.18 → 0.2.0
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/CHANGELOG.md +63 -0
- package/README.md +312 -41
- package/SECURITY.md +35 -0
- package/ai-push-hooks.toml +2 -1
- package/bin/ai-push-hooks.js +20 -5
- package/package.json +26 -4
- package/pyproject.toml +12 -4
- package/src/ai_push_hooks/artifacts.py +67 -11
- package/src/ai_push_hooks/cli.py +60 -3
- package/src/ai_push_hooks/config.py +284 -97
- package/src/ai_push_hooks/engine.py +0 -2
- package/src/ai_push_hooks/executors/apply.py +850 -29
- package/src/ai_push_hooks/executors/exec.py +735 -112
- package/src/ai_push_hooks/executors/llm.py +369 -42
- package/src/ai_push_hooks/hook.py +131 -21
- package/src/ai_push_hooks/install.py +205 -0
- package/src/ai_push_hooks/modules/beads.py +21 -6
- package/src/ai_push_hooks/modules/docs.py +147 -27
- package/src/ai_push_hooks/modules/pr.py +55 -8
- package/src/ai_push_hooks/paths.py +182 -0
- package/src/ai_push_hooks/prompts_builtin.py +7 -2
- package/src/ai_push_hooks/types.py +83 -3
package/pyproject.toml
CHANGED
|
@@ -4,23 +4,31 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ai-push-hooks"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Run structured AI-assisted checks and allowlisted maintenance before git push"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
12
|
-
authors = [{ name = "
|
|
12
|
+
authors = [{ name = "Shane Bishop" }]
|
|
13
13
|
keywords = ["git", "lefthook", "docs", "ai", "pre-push"]
|
|
14
14
|
classifiers = [
|
|
15
|
-
"Development Status ::
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
18
|
"Programming Language :: Python :: 3 :: Only",
|
|
19
19
|
"Programming Language :: Python :: 3.10",
|
|
20
20
|
"Programming Language :: Python :: 3.11",
|
|
21
21
|
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
22
23
|
"Topic :: Software Development :: Version Control :: Git",
|
|
23
24
|
]
|
|
25
|
+
dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/shanebishop1/ai-push-hooks"
|
|
29
|
+
Repository = "https://github.com/shanebishop1/ai-push-hooks.git"
|
|
30
|
+
Issues = "https://github.com/shanebishop1/ai-push-hooks/issues"
|
|
31
|
+
Changelog = "https://github.com/shanebishop1/ai-push-hooks/blob/main/CHANGELOG.md"
|
|
24
32
|
|
|
25
33
|
[project.scripts]
|
|
26
34
|
ai-push-hooks = "ai_push_hooks.cli:main"
|
|
@@ -6,6 +6,15 @@ from datetime import datetime, timezone
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
from uuid import uuid4
|
|
8
8
|
|
|
9
|
+
from .paths import (
|
|
10
|
+
ensure_private_directory,
|
|
11
|
+
is_path_within,
|
|
12
|
+
path_has_symlink,
|
|
13
|
+
path_is_link_or_reparse,
|
|
14
|
+
resolve_contained_path,
|
|
15
|
+
validate_path_component,
|
|
16
|
+
write_text_no_follow,
|
|
17
|
+
)
|
|
9
18
|
from .types import HookError, ModuleRuntimeState
|
|
10
19
|
|
|
11
20
|
|
|
@@ -19,13 +28,42 @@ class ArtifactStore:
|
|
|
19
28
|
self.run_dir = run_dir
|
|
20
29
|
|
|
21
30
|
def prepare(self) -> pathlib.Path:
|
|
22
|
-
self.run_dir
|
|
23
|
-
|
|
31
|
+
if path_is_link_or_reparse(self.run_dir):
|
|
32
|
+
raise HookError(
|
|
33
|
+
f"Artifact run directory must not be a symlink or reparse point: {self.run_dir}"
|
|
34
|
+
)
|
|
35
|
+
return ensure_private_directory(self.run_dir)
|
|
24
36
|
|
|
25
37
|
def step_dir(self, module_id: str, step_index: int, step_id: str) -> pathlib.Path:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
module_name = validate_path_component(module_id, "Artifact module id")
|
|
39
|
+
step_name = validate_path_component(step_id, "Artifact step id")
|
|
40
|
+
lexical_module_path = self.run_dir / module_name
|
|
41
|
+
if path_has_symlink(self.run_dir, lexical_module_path):
|
|
42
|
+
raise HookError(f"Artifact module path must not traverse a symlink: {module_id}")
|
|
43
|
+
module_path = resolve_contained_path(self.run_dir, module_name, "Artifact module path")
|
|
44
|
+
lexical_step_path = module_path / f"{step_index:02d}-{step_name}"
|
|
45
|
+
if path_has_symlink(self.run_dir, lexical_step_path):
|
|
46
|
+
raise HookError(f"Artifact step path must not traverse a symlink: {step_id}")
|
|
47
|
+
path = resolve_contained_path(
|
|
48
|
+
module_path,
|
|
49
|
+
f"{step_index:02d}-{step_name}",
|
|
50
|
+
"Artifact step path",
|
|
51
|
+
)
|
|
52
|
+
return ensure_private_directory(path)
|
|
53
|
+
|
|
54
|
+
def _artifact_path(
|
|
55
|
+
self,
|
|
56
|
+
module_id: str,
|
|
57
|
+
step_index: int,
|
|
58
|
+
step_id: str,
|
|
59
|
+
artifact_name: str,
|
|
60
|
+
) -> pathlib.Path:
|
|
61
|
+
name = validate_path_component(artifact_name, "Artifact name")
|
|
62
|
+
return resolve_contained_path(
|
|
63
|
+
self.step_dir(module_id, step_index, step_id),
|
|
64
|
+
name,
|
|
65
|
+
"Artifact output path",
|
|
66
|
+
)
|
|
29
67
|
|
|
30
68
|
def register(
|
|
31
69
|
self,
|
|
@@ -34,6 +72,13 @@ class ArtifactStore:
|
|
|
34
72
|
artifact_name: str,
|
|
35
73
|
path: pathlib.Path,
|
|
36
74
|
) -> pathlib.Path:
|
|
75
|
+
validate_path_component(step_id, "Artifact step id")
|
|
76
|
+
validate_path_component(artifact_name, "Artifact name")
|
|
77
|
+
resolved_run_dir = self.run_dir.resolve(strict=False)
|
|
78
|
+
if path_has_symlink(self.run_dir, path):
|
|
79
|
+
raise HookError(f"Artifact path must not traverse a symlink: {path}")
|
|
80
|
+
if not is_path_within(path.resolve(strict=False), resolved_run_dir):
|
|
81
|
+
raise HookError(f"Artifact path escapes run directory: {path}")
|
|
37
82
|
state.artifacts[f"{step_id}/{artifact_name}"] = path
|
|
38
83
|
return path
|
|
39
84
|
|
|
@@ -45,8 +90,8 @@ class ArtifactStore:
|
|
|
45
90
|
artifact_name: str,
|
|
46
91
|
content: str,
|
|
47
92
|
) -> pathlib.Path:
|
|
48
|
-
path = self.
|
|
49
|
-
path
|
|
93
|
+
path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
|
|
94
|
+
write_text_no_follow(path, content)
|
|
50
95
|
return self.register(state, step_id, artifact_name, path)
|
|
51
96
|
|
|
52
97
|
def write_json(
|
|
@@ -57,14 +102,17 @@ class ArtifactStore:
|
|
|
57
102
|
artifact_name: str,
|
|
58
103
|
payload: Any,
|
|
59
104
|
) -> pathlib.Path:
|
|
60
|
-
path = self.
|
|
61
|
-
path
|
|
105
|
+
path = self._artifact_path(state.module.id, step_index, step_id, artifact_name)
|
|
106
|
+
write_text_no_follow(path, json.dumps(payload, ensure_ascii=True, indent=2) + "\n")
|
|
62
107
|
return self.register(state, step_id, artifact_name, path)
|
|
63
108
|
|
|
64
109
|
def resolve_input(self, state: ModuleRuntimeState, reference: str) -> pathlib.Path:
|
|
65
110
|
if ":" in reference:
|
|
66
|
-
|
|
67
|
-
|
|
111
|
+
try:
|
|
112
|
+
module_and_step, artifact_name = reference.split("/", 1)
|
|
113
|
+
module_id, step_id = module_and_step.split(":", 1)
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
raise HookError(f"Invalid artifact reference: {reference}") from exc
|
|
68
116
|
key = f"{module_id}:{step_id}/{artifact_name}"
|
|
69
117
|
else:
|
|
70
118
|
key = reference
|
|
@@ -72,6 +120,11 @@ class ArtifactStore:
|
|
|
72
120
|
if path is None:
|
|
73
121
|
path = state.artifacts.get(reference)
|
|
74
122
|
if path is None:
|
|
123
|
+
if ":" in reference:
|
|
124
|
+
raise HookError(
|
|
125
|
+
f"Unknown artifact reference: {reference}. Artifact references are module-local; "
|
|
126
|
+
"use '<step>/<artifact>' from an earlier step in the same module."
|
|
127
|
+
)
|
|
75
128
|
raise HookError(f"Unknown artifact reference: {reference}")
|
|
76
129
|
return path
|
|
77
130
|
|
|
@@ -83,4 +136,7 @@ class ArtifactStore:
|
|
|
83
136
|
artifact_name: str,
|
|
84
137
|
path: pathlib.Path,
|
|
85
138
|
) -> None:
|
|
139
|
+
validate_path_component(module_id, "Artifact module id")
|
|
140
|
+
validate_path_component(step_id, "Artifact step id")
|
|
141
|
+
validate_path_component(artifact_name, "Artifact name")
|
|
86
142
|
state.artifacts[f"{module_id}:{step_id}/{artifact_name}"] = path
|
package/src/ai_push_hooks/cli.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import os
|
|
4
5
|
import pathlib
|
|
6
|
+
import stat
|
|
5
7
|
import sys
|
|
6
8
|
|
|
7
9
|
from .hook import run_hook
|
|
10
|
+
from .install import install_hook
|
|
11
|
+
from .paths import path_is_link_or_reparse, write_text_no_follow
|
|
8
12
|
from .prompts_builtin import MINIMAL_DOCS_TEMPLATE
|
|
9
13
|
from .types import HookError
|
|
10
14
|
|
|
@@ -20,6 +24,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
20
24
|
init_parser = subparsers.add_parser("init", help="Write a starter config")
|
|
21
25
|
init_parser.add_argument("--template", default="minimal-docs")
|
|
22
26
|
init_parser.add_argument("--force", action="store_true")
|
|
27
|
+
|
|
28
|
+
install_parser = subparsers.add_parser(
|
|
29
|
+
"install", help="Install a repo-local pre-push hook"
|
|
30
|
+
)
|
|
31
|
+
install_parser.add_argument("--force", action="store_true")
|
|
23
32
|
return parser
|
|
24
33
|
|
|
25
34
|
|
|
@@ -28,9 +37,55 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
|
|
|
28
37
|
raise HookError("Only `minimal-docs` is supported")
|
|
29
38
|
target_dir = cwd or pathlib.Path.cwd()
|
|
30
39
|
config_path = target_dir / "ai-push-hooks.toml"
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
if force:
|
|
41
|
+
try:
|
|
42
|
+
metadata = config_path.lstat()
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
metadata = None
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
|
|
47
|
+
if metadata is not None:
|
|
48
|
+
if path_is_link_or_reparse(config_path):
|
|
49
|
+
raise HookError(f"Refusing to replace symlink or reparse point: {config_path}")
|
|
50
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
51
|
+
raise HookError(f"Refusing to replace non-regular config path: {config_path}")
|
|
52
|
+
try:
|
|
53
|
+
write_text_no_follow(config_path, MINIMAL_DOCS_TEMPLATE)
|
|
54
|
+
except HookError:
|
|
55
|
+
raise
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise HookError(f"Could not write config file {config_path}: {exc}") from exc
|
|
58
|
+
else:
|
|
59
|
+
try:
|
|
60
|
+
metadata = config_path.lstat()
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
metadata = None
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
raise HookError(f"Could not inspect config path {config_path}: {exc}") from exc
|
|
65
|
+
if metadata is not None:
|
|
66
|
+
if path_is_link_or_reparse(config_path):
|
|
67
|
+
raise HookError(f"Refusing to overwrite symlink or reparse point: {config_path}")
|
|
68
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
69
|
+
raise HookError(f"Refusing to overwrite non-regular config path: {config_path}")
|
|
70
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0)
|
|
71
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
72
|
+
try:
|
|
73
|
+
descriptor = os.open(config_path, flags, 0o600)
|
|
74
|
+
except FileExistsError as exc:
|
|
75
|
+
raise HookError(
|
|
76
|
+
f"Refusing to overwrite existing config without --force: {config_path}"
|
|
77
|
+
) from exc
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise HookError(f"Could not create config file {config_path}: {exc}") from exc
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
82
|
+
descriptor = -1
|
|
83
|
+
handle.write(MINIMAL_DOCS_TEMPLATE)
|
|
84
|
+
handle.flush()
|
|
85
|
+
os.fsync(handle.fileno())
|
|
86
|
+
finally:
|
|
87
|
+
if descriptor >= 0:
|
|
88
|
+
os.close(descriptor)
|
|
34
89
|
sys.stdout.write(str(config_path) + "\n")
|
|
35
90
|
return 0
|
|
36
91
|
|
|
@@ -43,6 +98,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
43
98
|
return run_hook(args.remote_name, args.remote_url)
|
|
44
99
|
if args.command == "init":
|
|
45
100
|
return init_config(args.template, args.force)
|
|
101
|
+
if args.command == "install":
|
|
102
|
+
return install_hook(args.force)
|
|
46
103
|
raise HookError(f"Unknown command: {args.command}")
|
|
47
104
|
except HookError as exc:
|
|
48
105
|
sys.stderr.write(f"[ai-push-hooks] {exc}\n")
|