ai-push-hooks 0.1.14 → 0.1.16
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 +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/executors/exec.py +93 -18
- package/src/ai_push_hooks/executors/llm.py +4 -15
package/README.md
CHANGED
|
@@ -24,9 +24,9 @@ pnpm add -D ai-push-hooks
|
|
|
24
24
|
|
|
25
25
|
Requirements:
|
|
26
26
|
|
|
27
|
-
- Python 3.10+ (`python3` or `python`) is required, including npm installs.
|
|
28
|
-
-
|
|
29
|
-
- `gh` is required only if you use PR creation via `gh_pr_create`.
|
|
27
|
+
- [Python 3.10+](https://www.python.org/downloads/) (`python3` or `python`) is required, including npm installs.
|
|
28
|
+
- [OpenCode CLI](https://github.com/sst/opencode) is required for `llm` and `apply` steps. The expected executable is `opencode`; `opencode-cli` is also accepted for compatibility.
|
|
29
|
+
- [GitHub CLI (`gh`)](https://cli.github.com/manual/installation) is required only if you use PR creation via `gh_pr_create`.
|
|
30
30
|
|
|
31
31
|
## Quick start
|
|
32
32
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -11,7 +11,13 @@ import subprocess
|
|
|
11
11
|
from pathlib import PurePosixPath
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
-
from ..types import
|
|
14
|
+
from ..types import (
|
|
15
|
+
FEATURE_BRANCH_PREFIXES,
|
|
16
|
+
HookError,
|
|
17
|
+
ModuleRuntimeState,
|
|
18
|
+
RuntimeContext,
|
|
19
|
+
StepConfig,
|
|
20
|
+
)
|
|
15
21
|
|
|
16
22
|
ZERO_OID = "0000000000000000000000000000000000000000"
|
|
17
23
|
|
|
@@ -145,10 +151,17 @@ def collect_ranges_from_stdin(
|
|
|
145
151
|
if local_sha == ZERO_OID:
|
|
146
152
|
continue
|
|
147
153
|
if remote_sha and remote_sha != ZERO_OID:
|
|
148
|
-
if
|
|
154
|
+
if (
|
|
155
|
+
run_command(
|
|
156
|
+
["git", "cat-file", "-e", f"{remote_sha}^{{commit}}"], cwd=repo_root
|
|
157
|
+
).returncode
|
|
158
|
+
== 0
|
|
159
|
+
):
|
|
149
160
|
ranges.add(f"{remote_sha}..{local_sha}")
|
|
150
161
|
else:
|
|
151
|
-
merge_base = git(
|
|
162
|
+
merge_base = git(
|
|
163
|
+
repo_root, ["merge-base", local_sha, f"{remote_name}/main"], check=False
|
|
164
|
+
)
|
|
152
165
|
if merge_base:
|
|
153
166
|
ranges.add(f"{merge_base}..{local_sha}")
|
|
154
167
|
else:
|
|
@@ -156,7 +169,9 @@ def collect_ranges_from_stdin(
|
|
|
156
169
|
if ranges:
|
|
157
170
|
return sorted(ranges)
|
|
158
171
|
|
|
159
|
-
upstream = git(
|
|
172
|
+
upstream = git(
|
|
173
|
+
repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], check=False
|
|
174
|
+
)
|
|
160
175
|
if upstream:
|
|
161
176
|
merge_base = git(repo_root, ["merge-base", "HEAD", upstream], check=False)
|
|
162
177
|
if merge_base:
|
|
@@ -170,7 +185,9 @@ def collect_ranges_from_stdin(
|
|
|
170
185
|
def collect_changed_files(repo_root: pathlib.Path, ranges: list[str]) -> list[str]:
|
|
171
186
|
files: set[str] = set()
|
|
172
187
|
for range_expr in ranges:
|
|
173
|
-
output = git(
|
|
188
|
+
output = git(
|
|
189
|
+
repo_root, ["diff", "--name-only", "--diff-filter=ACMR", range_expr], check=True
|
|
190
|
+
)
|
|
174
191
|
for line in output.splitlines():
|
|
175
192
|
clean = line.strip()
|
|
176
193
|
if clean:
|
|
@@ -186,18 +203,29 @@ def collect_diff(repo_root: pathlib.Path, ranges: list[str], max_bytes: int) ->
|
|
|
186
203
|
return "\n".join(chunks)[:max_bytes]
|
|
187
204
|
|
|
188
205
|
|
|
189
|
-
def collect_commit_messages_for_ranges(
|
|
206
|
+
def collect_commit_messages_for_ranges(
|
|
207
|
+
repo_root: pathlib.Path, ranges: list[str]
|
|
208
|
+
) -> list[dict[str, str]]:
|
|
190
209
|
commits: list[dict[str, str]] = []
|
|
191
210
|
for range_expr in ranges:
|
|
192
|
-
|
|
211
|
+
completed = run_command(
|
|
212
|
+
["git", "log", "--format=%H%x1f%s%x1f%b%x1e", range_expr],
|
|
213
|
+
cwd=repo_root,
|
|
214
|
+
check=True,
|
|
215
|
+
)
|
|
216
|
+
raw = completed.stdout or ""
|
|
193
217
|
for record in raw.split("\x1e"):
|
|
194
|
-
payload = record.
|
|
218
|
+
payload = record.rstrip("\r\n")
|
|
195
219
|
if not payload:
|
|
196
220
|
continue
|
|
197
221
|
parts = payload.split("\x1f", 2)
|
|
198
|
-
if len(parts)
|
|
222
|
+
if len(parts) == 2:
|
|
223
|
+
commit_hash, subject = parts
|
|
224
|
+
body = ""
|
|
225
|
+
elif len(parts) == 3:
|
|
226
|
+
commit_hash, subject, body = parts
|
|
227
|
+
else:
|
|
199
228
|
continue
|
|
200
|
-
commit_hash, subject, body = parts
|
|
201
229
|
commits.append(
|
|
202
230
|
{
|
|
203
231
|
"hash": commit_hash.strip(),
|
|
@@ -229,7 +257,19 @@ def parse_key_value_text(text: str) -> dict[str, str]:
|
|
|
229
257
|
|
|
230
258
|
def lookup_open_pr_url(repo_root: pathlib.Path, branch_name: str) -> str:
|
|
231
259
|
completed = run_command(
|
|
232
|
-
[
|
|
260
|
+
[
|
|
261
|
+
"gh",
|
|
262
|
+
"pr",
|
|
263
|
+
"list",
|
|
264
|
+
"--head",
|
|
265
|
+
branch_name,
|
|
266
|
+
"--state",
|
|
267
|
+
"open",
|
|
268
|
+
"--limit",
|
|
269
|
+
"1",
|
|
270
|
+
"--json",
|
|
271
|
+
"url",
|
|
272
|
+
],
|
|
233
273
|
cwd=repo_root,
|
|
234
274
|
check=False,
|
|
235
275
|
)
|
|
@@ -292,10 +332,24 @@ def attempt_pr_creation_fallback(
|
|
|
292
332
|
changed_files: list[str],
|
|
293
333
|
commits: list[dict[str, str]],
|
|
294
334
|
) -> str:
|
|
295
|
-
title = sanitize_pr_title(
|
|
335
|
+
title = sanitize_pr_title(
|
|
336
|
+
git(repo_root, ["log", "-1", "--pretty=%s"], check=False), branch_name
|
|
337
|
+
)
|
|
296
338
|
body = build_fallback_pr_body(branch_name, ranges, changed_files, commits)
|
|
297
339
|
created = run_command(
|
|
298
|
-
[
|
|
340
|
+
[
|
|
341
|
+
"gh",
|
|
342
|
+
"pr",
|
|
343
|
+
"create",
|
|
344
|
+
"--head",
|
|
345
|
+
branch_name,
|
|
346
|
+
"--base",
|
|
347
|
+
base_branch,
|
|
348
|
+
"--title",
|
|
349
|
+
title,
|
|
350
|
+
"--body",
|
|
351
|
+
body,
|
|
352
|
+
],
|
|
299
353
|
cwd=repo_root,
|
|
300
354
|
check=False,
|
|
301
355
|
)
|
|
@@ -307,11 +361,15 @@ def attempt_pr_creation_fallback(
|
|
|
307
361
|
existing_pr = lookup_open_pr_url(repo_root, branch_name)
|
|
308
362
|
if existing_pr:
|
|
309
363
|
return existing_pr
|
|
310
|
-
raise HookError(
|
|
364
|
+
raise HookError(
|
|
365
|
+
combined_output.strip() or f"gh pr create failed with exit code {created.returncode}"
|
|
366
|
+
)
|
|
311
367
|
|
|
312
368
|
|
|
313
369
|
def remote_branch_exists(repo_root: pathlib.Path, remote_name: str, branch_name: str) -> bool:
|
|
314
|
-
completed = run_command(
|
|
370
|
+
completed = run_command(
|
|
371
|
+
["git", "ls-remote", "--heads", remote_name, branch_name], cwd=repo_root, check=False
|
|
372
|
+
)
|
|
315
373
|
return completed.returncode == 0 and bool((completed.stdout or "").strip())
|
|
316
374
|
|
|
317
375
|
|
|
@@ -385,14 +443,28 @@ def gh_pr_create_executor(
|
|
|
385
443
|
title = sanitize_pr_title(str(payload.get("title", "")).strip(), branch_name)
|
|
386
444
|
body = str(payload.get("body", "")).strip()
|
|
387
445
|
if not body:
|
|
388
|
-
commits = collect_commit_messages_for_ranges(
|
|
446
|
+
commits = collect_commit_messages_for_ranges(
|
|
447
|
+
context.repo_root, context.cache.get("ranges", [])
|
|
448
|
+
)
|
|
389
449
|
body = build_fallback_pr_body(
|
|
390
450
|
branch_name,
|
|
391
451
|
context.cache.get("ranges", []),
|
|
392
452
|
context.cache.get("changed_files", []),
|
|
393
453
|
commits,
|
|
394
454
|
)
|
|
395
|
-
args = [
|
|
455
|
+
args = [
|
|
456
|
+
"gh",
|
|
457
|
+
"pr",
|
|
458
|
+
"create",
|
|
459
|
+
"--head",
|
|
460
|
+
head_branch,
|
|
461
|
+
"--base",
|
|
462
|
+
base_branch,
|
|
463
|
+
"--title",
|
|
464
|
+
title,
|
|
465
|
+
"--body",
|
|
466
|
+
body,
|
|
467
|
+
]
|
|
396
468
|
if bool(payload.get("draft", False)):
|
|
397
469
|
args.append("--draft")
|
|
398
470
|
created = run_command(args, cwd=context.repo_root, check=False)
|
|
@@ -402,7 +474,10 @@ def gh_pr_create_executor(
|
|
|
402
474
|
pr_url = lookup_open_pr_url(context.repo_root, branch_name)
|
|
403
475
|
if not pr_url:
|
|
404
476
|
if remote_branch_exists(context.repo_root, context.remote_name or "origin", branch_name):
|
|
405
|
-
raise HookError(
|
|
477
|
+
raise HookError(
|
|
478
|
+
combined_output.strip()
|
|
479
|
+
or f"gh pr create failed with exit code {created.returncode}"
|
|
480
|
+
)
|
|
406
481
|
return {"skipped": False, "pr_url": "", "deferred_until_remote": True}
|
|
407
482
|
return {"skipped": False, "pr_url": pr_url, "already_exists": False}
|
|
408
483
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
import os
|
|
5
4
|
import pathlib
|
|
6
5
|
import re
|
|
7
6
|
import shutil
|
|
@@ -26,24 +25,14 @@ def sanitize_filename_component(value: str) -> str:
|
|
|
26
25
|
return cleaned.strip("-") or "value"
|
|
27
26
|
|
|
28
27
|
|
|
29
|
-
def prefer_opencode_cli_candidate(candidate: str) -> str:
|
|
30
|
-
path = pathlib.Path(candidate)
|
|
31
|
-
if path.name != "opencode":
|
|
32
|
-
return candidate
|
|
33
|
-
sibling = path.with_name("opencode-cli")
|
|
34
|
-
if sibling.exists() and os.access(sibling, os.X_OK):
|
|
35
|
-
return str(sibling)
|
|
36
|
-
return candidate
|
|
37
|
-
|
|
38
|
-
|
|
39
28
|
def resolve_opencode_executable() -> str:
|
|
29
|
+
opencode_path = shutil.which("opencode")
|
|
30
|
+
if opencode_path:
|
|
31
|
+
return opencode_path
|
|
40
32
|
cli_path = shutil.which("opencode-cli")
|
|
41
33
|
if cli_path:
|
|
42
34
|
return cli_path
|
|
43
|
-
|
|
44
|
-
if opencode_path:
|
|
45
|
-
return prefer_opencode_cli_candidate(opencode_path)
|
|
46
|
-
raise HookError("opencode is required but not installed")
|
|
35
|
+
raise HookError("opencode (or opencode-cli) is required but not installed")
|
|
47
36
|
|
|
48
37
|
|
|
49
38
|
def parse_opencode_json_run_output(raw: str) -> tuple[str | None, str]:
|