ai-push-hooks 0.3.0 → 0.3.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/CHANGELOG.md +78 -2
- package/README.md +80 -993
- package/SECURITY.md +21 -16
- package/ai-push-hooks.toml +1 -1
- package/bin/ai-push-hooks.js +6 -6
- package/docs/configuration.md +263 -0
- package/package.json +4 -3
- package/pyproject.toml +12 -3
- package/src/ai_push_hooks/artifacts.py +19 -19
- package/src/ai_push_hooks/cli.py +24 -8
- package/src/ai_push_hooks/config.py +239 -54
- package/src/ai_push_hooks/engine.py +94 -43
- package/src/ai_push_hooks/executors/apply.py +124 -46
- package/src/ai_push_hooks/executors/ask.py +30 -536
- package/src/ai_push_hooks/executors/exec.py +73 -818
- package/src/ai_push_hooks/executors/runner_workflow.py +42 -20
- package/src/ai_push_hooks/executors/runners/claude.py +18 -6
- package/src/ai_push_hooks/executors/runners/codex.py +9 -3
- package/src/ai_push_hooks/executors/runners/command.py +25 -9
- package/src/ai_push_hooks/executors/runners/contracts.py +52 -25
- package/src/ai_push_hooks/executors/runners/opencode.py +109 -27
- package/src/ai_push_hooks/executors/runners/opencode_support.py +286 -0
- package/src/ai_push_hooks/executors/runners/process.py +97 -17
- package/src/ai_push_hooks/executors/runners/registry.py +31 -9
- package/src/ai_push_hooks/executors/step_commands.py +65 -20
- package/src/ai_push_hooks/git_utils.py +899 -0
- package/src/ai_push_hooks/hook.py +49 -13
- package/src/ai_push_hooks/install.py +40 -18
- package/src/ai_push_hooks/modules/beads.py +19 -8
- package/src/ai_push_hooks/modules/docs.py +146 -96
- package/src/ai_push_hooks/modules/pr.py +19 -8
- package/src/ai_push_hooks/paths.py +6 -2
- package/src/ai_push_hooks/plugin_loader.py +182 -105
- package/src/ai_push_hooks/plugins.py +3 -1
- package/src/ai_push_hooks/prompts_builtin.py +1 -1
- package/src/ai_push_hooks/types.py +48 -27
- package/vendor/README.md +15 -0
- package/vendor/requirements.txt +1 -0
- package/vendor/tomli-2.4.0-py3-none-any.whl +0 -0
- package/run.sh +0 -29
|
@@ -9,8 +9,12 @@ from typing import Sequence
|
|
|
9
9
|
from .artifacts import ArtifactStore, generate_run_id
|
|
10
10
|
from .config import load_config
|
|
11
11
|
from .engine import WorkflowEngine
|
|
12
|
-
from .paths import
|
|
13
|
-
|
|
12
|
+
from .paths import (
|
|
13
|
+
ensure_private_directory,
|
|
14
|
+
resolve_contained_path,
|
|
15
|
+
write_text_no_follow,
|
|
16
|
+
)
|
|
17
|
+
from .git_utils import (
|
|
14
18
|
collect_changed_files,
|
|
15
19
|
collect_diff,
|
|
16
20
|
collect_revision_ranges,
|
|
@@ -28,11 +32,15 @@ from .executors.exec import (
|
|
|
28
32
|
from .types import HookConfig, HookError, HookLogger, RuntimeContext
|
|
29
33
|
|
|
30
34
|
|
|
31
|
-
def _build_logger(
|
|
35
|
+
def _build_logger(
|
|
36
|
+
repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookConfig
|
|
37
|
+
) -> HookLogger:
|
|
32
38
|
ensure_private_directory(git_dir / "ai-push-hooks")
|
|
33
39
|
jsonl_path = None
|
|
34
40
|
if config.logging.jsonl:
|
|
35
|
-
log_dir = ensure_dir(
|
|
41
|
+
log_dir = ensure_dir(
|
|
42
|
+
resolve_storage_path(repo_root, git_dir, config.logging.dir)
|
|
43
|
+
)
|
|
36
44
|
if log_dir is not None:
|
|
37
45
|
jsonl_path = resolve_contained_path(log_dir, "hook.jsonl", "JSONL log path")
|
|
38
46
|
return HookLogger(jsonl_path=jsonl_path, console_level=config.logging.level)
|
|
@@ -40,7 +48,9 @@ def _build_logger(repo_root: pathlib.Path, git_dir: pathlib.Path, config: HookCo
|
|
|
40
48
|
|
|
41
49
|
def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
|
|
42
50
|
summary_dir = ensure_dir(
|
|
43
|
-
resolve_storage_path(
|
|
51
|
+
resolve_storage_path(
|
|
52
|
+
context.repo_root, context.git_dir, context.config.logging.summary_dir
|
|
53
|
+
)
|
|
44
54
|
)
|
|
45
55
|
if summary_dir is None:
|
|
46
56
|
return
|
|
@@ -49,7 +59,9 @@ def _write_summary(context: RuntimeContext, result: dict[str, object]) -> None:
|
|
|
49
59
|
f"{context.run_id}.json",
|
|
50
60
|
"Summary output path",
|
|
51
61
|
)
|
|
52
|
-
write_text_no_follow(
|
|
62
|
+
write_text_no_follow(
|
|
63
|
+
summary_path, json.dumps(result, ensure_ascii=True, indent=2) + "\n"
|
|
64
|
+
)
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
def _assert_clean_worktree(repo_root: pathlib.Path) -> None:
|
|
@@ -77,7 +89,11 @@ def _run_hook_impl(
|
|
|
77
89
|
if config.general.require_clean_worktree:
|
|
78
90
|
_assert_clean_worktree(repo_root)
|
|
79
91
|
|
|
80
|
-
actual_stdin =
|
|
92
|
+
actual_stdin = (
|
|
93
|
+
list(stdin_lines)
|
|
94
|
+
if stdin_lines is not None
|
|
95
|
+
else [line.rstrip("\n") for line in sys.stdin]
|
|
96
|
+
)
|
|
81
97
|
push_updates = parse_push_updates(actual_stdin)
|
|
82
98
|
pushed_branch_updates = [
|
|
83
99
|
update
|
|
@@ -92,7 +108,9 @@ def _run_hook_impl(
|
|
|
92
108
|
)
|
|
93
109
|
pushed_branches = list(
|
|
94
110
|
dict.fromkeys(
|
|
95
|
-
update.branch_name
|
|
111
|
+
update.branch_name
|
|
112
|
+
for update in pushed_branch_updates
|
|
113
|
+
if update.branch_name is not None
|
|
96
114
|
)
|
|
97
115
|
)
|
|
98
116
|
if config.general.skip_on_sync_branch:
|
|
@@ -108,7 +126,9 @@ def _run_hook_impl(
|
|
|
108
126
|
)
|
|
109
127
|
ranges = unique_range_expressions(revision_ranges)
|
|
110
128
|
changed_files = collect_changed_files(repo_root, ranges) if ranges else []
|
|
111
|
-
diff_text =
|
|
129
|
+
diff_text = (
|
|
130
|
+
collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
|
|
131
|
+
)
|
|
112
132
|
if len(pushed_branches) == 1:
|
|
113
133
|
branch_name = pushed_branches[0]
|
|
114
134
|
branch_selection_reason = "single pushed branch"
|
|
@@ -141,7 +161,9 @@ def _run_hook_impl(
|
|
|
141
161
|
branch_diff_text = ""
|
|
142
162
|
branch_is_new = False
|
|
143
163
|
run_id = generate_run_id()
|
|
144
|
-
run_dir = resolve_storage_path(
|
|
164
|
+
run_dir = resolve_storage_path(
|
|
165
|
+
repo_root, git_dir, f".git/ai-push-hooks/runs/{run_id}"
|
|
166
|
+
)
|
|
145
167
|
|
|
146
168
|
context = RuntimeContext(
|
|
147
169
|
repo_root=repo_root,
|
|
@@ -198,14 +220,28 @@ def _run_hook_impl(
|
|
|
198
220
|
try:
|
|
199
221
|
workflow_result = engine.run()
|
|
200
222
|
logger.llm_summary()
|
|
201
|
-
_write_summary(
|
|
202
|
-
|
|
223
|
+
_write_summary(
|
|
224
|
+
context,
|
|
225
|
+
{
|
|
226
|
+
"run_dir": str(workflow_result.run_dir),
|
|
227
|
+
"modules": workflow_result.modules,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
logger.status(
|
|
231
|
+
"hook.complete",
|
|
232
|
+
"AI push hooks workflow completed",
|
|
233
|
+
run_dir=str(workflow_result.run_dir),
|
|
234
|
+
)
|
|
203
235
|
return 0
|
|
204
236
|
except Exception as exc: # noqa: BLE001
|
|
205
237
|
message = str(exc).strip() or exc.__class__.__name__
|
|
206
238
|
logger.error("hook.failed", "AI push hooks workflow failed", error=message)
|
|
207
239
|
if config.general.allow_push_on_error:
|
|
208
|
-
logger.warn(
|
|
240
|
+
logger.warn(
|
|
241
|
+
"hook.fail_open",
|
|
242
|
+
"Allowing push because allow_push_on_error=true",
|
|
243
|
+
error=message,
|
|
244
|
+
)
|
|
209
245
|
return 0
|
|
210
246
|
raise
|
|
211
247
|
|
|
@@ -61,11 +61,7 @@ def pre_push_hook_script(delegate: tuple[str, ...] | None = None) -> str:
|
|
|
61
61
|
)
|
|
62
62
|
else:
|
|
63
63
|
availability_check = ""
|
|
64
|
-
return
|
|
65
|
-
"#!/bin/sh\n"
|
|
66
|
-
+ availability_check
|
|
67
|
-
+ f'exec {command} hook "$@"\n'
|
|
68
|
-
)
|
|
64
|
+
return "#!/bin/sh\n" + availability_check + f'exec {command} hook "$@"\n'
|
|
69
65
|
|
|
70
66
|
|
|
71
67
|
def _git_value(cwd: pathlib.Path, *args: str) -> str:
|
|
@@ -81,7 +77,9 @@ def _git_value(cwd: pathlib.Path, *args: str) -> str:
|
|
|
81
77
|
except FileNotFoundError as exc:
|
|
82
78
|
raise HookError("Git is required for `ai-push-hooks install`") from exc
|
|
83
79
|
except subprocess.TimeoutExpired as exc:
|
|
84
|
-
raise HookError(
|
|
80
|
+
raise HookError(
|
|
81
|
+
f"Git command timed out while resolving hook location: {' '.join(args)}"
|
|
82
|
+
) from exc
|
|
85
83
|
except subprocess.CalledProcessError as exc:
|
|
86
84
|
detail = (exc.stderr or exc.stdout or "not a Git repository").strip()
|
|
87
85
|
raise HookError(f"Could not resolve Git hook location: {detail}") from exc
|
|
@@ -93,11 +91,15 @@ def _resolve_git_namespace(repo_root: pathlib.Path, value: str) -> pathlib.Path:
|
|
|
93
91
|
return (repo_root / path).resolve() if not path.is_absolute() else path.resolve()
|
|
94
92
|
|
|
95
93
|
|
|
96
|
-
def _path_is_in_namespace(
|
|
94
|
+
def _path_is_in_namespace(
|
|
95
|
+
path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]
|
|
96
|
+
) -> bool:
|
|
97
97
|
return any(is_path_within(path, namespace) for namespace in namespaces)
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def _validate_parent_chain(
|
|
100
|
+
def _validate_parent_chain(
|
|
101
|
+
path: pathlib.Path, namespaces: tuple[pathlib.Path, ...]
|
|
102
|
+
) -> None:
|
|
101
103
|
"""Reject symlink/reparse parents and create only missing safe directories."""
|
|
102
104
|
parent = path.parent
|
|
103
105
|
existing: list[pathlib.Path] = []
|
|
@@ -129,25 +131,41 @@ def _validate_parent_chain(path: pathlib.Path, namespaces: tuple[pathlib.Path, .
|
|
|
129
131
|
current = current.parent
|
|
130
132
|
|
|
131
133
|
|
|
132
|
-
def _effective_hook_path(
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
def _effective_hook_path(
|
|
135
|
+
current_dir: pathlib.Path,
|
|
136
|
+
) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path, pathlib.Path]:
|
|
137
|
+
repo_root = pathlib.Path(
|
|
138
|
+
_git_value(current_dir, "rev-parse", "--show-toplevel")
|
|
139
|
+
).resolve()
|
|
140
|
+
git_dir = _resolve_git_namespace(
|
|
141
|
+
repo_root, _git_value(repo_root, "rev-parse", "--git-dir")
|
|
142
|
+
)
|
|
135
143
|
common_dir = _resolve_git_namespace(
|
|
136
144
|
repo_root, _git_value(repo_root, "rev-parse", "--git-common-dir")
|
|
137
145
|
)
|
|
138
|
-
raw_hooks_dir = pathlib.Path(
|
|
146
|
+
raw_hooks_dir = pathlib.Path(
|
|
147
|
+
_git_value(current_dir, "rev-parse", "--git-path", "hooks")
|
|
148
|
+
)
|
|
139
149
|
lexical_hooks_dir = (
|
|
140
150
|
raw_hooks_dir if raw_hooks_dir.is_absolute() else current_dir / raw_hooks_dir
|
|
141
151
|
)
|
|
142
152
|
if path_is_link_or_reparse(current_dir) or not current_dir.is_dir():
|
|
143
|
-
raise HookError(
|
|
153
|
+
raise HookError(
|
|
154
|
+
f"Refusing to install from an unsafe working directory: {current_dir}"
|
|
155
|
+
)
|
|
144
156
|
if any(path_is_link_or_reparse(part) for part in lexical_hooks_dir.parents):
|
|
145
|
-
raise HookError(
|
|
157
|
+
raise HookError(
|
|
158
|
+
f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}"
|
|
159
|
+
)
|
|
146
160
|
if path_is_link_or_reparse(lexical_hooks_dir):
|
|
147
|
-
raise HookError(
|
|
161
|
+
raise HookError(
|
|
162
|
+
f"Refusing hook path with a symlink or reparse parent: {lexical_hooks_dir}"
|
|
163
|
+
)
|
|
148
164
|
lexical_hook_path = lexical_hooks_dir / "pre-push"
|
|
149
165
|
if path_is_link_or_reparse(lexical_hook_path):
|
|
150
|
-
raise HookError(
|
|
166
|
+
raise HookError(
|
|
167
|
+
f"Refusing symlink or reparse-point hook target: {lexical_hook_path}"
|
|
168
|
+
)
|
|
151
169
|
hook_path = lexical_hook_path.resolve(strict=False)
|
|
152
170
|
|
|
153
171
|
namespaces = (repo_root, git_dir)
|
|
@@ -179,11 +197,15 @@ def install_hook(force: bool, cwd: pathlib.Path | None = None) -> int:
|
|
|
179
197
|
except FileNotFoundError:
|
|
180
198
|
metadata = None
|
|
181
199
|
except OSError as exc:
|
|
182
|
-
raise HookError(
|
|
200
|
+
raise HookError(
|
|
201
|
+
f"Could not inspect pre-push hook path {hook_path}: {exc}"
|
|
202
|
+
) from exc
|
|
183
203
|
|
|
184
204
|
if metadata is not None:
|
|
185
205
|
if path_is_link_or_reparse(hook_path):
|
|
186
|
-
raise HookError(
|
|
206
|
+
raise HookError(
|
|
207
|
+
f"Refusing symlink or reparse-point hook target: {hook_path}"
|
|
208
|
+
)
|
|
187
209
|
if not stat.S_ISREG(metadata.st_mode):
|
|
188
210
|
raise HookError(f"Refusing non-regular hook target: {hook_path}")
|
|
189
211
|
if not force:
|
|
@@ -2,11 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from ..
|
|
5
|
+
from ..git_utils import collect_commit_messages_for_ranges, is_feature_branch
|
|
6
6
|
from ..types import CollectorResult, RuntimeContext
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def collect_beads_status_context(
|
|
9
|
+
def collect_beads_status_context(
|
|
10
|
+
context: RuntimeContext, state: Any
|
|
11
|
+
) -> CollectorResult:
|
|
10
12
|
branch_name = str(context.cache.get("branch_name", ""))
|
|
11
13
|
branch_selection_reason = str(
|
|
12
14
|
context.cache.get("branch_selection_reason", "no pushed branch updates")
|
|
@@ -22,7 +24,9 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
|
|
|
22
24
|
skip_reason=branch_selection_reason,
|
|
23
25
|
)
|
|
24
26
|
sync_branch = context.cache.get("sync_branch", "beads-sync")
|
|
25
|
-
if branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(
|
|
27
|
+
if branch_name in {"HEAD", "main", sync_branch} or not is_feature_branch(
|
|
28
|
+
branch_name
|
|
29
|
+
):
|
|
26
30
|
return CollectorResult(
|
|
27
31
|
artifacts={"branch-context.txt": f"branch={branch_name}\n"},
|
|
28
32
|
skip_module=True,
|
|
@@ -33,8 +37,12 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
|
|
|
33
37
|
changed_files = context.cache.get(
|
|
34
38
|
"branch_changed_files", context.cache.get("changed_files", [])
|
|
35
39
|
)
|
|
36
|
-
diff_text = context.cache.get(
|
|
37
|
-
|
|
40
|
+
diff_text = context.cache.get(
|
|
41
|
+
"branch_diff_text", context.cache.get("diff_text", "")
|
|
42
|
+
)
|
|
43
|
+
commits = (
|
|
44
|
+
collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
45
|
+
)
|
|
38
46
|
report_file = "BEADS_STATUS_ACTION_REQUIRED.md"
|
|
39
47
|
commit_lines = []
|
|
40
48
|
for commit in commits:
|
|
@@ -54,8 +62,11 @@ def collect_beads_status_context(context: RuntimeContext, state: Any) -> Collect
|
|
|
54
62
|
]
|
|
55
63
|
)
|
|
56
64
|
+ "\n",
|
|
57
|
-
"changed-files.txt": "\n".join(changed_files)
|
|
58
|
-
|
|
59
|
-
"
|
|
65
|
+
"changed-files.txt": "\n".join(changed_files)
|
|
66
|
+
+ ("\n" if changed_files else ""),
|
|
67
|
+
"push.diff": diff_text
|
|
68
|
+
+ ("\n" if diff_text and not diff_text.endswith("\n") else ""),
|
|
69
|
+
"commits.txt": "\n".join(commit_lines).strip()
|
|
70
|
+
+ ("\n" if commit_lines else ""),
|
|
60
71
|
}
|
|
61
72
|
)
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import pathlib
|
|
6
5
|
import re
|
|
@@ -10,7 +9,7 @@ from pathlib import PurePosixPath
|
|
|
10
9
|
from typing import Any
|
|
11
10
|
|
|
12
11
|
from ..types import CollectorResult, RuntimeContext
|
|
13
|
-
from ..
|
|
12
|
+
from ..git_utils import collect_commit_messages_for_ranges, git, path_matches
|
|
14
13
|
|
|
15
14
|
DOC_INCLUDE_PATTERNS = ("README.md", "docs/**/*.md")
|
|
16
15
|
DOC_IGNORE_PATTERNS = ("docs/archive/**",)
|
|
@@ -18,6 +17,10 @@ DOC_CONTEXT_LINES = 2
|
|
|
18
17
|
DOC_MAX_BYTES = 64 * 1024
|
|
19
18
|
DOC_CONTEXT_BUDGET = 32000
|
|
20
19
|
DOC_FALLBACK_FILE_LIMIT = 8
|
|
20
|
+
# Matching metadata is deliberately bounded independently of the document
|
|
21
|
+
# inventory. The normal context budget is reached much earlier, while this
|
|
22
|
+
# cap prevents a pathological repeated-match file from retaining every hit.
|
|
23
|
+
DOC_MAX_QUERY_MATCHES = 4096
|
|
21
24
|
|
|
22
25
|
|
|
23
26
|
def _path_matches(path: str, patterns: tuple[str, ...]) -> bool:
|
|
@@ -126,25 +129,70 @@ def _read_bounded_text(path: pathlib.Path, max_bytes: int | None = None) -> str:
|
|
|
126
129
|
os.close(descriptor)
|
|
127
130
|
|
|
128
131
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
class _ContextAccumulator:
|
|
133
|
+
"""Collect bounded snippets without rescanning all prior snippets."""
|
|
134
|
+
|
|
135
|
+
def __init__(self) -> None:
|
|
136
|
+
self.chunks: list[str] = []
|
|
137
|
+
self.size = 0
|
|
138
|
+
|
|
139
|
+
def append(self, chunk: str, budget: int) -> bool:
|
|
140
|
+
remaining = budget - self.size
|
|
141
|
+
separator_size = 1 if self.chunks else 0
|
|
142
|
+
remaining -= separator_size
|
|
143
|
+
if remaining <= 0:
|
|
137
144
|
return False
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
truncated = len(chunk) > remaining
|
|
146
|
+
if truncated:
|
|
147
|
+
if self.chunks:
|
|
148
|
+
return False
|
|
149
|
+
chunk = chunk[:remaining]
|
|
150
|
+
self.chunks.append(chunk)
|
|
151
|
+
self.size += separator_size + len(chunk)
|
|
152
|
+
return not truncated
|
|
141
153
|
|
|
154
|
+
def render(self) -> str:
|
|
155
|
+
return "\n".join(self.chunks)
|
|
142
156
|
|
|
143
|
-
|
|
157
|
+
|
|
158
|
+
class _QueryMatchBuffer:
|
|
159
|
+
"""Bounded query-ranked snippet metadata for one document scan."""
|
|
160
|
+
|
|
161
|
+
def __init__(self, max_matches: int = DOC_MAX_QUERY_MATCHES) -> None:
|
|
162
|
+
self.max_matches = max_matches
|
|
163
|
+
self.matches: list[tuple[tuple[int, int], str]] = []
|
|
164
|
+
self._seen: set[tuple[int, int]] = set()
|
|
165
|
+
self.characters = 0
|
|
166
|
+
self.saturated = False
|
|
167
|
+
|
|
168
|
+
def add(
|
|
169
|
+
self,
|
|
170
|
+
key: tuple[int, int],
|
|
171
|
+
relative: str,
|
|
172
|
+
line_number: int,
|
|
173
|
+
line: str,
|
|
174
|
+
) -> None:
|
|
175
|
+
if key in self._seen or self.saturated or len(self.matches) >= self.max_matches:
|
|
176
|
+
return
|
|
177
|
+
chunk = f"{relative}:{line_number}: {line}"
|
|
178
|
+
self._seen.add(key)
|
|
179
|
+
self.matches.append((key, chunk))
|
|
180
|
+
self.characters += len(chunk)
|
|
181
|
+
self.saturated = (
|
|
182
|
+
len(self.matches) >= self.max_matches
|
|
183
|
+
or self.characters >= DOC_CONTEXT_BUDGET
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _fallback_docs_context(
|
|
188
|
+
repo_root: pathlib.Path,
|
|
189
|
+
doc_files: list[pathlib.Path],
|
|
190
|
+
contents: dict[pathlib.Path, str] | None = None,
|
|
191
|
+
) -> str:
|
|
144
192
|
snippets: list[str] = []
|
|
145
193
|
for path in doc_files[:DOC_FALLBACK_FILE_LIMIT]:
|
|
146
194
|
relative = path.relative_to(repo_root).as_posix()
|
|
147
|
-
content = _read_bounded_text(path)
|
|
195
|
+
content = _read_bounded_text(path) if contents is None else contents[path]
|
|
148
196
|
block = f"--- {relative} ---\n{content}"
|
|
149
197
|
current_size = len("\n\n".join(snippets))
|
|
150
198
|
remaining = DOC_CONTEXT_BUDGET - current_size
|
|
@@ -156,90 +204,84 @@ def _fallback_docs_context(repo_root: pathlib.Path, doc_files: list[pathlib.Path
|
|
|
156
204
|
return "\n\n".join(snippets)
|
|
157
205
|
|
|
158
206
|
|
|
159
|
-
def
|
|
207
|
+
def _collect_query_matches(
|
|
208
|
+
repo_root: pathlib.Path,
|
|
209
|
+
doc_files: list[pathlib.Path],
|
|
210
|
+
queries: list[str],
|
|
211
|
+
*,
|
|
212
|
+
rg_available: bool,
|
|
213
|
+
) -> tuple[list[_QueryMatchBuffer], dict[pathlib.Path, str]]:
|
|
214
|
+
"""Read each document once and retain only bounded ranked snippet metadata.
|
|
215
|
+
|
|
216
|
+
The old rg path skipped files larger than ``DOC_MAX_BYTES`` while its
|
|
217
|
+
no-rg fallback read a bounded prefix. Keep that environment-dependent
|
|
218
|
+
compatibility behavior explicit while avoiding N query subprocesses and
|
|
219
|
+
without caching the whole documentation tree.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
buffers = [_QueryMatchBuffer() for _query in queries]
|
|
223
|
+
fallback_contents: dict[pathlib.Path, str] = {}
|
|
224
|
+
for path_index, path in enumerate(doc_files):
|
|
225
|
+
if rg_available:
|
|
226
|
+
try:
|
|
227
|
+
if path.stat().st_size > DOC_MAX_BYTES:
|
|
228
|
+
continue
|
|
229
|
+
except OSError:
|
|
230
|
+
continue
|
|
231
|
+
content = _read_bounded_text(path)
|
|
232
|
+
if not rg_available and path_index < DOC_FALLBACK_FILE_LIMIT:
|
|
233
|
+
fallback_contents[path] = content
|
|
234
|
+
lines = content.splitlines()
|
|
235
|
+
relative = path.relative_to(repo_root).as_posix()
|
|
236
|
+
for line_index, line in enumerate(lines):
|
|
237
|
+
for query_index, query in enumerate(queries):
|
|
238
|
+
if query not in line:
|
|
239
|
+
continue
|
|
240
|
+
first = max(0, line_index - DOC_CONTEXT_LINES)
|
|
241
|
+
last = min(len(lines), line_index + DOC_CONTEXT_LINES + 1)
|
|
242
|
+
buffer = buffers[query_index]
|
|
243
|
+
for index in range(first, last):
|
|
244
|
+
buffer.add(
|
|
245
|
+
(path_index, index),
|
|
246
|
+
relative,
|
|
247
|
+
index + 1,
|
|
248
|
+
lines[index],
|
|
249
|
+
)
|
|
250
|
+
if all(buffer.saturated for buffer in buffers):
|
|
251
|
+
return buffers, fallback_contents
|
|
252
|
+
return buffers, fallback_contents
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _search_docs_context(
|
|
256
|
+
repo_root: pathlib.Path, doc_files: list[pathlib.Path], queries: list[str]
|
|
257
|
+
) -> str:
|
|
160
258
|
repo_root = repo_root.resolve(strict=True)
|
|
161
259
|
if not doc_files:
|
|
162
260
|
return ""
|
|
163
261
|
if not queries:
|
|
164
262
|
return _fallback_docs_context(repo_root, doc_files)
|
|
165
263
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for index in range(first, last):
|
|
178
|
-
key = (relative, index + 1)
|
|
179
|
-
if key in seen:
|
|
180
|
-
continue
|
|
181
|
-
seen.add(key)
|
|
182
|
-
chunk = f"{relative}:{index + 1}: {lines[index]}"
|
|
183
|
-
if not _append_context_chunk(chunks, chunk, DOC_CONTEXT_BUDGET):
|
|
184
|
-
return "\n".join(chunks)
|
|
185
|
-
return "\n".join(chunks) if chunks else _fallback_docs_context(repo_root, doc_files)
|
|
186
|
-
|
|
187
|
-
files = [path.relative_to(repo_root).as_posix() for path in doc_files]
|
|
188
|
-
allowed_files = set(files)
|
|
189
|
-
chunks: list[str] = []
|
|
190
|
-
seen: set[tuple[str, int]] = set()
|
|
191
|
-
for query in queries:
|
|
192
|
-
completed = run_command(
|
|
193
|
-
[
|
|
194
|
-
"rg",
|
|
195
|
-
"--json",
|
|
196
|
-
"--fixed-strings",
|
|
197
|
-
"--with-filename",
|
|
198
|
-
"--color=never",
|
|
199
|
-
"--context",
|
|
200
|
-
str(DOC_CONTEXT_LINES),
|
|
201
|
-
"--max-filesize",
|
|
202
|
-
str(DOC_MAX_BYTES),
|
|
203
|
-
"--",
|
|
204
|
-
query,
|
|
205
|
-
*files,
|
|
206
|
-
],
|
|
207
|
-
cwd=repo_root,
|
|
208
|
-
check=False,
|
|
209
|
-
)
|
|
210
|
-
if completed.returncode not in {0, 1}:
|
|
211
|
-
continue
|
|
212
|
-
for line in completed.stdout.splitlines():
|
|
213
|
-
try:
|
|
214
|
-
message = json.loads(line)
|
|
215
|
-
except json.JSONDecodeError:
|
|
216
|
-
continue
|
|
217
|
-
if message.get("type") not in {"match", "context"}:
|
|
218
|
-
continue
|
|
219
|
-
data = message.get("data")
|
|
220
|
-
if not isinstance(data, dict):
|
|
221
|
-
continue
|
|
222
|
-
path_data = data.get("path")
|
|
223
|
-
lines_data = data.get("lines")
|
|
224
|
-
file_name = path_data.get("text") if isinstance(path_data, dict) else None
|
|
225
|
-
line_number = data.get("line_number")
|
|
226
|
-
content = lines_data.get("text") if isinstance(lines_data, dict) else None
|
|
227
|
-
if (
|
|
228
|
-
not isinstance(file_name, str)
|
|
229
|
-
or file_name not in allowed_files
|
|
230
|
-
or not isinstance(line_number, int)
|
|
231
|
-
or not isinstance(content, str)
|
|
232
|
-
):
|
|
233
|
-
continue
|
|
234
|
-
key = (file_name, line_number)
|
|
264
|
+
rg_available = shutil.which("rg") is not None
|
|
265
|
+
matches_by_query, fallback_contents = _collect_query_matches(
|
|
266
|
+
repo_root,
|
|
267
|
+
doc_files,
|
|
268
|
+
queries,
|
|
269
|
+
rg_available=rg_available,
|
|
270
|
+
)
|
|
271
|
+
accumulator = _ContextAccumulator()
|
|
272
|
+
seen: set[tuple[int, int]] = set()
|
|
273
|
+
for query_matches in matches_by_query:
|
|
274
|
+
for key, chunk in query_matches.matches:
|
|
235
275
|
if key in seen:
|
|
236
276
|
continue
|
|
237
277
|
seen.add(key)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
278
|
+
if not accumulator.append(chunk, DOC_CONTEXT_BUDGET):
|
|
279
|
+
return accumulator.render()
|
|
280
|
+
if accumulator.chunks:
|
|
281
|
+
return accumulator.render()
|
|
282
|
+
if rg_available:
|
|
283
|
+
return ""
|
|
284
|
+
return _fallback_docs_context(repo_root, doc_files, fallback_contents)
|
|
243
285
|
|
|
244
286
|
|
|
245
287
|
def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResult:
|
|
@@ -257,7 +299,9 @@ def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResul
|
|
|
257
299
|
["log", "--oneline", "-n", "20", "--", "README.md", "docs"],
|
|
258
300
|
check=False,
|
|
259
301
|
)
|
|
260
|
-
commits =
|
|
302
|
+
commits = (
|
|
303
|
+
collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
304
|
+
)
|
|
261
305
|
commit_lines = []
|
|
262
306
|
for commit in commits:
|
|
263
307
|
commit_lines.append(f"--- {commit['hash']}")
|
|
@@ -268,12 +312,18 @@ def collect_docs_context(context: RuntimeContext, _state: Any) -> CollectorResul
|
|
|
268
312
|
commit_lines.append("")
|
|
269
313
|
return CollectorResult(
|
|
270
314
|
artifacts={
|
|
271
|
-
"changed-files.txt": "\n".join(changed_files)
|
|
272
|
-
|
|
273
|
-
"
|
|
315
|
+
"changed-files.txt": "\n".join(changed_files)
|
|
316
|
+
+ ("\n" if changed_files else ""),
|
|
317
|
+
"push.diff": diff_text
|
|
318
|
+
+ ("\n" if diff_text and not diff_text.endswith("\n") else ""),
|
|
319
|
+
"docs-inventory.txt": "\n".join(
|
|
320
|
+
path.relative_to(context.repo_root).as_posix() for path in doc_files
|
|
321
|
+
)
|
|
274
322
|
+ ("\n" if doc_files else ""),
|
|
275
323
|
"docs-context.txt": docs_context + ("\n" if docs_context else ""),
|
|
276
|
-
"recent-commits.txt": recent_commits
|
|
277
|
-
|
|
324
|
+
"recent-commits.txt": recent_commits
|
|
325
|
+
+ ("\n" if recent_commits and not recent_commits.endswith("\n") else ""),
|
|
326
|
+
"commits.txt": "\n".join(commit_lines).strip()
|
|
327
|
+
+ ("\n" if commit_lines else ""),
|
|
278
328
|
}
|
|
279
329
|
)
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import Any
|
|
4
4
|
|
|
5
|
-
from ..
|
|
5
|
+
from ..git_utils import (
|
|
6
6
|
collect_commit_messages_for_ranges,
|
|
7
7
|
env_bool,
|
|
8
8
|
initial_pr_defer_reason,
|
|
@@ -37,7 +37,9 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
37
37
|
break
|
|
38
38
|
if flag_env and env_bool(flag_env) is not True:
|
|
39
39
|
return CollectorResult(
|
|
40
|
-
artifacts={
|
|
40
|
+
artifacts={
|
|
41
|
+
"pr-context.txt": f"branch={branch_name}\nflag_env={flag_env}\n"
|
|
42
|
+
},
|
|
41
43
|
skip_module=True,
|
|
42
44
|
skip_reason="PR create env flag is not enabled",
|
|
43
45
|
)
|
|
@@ -81,7 +83,9 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
81
83
|
existing_pr_url = ""
|
|
82
84
|
if existing_pr_url:
|
|
83
85
|
return CollectorResult(
|
|
84
|
-
artifacts={
|
|
86
|
+
artifacts={
|
|
87
|
+
"pr-context.txt": f"branch={branch_name}\nexisting_pr_url={existing_pr_url}\n"
|
|
88
|
+
},
|
|
85
89
|
skip_module=True,
|
|
86
90
|
skip_reason="open PR already exists",
|
|
87
91
|
metadata={"existing_pr_url": existing_pr_url},
|
|
@@ -91,8 +95,12 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
91
95
|
changed_files = context.cache.get(
|
|
92
96
|
"branch_changed_files", context.cache.get("changed_files", [])
|
|
93
97
|
)
|
|
94
|
-
diff_text = context.cache.get(
|
|
95
|
-
|
|
98
|
+
diff_text = context.cache.get(
|
|
99
|
+
"branch_diff_text", context.cache.get("diff_text", "")
|
|
100
|
+
)
|
|
101
|
+
commits = (
|
|
102
|
+
collect_commit_messages_for_ranges(context.repo_root, ranges) if ranges else []
|
|
103
|
+
)
|
|
96
104
|
commit_lines = []
|
|
97
105
|
for commit in commits:
|
|
98
106
|
commit_lines.append(f"--- {commit['hash']}")
|
|
@@ -113,8 +121,11 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
113
121
|
]
|
|
114
122
|
)
|
|
115
123
|
+ "\n",
|
|
116
|
-
"changed-files.txt": "\n".join(changed_files)
|
|
117
|
-
|
|
118
|
-
"
|
|
124
|
+
"changed-files.txt": "\n".join(changed_files)
|
|
125
|
+
+ ("\n" if changed_files else ""),
|
|
126
|
+
"push.diff": diff_text
|
|
127
|
+
+ ("\n" if diff_text and not diff_text.endswith("\n") else ""),
|
|
128
|
+
"commits.txt": "\n".join(commit_lines).strip()
|
|
129
|
+
+ ("\n" if commit_lines else ""),
|
|
119
130
|
}
|
|
120
131
|
)
|