ai-push-hooks 0.1.17 → 0.1.19
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 +67 -17
- package/ai-push-hooks.toml +2 -1
- package/package.json +3 -2
- package/pyproject.toml +2 -1
- package/src/ai_push_hooks/artifacts.py +10 -2
- package/src/ai_push_hooks/cli.py +2 -4
- package/src/ai_push_hooks/config.py +18 -183
- package/src/ai_push_hooks/executors/apply.py +20 -1
- package/src/ai_push_hooks/executors/exec.py +6 -3
- package/src/ai_push_hooks/executors/llm.py +1 -1
- package/src/ai_push_hooks/hook.py +4 -1
- package/src/ai_push_hooks/modules/pr.py +3 -2
- package/src/ai_push_hooks/prompts_builtin.py +2 -1
- package/src/ai_push_hooks/types.py +2 -1
package/README.md
CHANGED
|
@@ -6,6 +6,23 @@ It runs module-based steps (collect, LLM, apply, exec, assert) before push so yo
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
+
### Mise (recommended for repository hooks)
|
|
10
|
+
|
|
11
|
+
Pin the currently published release in the consuming repository:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
mise use npm:ai-push-hooks@0.1.19
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This adds the following project-level tool entry to `mise.toml` and installs it:
|
|
18
|
+
|
|
19
|
+
```toml
|
|
20
|
+
[tools]
|
|
21
|
+
"npm:ai-push-hooks" = "0.1.19"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
After checking in `mise.toml`, other contributors can install the pinned tool with `mise install`.
|
|
25
|
+
|
|
9
26
|
### Python
|
|
10
27
|
|
|
11
28
|
```bash
|
|
@@ -24,32 +41,63 @@ pnpm add -D ai-push-hooks
|
|
|
24
41
|
|
|
25
42
|
Requirements:
|
|
26
43
|
|
|
27
|
-
- [Python 3.10+](https://www.python.org/downloads/) (`python3` or `python`) is required,
|
|
28
|
-
- [OpenCode
|
|
44
|
+
- [Python 3.10+](https://www.python.org/downloads/) (`python3` or `python`) is required. The npm package is a wrapper around the Python CLI, so Python is still required when installed through npm/pnpm.
|
|
45
|
+
- [OpenCode](https://github.com/anomalyco/opencode) is required for `llm` and `apply` steps.
|
|
29
46
|
- [GitHub CLI (`gh`)](https://cli.github.com/manual/installation) is required only if you use PR creation via `gh_pr_create`.
|
|
30
47
|
|
|
31
48
|
## Quick start
|
|
32
49
|
|
|
33
|
-
1.
|
|
50
|
+
1. Add the pinned Mise tool by following [Mise installation](#mise-recommended-for-repository-hooks) above.
|
|
34
51
|
2. Generate a starter config:
|
|
35
52
|
|
|
36
53
|
```bash
|
|
37
|
-
ai-push-hooks init --template minimal-docs
|
|
54
|
+
mise exec -- ai-push-hooks init --template minimal-docs
|
|
38
55
|
```
|
|
39
56
|
|
|
40
|
-
3.
|
|
57
|
+
3. Add the single repository-owned runner at `scripts/hooks/pre-push-runner.sh`:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
#!/usr/bin/env bash
|
|
61
|
+
set -euo pipefail
|
|
62
|
+
|
|
63
|
+
remote_name="${1:-}"
|
|
64
|
+
remote_url="${2:-}"
|
|
65
|
+
push_stdin="$(mktemp)"
|
|
66
|
+
trap 'rm -f "$push_stdin"' EXIT
|
|
67
|
+
cat >"$push_stdin"
|
|
68
|
+
|
|
69
|
+
# Run deterministic quality checks first. Replace these with the repository's checks.
|
|
70
|
+
npm run lint
|
|
71
|
+
npm test
|
|
72
|
+
|
|
73
|
+
# Keep ai-push-hooks as the single final phase and replay Git's pre-push input.
|
|
74
|
+
mise exec -- ai-push-hooks hook "$remote_name" "$remote_url" <"$push_stdin"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Make the runner executable:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
chmod +x scripts/hooks/pre-push-runner.sh
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
4. Configure Lefthook to invoke only that runner in `lefthook.yml`:
|
|
41
84
|
|
|
42
85
|
```yaml
|
|
43
86
|
pre-push:
|
|
44
87
|
commands:
|
|
45
|
-
|
|
46
|
-
run:
|
|
88
|
+
repository-pre-push:
|
|
89
|
+
run: bash scripts/hooks/pre-push-runner.sh {1} {2}
|
|
90
|
+
use_stdin: true
|
|
47
91
|
```
|
|
48
92
|
|
|
49
|
-
|
|
93
|
+
`use_stdin: true` forwards Git's ref-update stream to the runner. The runner captures it before quality checks consume or close standard input, then replays it to `ai-push-hooks hook`. Lefthook's `{1}` and `{2}` are the remote name and remote URL. Keep all repository checks in this runner and keep the one `ai-push-hooks hook` call last so failures propagate and block the push.
|
|
94
|
+
5. Configure modules and steps in [Configuration reference](#configuration-reference).
|
|
95
|
+
6. Push as usual. The workflow runs automatically before push completes.
|
|
50
96
|
|
|
51
97
|
## Commands
|
|
52
98
|
|
|
99
|
+
If installed as a local npm/pnpm dependency, run commands with `npx` or `pnpm exec`.
|
|
100
|
+
|
|
53
101
|
| Command | What it does |
|
|
54
102
|
| --- | --- |
|
|
55
103
|
| `ai-push-hooks hook <remote-name> <remote-url>` | Runs the configured pre-push workflow. |
|
|
@@ -58,9 +106,7 @@ Requirements:
|
|
|
58
106
|
|
|
59
107
|
## Configuration overview
|
|
60
108
|
|
|
61
|
-
- Config file
|
|
62
|
-
- If no config file is present, built-in defaults are used.
|
|
63
|
-
- File values are deep-merged over defaults.
|
|
109
|
+
- Config file: `ai-push-hooks.toml` in repo root (required).
|
|
64
110
|
- Prompt resolution precedence for `llm` and `apply` steps:
|
|
65
111
|
1. `prompt`
|
|
66
112
|
2. `prompt_file`
|
|
@@ -72,11 +118,11 @@ Requirements:
|
|
|
72
118
|
|
|
73
119
|
| Key | Type | Required | Default |
|
|
74
120
|
| --- | --- | --- | --- |
|
|
75
|
-
| `general` | table | no |
|
|
76
|
-
| `llm` | table | no |
|
|
77
|
-
| `logging` | table | no |
|
|
78
|
-
| `workflow` | table | yes |
|
|
79
|
-
| `modules` | table | yes |
|
|
121
|
+
| `general` | table | no | see section defaults |
|
|
122
|
+
| `llm` | table | no | see section defaults |
|
|
123
|
+
| `logging` | table | no | see section defaults |
|
|
124
|
+
| `workflow` | table | yes | n/a |
|
|
125
|
+
| `modules` | table | yes | n/a |
|
|
80
126
|
|
|
81
127
|
### `[general]`
|
|
82
128
|
|
|
@@ -86,13 +132,14 @@ Requirements:
|
|
|
86
132
|
| `allow_push_on_error` | bool | `false` | If `true`, push continues even when workflow fails. |
|
|
87
133
|
| `require_clean_worktree` | bool | `false` | If `true`, aborts when local changes exist. |
|
|
88
134
|
| `skip_on_sync_branch` | bool | `true` | If `true`, skips on sync branch/worktree context. |
|
|
135
|
+
| `base_branch` | string | `"main"` | Base branch used for new-branch range fallback and default PR base/context. |
|
|
89
136
|
|
|
90
137
|
### `[llm]`
|
|
91
138
|
|
|
92
139
|
| Key | Type | Default | Description |
|
|
93
140
|
| --- | --- | --- | --- |
|
|
94
141
|
| `runner` | string | `"opencode"` | LLM runner label (currently OpenCode flow). |
|
|
95
|
-
| `model` | string | `"openai/gpt-5.
|
|
142
|
+
| `model` | string | `"openai/gpt-5.5"` | Model passed to OpenCode. |
|
|
96
143
|
| `variant` | string | `""` | Optional OpenCode variant. |
|
|
97
144
|
| `timeout_seconds` | int | `800` | Timeout per LLM invocation and related OpenCode calls. |
|
|
98
145
|
| `max_parallel` | int | `2` | Max concurrent read-only steps (`collect`, `llm`). |
|
|
@@ -148,6 +195,8 @@ Requirements:
|
|
|
148
195
|
|
|
149
196
|
`llm` and `apply` are promptable step types: at least one of `prompt`, `prompt_file`, or `fallback_prompt_id` must be set.
|
|
150
197
|
|
|
198
|
+
Artifact references in `inputs` are module-local. Use `<step>/<artifact>` to reference an artifact produced by an earlier step in the same module (for example, `collect/push.diff` or `analyze/issues.json`). Cross-module references such as `docs:collect/push.diff` are not currently supported.
|
|
199
|
+
|
|
151
200
|
### Supported handler and schema values
|
|
152
201
|
|
|
153
202
|
#### Collectors
|
|
@@ -201,6 +250,7 @@ Boolean env parsing accepts: `1`, `true`, `yes`, `y`, `on` and `0`, `false`, `no
|
|
|
201
250
|
| `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR` | Overrides `general.allow_push_on_error`. |
|
|
202
251
|
| `AI_PUSH_HOOKS_REQUIRE_CLEAN` | Overrides `general.require_clean_worktree`. |
|
|
203
252
|
| `AI_PUSH_HOOKS_ALLOW_DIRTY` | If true, forces `general.require_clean_worktree = false`. |
|
|
253
|
+
| `AI_PUSH_HOOKS_BASE_BRANCH` | Overrides `general.base_branch`. |
|
|
204
254
|
| `AI_PUSH_HOOKS_LOG_LEVEL` | Overrides `logging.level`. |
|
|
205
255
|
| `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT` | Overrides `logging.print_llm_output`. |
|
|
206
256
|
| `AI_PUSH_HOOKS_MODEL` | Overrides `llm.model`. |
|
package/ai-push-hooks.toml
CHANGED
|
@@ -3,10 +3,11 @@ enabled = true
|
|
|
3
3
|
allow_push_on_error = false
|
|
4
4
|
require_clean_worktree = false
|
|
5
5
|
skip_on_sync_branch = true
|
|
6
|
+
base_branch = "main"
|
|
6
7
|
|
|
7
8
|
[llm]
|
|
8
9
|
runner = "opencode"
|
|
9
|
-
model = "openai/gpt-5.
|
|
10
|
+
model = "openai/gpt-5.5"
|
|
10
11
|
variant = ""
|
|
11
12
|
timeout_seconds = 800
|
|
12
13
|
max_parallel = 2
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-push-hooks",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Modular AI push-hook workflow runner",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"ai-push-hooks": "bin/ai-push-hooks.js"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"test": "uv run --with pytest pytest tests -q"
|
|
14
|
+
"test": "uv run --with pytest pytest tests -q",
|
|
15
|
+
"test:npm-pack": "node tests/npm-pack-smoke.mjs"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"bin",
|
package/pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ai-push-hooks"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.19"
|
|
8
8
|
description = "Modular AI push-hook workflow runner"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -21,6 +21,7 @@ classifiers = [
|
|
|
21
21
|
"Programming Language :: Python :: 3.12",
|
|
22
22
|
"Topic :: Software Development :: Version Control :: Git",
|
|
23
23
|
]
|
|
24
|
+
dependencies = ["tomli>=2.0.0; python_version < '3.11'"]
|
|
24
25
|
|
|
25
26
|
[project.scripts]
|
|
26
27
|
ai-push-hooks = "ai_push_hooks.cli:main"
|
|
@@ -63,8 +63,11 @@ class ArtifactStore:
|
|
|
63
63
|
|
|
64
64
|
def resolve_input(self, state: ModuleRuntimeState, reference: str) -> pathlib.Path:
|
|
65
65
|
if ":" in reference:
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
try:
|
|
67
|
+
module_and_step, artifact_name = reference.split("/", 1)
|
|
68
|
+
module_id, step_id = module_and_step.split(":", 1)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise HookError(f"Invalid artifact reference: {reference}") from exc
|
|
68
71
|
key = f"{module_id}:{step_id}/{artifact_name}"
|
|
69
72
|
else:
|
|
70
73
|
key = reference
|
|
@@ -72,6 +75,11 @@ class ArtifactStore:
|
|
|
72
75
|
if path is None:
|
|
73
76
|
path = state.artifacts.get(reference)
|
|
74
77
|
if path is None:
|
|
78
|
+
if ":" in reference:
|
|
79
|
+
raise HookError(
|
|
80
|
+
f"Unknown artifact reference: {reference}. Artifact references are module-local; "
|
|
81
|
+
"use '<step>/<artifact>' from an earlier step in the same module."
|
|
82
|
+
)
|
|
75
83
|
raise HookError(f"Unknown artifact reference: {reference}")
|
|
76
84
|
return path
|
|
77
85
|
|
package/src/ai_push_hooks/cli.py
CHANGED
|
@@ -28,10 +28,8 @@ def init_config(template: str, force: bool, cwd: pathlib.Path | None = None) ->
|
|
|
28
28
|
raise HookError("Only `minimal-docs` is supported")
|
|
29
29
|
target_dir = cwd or pathlib.Path.cwd()
|
|
30
30
|
config_path = target_dir / "ai-push-hooks.toml"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if existing_path is not None and not force:
|
|
34
|
-
raise HookError(f"Refusing to overwrite existing config without --force: {existing_path}")
|
|
31
|
+
if config_path.exists() and not force:
|
|
32
|
+
raise HookError(f"Refusing to overwrite existing config without --force: {config_path}")
|
|
35
33
|
config_path.write_text(MINIMAL_DOCS_TEMPLATE, encoding="utf-8")
|
|
36
34
|
sys.stdout.write(str(config_path) + "\n")
|
|
37
35
|
return 0
|
|
@@ -1,189 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import copy
|
|
4
|
-
import json
|
|
5
3
|
import os
|
|
6
4
|
import pathlib
|
|
7
|
-
import re
|
|
8
5
|
from typing import Any
|
|
9
6
|
|
|
7
|
+
from .executors.exec import env_bool
|
|
10
8
|
from .prompts_builtin import BUILTIN_PROMPTS
|
|
11
9
|
from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
|
|
12
|
-
from .executors.exec import env_bool
|
|
13
10
|
|
|
14
11
|
try:
|
|
15
12
|
import tomllib
|
|
16
13
|
except ModuleNotFoundError: # pragma: no cover
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
DEFAULT_CONFIG_RAW: dict[str, Any] = {
|
|
20
|
-
"general": {
|
|
21
|
-
"enabled": True,
|
|
22
|
-
"allow_push_on_error": False,
|
|
23
|
-
"require_clean_worktree": False,
|
|
24
|
-
"skip_on_sync_branch": True,
|
|
25
|
-
},
|
|
26
|
-
"llm": {
|
|
27
|
-
"runner": "opencode",
|
|
28
|
-
"model": "openai/gpt-5.3-codex",
|
|
29
|
-
"variant": "",
|
|
30
|
-
"timeout_seconds": 800,
|
|
31
|
-
"max_parallel": 2,
|
|
32
|
-
"json_max_retries": 2,
|
|
33
|
-
"invalid_json_feedback_max_chars": 6000,
|
|
34
|
-
"json_retry_new_session": True,
|
|
35
|
-
"delete_session_after_run": True,
|
|
36
|
-
"max_diff_bytes": 180000,
|
|
37
|
-
"session_title_prefix": "ai-push-hooks",
|
|
38
|
-
},
|
|
39
|
-
"logging": {
|
|
40
|
-
"level": "status",
|
|
41
|
-
"jsonl": True,
|
|
42
|
-
"dir": ".git/ai-push-hooks/logs",
|
|
43
|
-
"capture_llm_transcript": True,
|
|
44
|
-
"transcript_dir": ".git/ai-push-hooks/transcripts",
|
|
45
|
-
"summary_dir": ".git/ai-push-hooks/summaries",
|
|
46
|
-
"print_llm_output": False,
|
|
47
|
-
},
|
|
48
|
-
"workflow": {"modules": ["docs"]},
|
|
49
|
-
"modules": {
|
|
50
|
-
"docs": {
|
|
51
|
-
"enabled": True,
|
|
52
|
-
"steps": [
|
|
53
|
-
{"id": "collect", "type": "collect", "collector": "docs_context"},
|
|
54
|
-
{
|
|
55
|
-
"id": "query",
|
|
56
|
-
"type": "llm",
|
|
57
|
-
"inputs": ["collect/push.diff", "collect/changed-files.txt"],
|
|
58
|
-
"output": "queries.json",
|
|
59
|
-
"schema": "string_array",
|
|
60
|
-
"fallback_prompt_id": "docs-query-basic",
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
"id": "analyze",
|
|
64
|
-
"type": "llm",
|
|
65
|
-
"inputs": [
|
|
66
|
-
"collect/push.diff",
|
|
67
|
-
"collect/docs-context.txt",
|
|
68
|
-
"query/queries.json",
|
|
69
|
-
"collect/recent-commits.txt",
|
|
70
|
-
],
|
|
71
|
-
"output": "issues.json",
|
|
72
|
-
"schema": "docs_issue_array",
|
|
73
|
-
"fallback_prompt_id": "docs-analysis-basic",
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
"id": "apply",
|
|
77
|
-
"type": "apply",
|
|
78
|
-
"inputs": ["collect/push.diff", "collect/docs-context.txt", "analyze/issues.json"],
|
|
79
|
-
"allow_paths": ["README.md", "docs/**/*.md"],
|
|
80
|
-
"fallback_prompt_id": "docs-apply-basic",
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
"id": "assert",
|
|
84
|
-
"type": "assert",
|
|
85
|
-
"inputs": ["apply/result.json"],
|
|
86
|
-
"assertion": "docs_apply_requires_manual_commit",
|
|
87
|
-
},
|
|
88
|
-
]
|
|
89
|
-
}
|
|
90
|
-
},
|
|
91
|
-
}
|
|
14
|
+
import tomli as tomllib
|
|
92
15
|
|
|
93
16
|
ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
|
|
94
17
|
|
|
95
18
|
|
|
96
|
-
def _parse_multiline_string(lines: list[str], index: int, initial: str) -> tuple[str, int]:
|
|
97
|
-
chunks: list[str] = []
|
|
98
|
-
value = initial[3:]
|
|
99
|
-
while True:
|
|
100
|
-
end_index = value.find('"""')
|
|
101
|
-
if end_index >= 0:
|
|
102
|
-
chunks.append(value[:end_index])
|
|
103
|
-
return "\n".join(chunks), index
|
|
104
|
-
chunks.append(value)
|
|
105
|
-
index += 1
|
|
106
|
-
if index >= len(lines):
|
|
107
|
-
raise HookError("Unterminated multiline string in TOML fallback parser")
|
|
108
|
-
value = lines[index]
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _assign_path(root: dict[str, Any], path: list[str], value: Any, array_mode: bool = False) -> dict[str, Any]:
|
|
112
|
-
current: Any = root
|
|
113
|
-
for part in path[:-1]:
|
|
114
|
-
if isinstance(current, list):
|
|
115
|
-
if not current:
|
|
116
|
-
current.append({})
|
|
117
|
-
current = current[-1]
|
|
118
|
-
current = current.setdefault(part, {})
|
|
119
|
-
key = path[-1]
|
|
120
|
-
if array_mode:
|
|
121
|
-
items = current.setdefault(key, [])
|
|
122
|
-
if not isinstance(items, list):
|
|
123
|
-
raise HookError(f"Invalid array-of-table path: {'.'.join(path)}")
|
|
124
|
-
item: dict[str, Any] = {}
|
|
125
|
-
items.append(item)
|
|
126
|
-
return item
|
|
127
|
-
current[key] = value
|
|
128
|
-
return current
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def _parse_scalar(raw: str) -> Any:
|
|
132
|
-
raw = raw.strip()
|
|
133
|
-
if raw.startswith('"') and raw.endswith('"'):
|
|
134
|
-
return raw[1:-1]
|
|
135
|
-
if raw in {"true", "false"}:
|
|
136
|
-
return raw == "true"
|
|
137
|
-
if re.fullmatch(r"-?\d+", raw):
|
|
138
|
-
return int(raw)
|
|
139
|
-
if raw.startswith("[") and raw.endswith("]"):
|
|
140
|
-
return json.loads(raw)
|
|
141
|
-
return raw
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def parse_toml_fallback(raw: str) -> dict[str, Any]:
|
|
145
|
-
parsed: dict[str, Any] = {}
|
|
146
|
-
lines = raw.splitlines()
|
|
147
|
-
current: Any = parsed
|
|
148
|
-
index = 0
|
|
149
|
-
while index < len(lines):
|
|
150
|
-
line = lines[index].strip()
|
|
151
|
-
index += 1
|
|
152
|
-
if not line or line.startswith("#"):
|
|
153
|
-
continue
|
|
154
|
-
if line.startswith("[[") and line.endswith("]]"):
|
|
155
|
-
path = [part.strip() for part in line[2:-2].split(".") if part.strip()]
|
|
156
|
-
current = _assign_path(parsed, path, None, array_mode=True)
|
|
157
|
-
continue
|
|
158
|
-
if line.startswith("[") and line.endswith("]"):
|
|
159
|
-
path = [part.strip() for part in line[1:-1].split(".") if part.strip()]
|
|
160
|
-
current = parsed
|
|
161
|
-
for part in path:
|
|
162
|
-
current = current.setdefault(part, {})
|
|
163
|
-
continue
|
|
164
|
-
if "=" not in line:
|
|
165
|
-
continue
|
|
166
|
-
key, value = line.split("=", 1)
|
|
167
|
-
key = key.strip()
|
|
168
|
-
value = value.strip()
|
|
169
|
-
if value.startswith('"""'):
|
|
170
|
-
parsed_value, index = _parse_multiline_string(lines, index - 1, value)
|
|
171
|
-
else:
|
|
172
|
-
parsed_value = _parse_scalar(value)
|
|
173
|
-
current[key] = parsed_value
|
|
174
|
-
return parsed
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
178
|
-
merged = copy.deepcopy(base)
|
|
179
|
-
for key, value in override.items():
|
|
180
|
-
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
181
|
-
merged[key] = deep_merge(merged[key], value)
|
|
182
|
-
else:
|
|
183
|
-
merged[key] = copy.deepcopy(value)
|
|
184
|
-
return merged
|
|
185
|
-
|
|
186
|
-
|
|
187
19
|
def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
188
20
|
step_type = str(raw.get("type", "")).strip()
|
|
189
21
|
if step_type not in SUPPORTED_STEP_TYPES:
|
|
@@ -272,6 +104,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
272
104
|
"allow_push_on_error": config.general.allow_push_on_error,
|
|
273
105
|
"require_clean_worktree": config.general.require_clean_worktree,
|
|
274
106
|
"skip_on_sync_branch": config.general.skip_on_sync_branch,
|
|
107
|
+
"base_branch": config.general.base_branch,
|
|
275
108
|
},
|
|
276
109
|
"llm": config.llm.__dict__.copy(),
|
|
277
110
|
"logging": config.logging.__dict__.copy(),
|
|
@@ -296,6 +129,9 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
296
129
|
allow_dirty = env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
|
|
297
130
|
if allow_dirty is True:
|
|
298
131
|
raw["general"]["require_clean_worktree"] = False
|
|
132
|
+
base_branch = os.getenv("AI_PUSH_HOOKS_BASE_BRANCH")
|
|
133
|
+
if base_branch:
|
|
134
|
+
raw["general"]["base_branch"] = base_branch.strip() or "main"
|
|
299
135
|
|
|
300
136
|
logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
|
|
301
137
|
if logging_level:
|
|
@@ -315,19 +151,18 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
315
151
|
return _build_config(raw)
|
|
316
152
|
|
|
317
153
|
|
|
318
|
-
def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path
|
|
319
|
-
config_path
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
return _apply_env_overrides(_build_config(raw)), config_path
|
|
154
|
+
def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
|
|
155
|
+
config_path = repo_root / "ai-push-hooks.toml"
|
|
156
|
+
if not config_path.exists():
|
|
157
|
+
raise HookError(
|
|
158
|
+
"Missing required config file `ai-push-hooks.toml` in repo root. "
|
|
159
|
+
"Run `ai-push-hooks init --template minimal-docs` first"
|
|
160
|
+
)
|
|
161
|
+
text = config_path.read_text(encoding="utf-8")
|
|
162
|
+
loaded = tomllib.loads(text)
|
|
163
|
+
if not isinstance(loaded, dict):
|
|
164
|
+
raise HookError(f"Invalid config format in {config_path}")
|
|
165
|
+
return _apply_env_overrides(_build_config(loaded)), config_path
|
|
331
166
|
|
|
332
167
|
|
|
333
168
|
def resolve_prompt_text(repo_root: pathlib.Path, step: StepConfig) -> str:
|
|
@@ -8,6 +8,17 @@ from .exec import list_repo_changes, path_matches
|
|
|
8
8
|
from .llm import call_opencode, finalize_opencode_session
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def _snapshot_file_contents(repo_root: pathlib.Path, paths: set[str]) -> dict[str, bytes | None]:
|
|
12
|
+
snapshot: dict[str, bytes | None] = {}
|
|
13
|
+
for path in paths:
|
|
14
|
+
full_path = repo_root / path
|
|
15
|
+
if full_path.is_file():
|
|
16
|
+
snapshot[path] = full_path.read_bytes()
|
|
17
|
+
else:
|
|
18
|
+
snapshot[path] = None
|
|
19
|
+
return snapshot
|
|
20
|
+
|
|
21
|
+
|
|
11
22
|
def run_apply_step(
|
|
12
23
|
context: RuntimeContext,
|
|
13
24
|
state: ModuleRuntimeState,
|
|
@@ -23,6 +34,7 @@ def run_apply_step(
|
|
|
23
34
|
return {"changed": False, "changed_files": [], "skipped": True}
|
|
24
35
|
|
|
25
36
|
baseline = list_repo_changes(context.repo_root)
|
|
37
|
+
baseline_contents = _snapshot_file_contents(context.repo_root, baseline)
|
|
26
38
|
files = list(input_paths)
|
|
27
39
|
agents = context.repo_root / "AGENTS.md"
|
|
28
40
|
if agents.exists():
|
|
@@ -41,7 +53,14 @@ def run_apply_step(
|
|
|
41
53
|
raise HookError(f"Apply step failed: {details}")
|
|
42
54
|
|
|
43
55
|
after = list_repo_changes(context.repo_root)
|
|
44
|
-
|
|
56
|
+
newly_dirty = after - baseline
|
|
57
|
+
after_baseline_contents = _snapshot_file_contents(context.repo_root, baseline)
|
|
58
|
+
changed_while_dirty = {
|
|
59
|
+
path
|
|
60
|
+
for path, before_content in baseline_contents.items()
|
|
61
|
+
if before_content != after_baseline_contents[path]
|
|
62
|
+
}
|
|
63
|
+
changed_files = sorted(newly_dirty | changed_while_dirty)
|
|
45
64
|
unexpected = [
|
|
46
65
|
path for path in changed_files if not any(path_matches(path, pattern) for pattern in step.allow_paths)
|
|
47
66
|
]
|
|
@@ -129,7 +129,7 @@ def path_matches(path: str, pattern: str) -> bool:
|
|
|
129
129
|
|
|
130
130
|
def list_repo_changes(repo_root: pathlib.Path) -> set[str]:
|
|
131
131
|
changes: set[str] = set()
|
|
132
|
-
output = git
|
|
132
|
+
output = run_command(["git", "status", "--short"], cwd=repo_root).stdout
|
|
133
133
|
for line in output.splitlines():
|
|
134
134
|
payload = line[3:].strip()
|
|
135
135
|
if payload:
|
|
@@ -141,7 +141,9 @@ def collect_ranges_from_stdin(
|
|
|
141
141
|
repo_root: pathlib.Path,
|
|
142
142
|
remote_name: str,
|
|
143
143
|
stdin_lines: list[str],
|
|
144
|
+
base_branch: str = "main",
|
|
144
145
|
) -> list[str]:
|
|
146
|
+
base_branch = base_branch.strip() or "main"
|
|
145
147
|
ranges: set[str] = set()
|
|
146
148
|
for line in stdin_lines:
|
|
147
149
|
parts = line.strip().split()
|
|
@@ -160,7 +162,7 @@ def collect_ranges_from_stdin(
|
|
|
160
162
|
ranges.add(f"{remote_sha}..{local_sha}")
|
|
161
163
|
else:
|
|
162
164
|
merge_base = git(
|
|
163
|
-
repo_root, ["merge-base", local_sha, f"{remote_name}/
|
|
165
|
+
repo_root, ["merge-base", local_sha, f"{remote_name}/{base_branch}"], check=False
|
|
164
166
|
)
|
|
165
167
|
if merge_base:
|
|
166
168
|
ranges.add(f"{merge_base}..{local_sha}")
|
|
@@ -438,7 +440,8 @@ def gh_pr_create_executor(
|
|
|
438
440
|
if existing_pr:
|
|
439
441
|
return {"skipped": False, "pr_url": existing_pr, "already_exists": True}
|
|
440
442
|
|
|
441
|
-
|
|
443
|
+
default_base_branch = context.config.general.base_branch.strip() or "main"
|
|
444
|
+
base_branch = str(payload.get("base_branch", default_base_branch)).strip() or default_base_branch
|
|
442
445
|
head_branch = str(payload.get("head_branch", branch_name)).strip() or branch_name
|
|
443
446
|
title = sanitize_pr_title(str(payload.get("title", "")).strip(), branch_name)
|
|
444
447
|
body = str(payload.get("body", "")).strip()
|
|
@@ -32,7 +32,7 @@ def resolve_opencode_executable() -> str:
|
|
|
32
32
|
cli_path = shutil.which("opencode-cli")
|
|
33
33
|
if cli_path:
|
|
34
34
|
return cli_path
|
|
35
|
-
raise HookError("opencode
|
|
35
|
+
raise HookError("opencode is required but not installed")
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def parse_opencode_json_run_output(raw: str) -> tuple[str | None, str]:
|
|
@@ -73,7 +73,9 @@ def run_hook(
|
|
|
73
73
|
return 0
|
|
74
74
|
|
|
75
75
|
actual_stdin = list(stdin_lines) if stdin_lines is not None else [line.rstrip("\n") for line in sys.stdin]
|
|
76
|
-
ranges = collect_ranges_from_stdin(
|
|
76
|
+
ranges = collect_ranges_from_stdin(
|
|
77
|
+
repo_root, remote_name or "origin", actual_stdin, config.general.base_branch
|
|
78
|
+
)
|
|
77
79
|
changed_files = collect_changed_files(repo_root, ranges) if ranges else []
|
|
78
80
|
diff_text = collect_diff(repo_root, ranges, config.llm.max_diff_bytes) if ranges else ""
|
|
79
81
|
run_id = generate_run_id()
|
|
@@ -104,6 +106,7 @@ def run_hook(
|
|
|
104
106
|
"changed_files": changed_files,
|
|
105
107
|
"diff_text": diff_text,
|
|
106
108
|
"branch_name": current_branch(repo_root),
|
|
109
|
+
"base_branch": config.general.base_branch,
|
|
107
110
|
"sync_branch": "beads-sync",
|
|
108
111
|
},
|
|
109
112
|
)
|
|
@@ -14,6 +14,7 @@ from ..types import CollectorResult, RuntimeContext
|
|
|
14
14
|
|
|
15
15
|
def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
16
16
|
branch_name = current_branch(context.repo_root)
|
|
17
|
+
base_branch = context.config.general.base_branch.strip() or "main"
|
|
17
18
|
flag_env = ""
|
|
18
19
|
for step in state.module.steps:
|
|
19
20
|
if step.when_env:
|
|
@@ -25,7 +26,7 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
25
26
|
skip_module=True,
|
|
26
27
|
skip_reason="PR create env flag is not enabled",
|
|
27
28
|
)
|
|
28
|
-
if not branch_name or branch_name in {"HEAD",
|
|
29
|
+
if not branch_name or branch_name in {"HEAD", base_branch} or not is_feature_branch(branch_name):
|
|
29
30
|
return CollectorResult(
|
|
30
31
|
artifacts={"pr-context.txt": f"branch={branch_name}\n"},
|
|
31
32
|
skip_module=True,
|
|
@@ -61,7 +62,7 @@ def collect_pr_context(context: RuntimeContext, state: Any) -> CollectorResult:
|
|
|
61
62
|
"pr-context.txt": "\n".join(
|
|
62
63
|
[
|
|
63
64
|
f"branch={branch_name}",
|
|
64
|
-
f"base_branch=
|
|
65
|
+
f"base_branch={base_branch}",
|
|
65
66
|
f"remote_name={context.remote_name or 'origin'}",
|
|
66
67
|
]
|
|
67
68
|
)
|
|
@@ -64,10 +64,11 @@ enabled = true
|
|
|
64
64
|
allow_push_on_error = false
|
|
65
65
|
require_clean_worktree = false
|
|
66
66
|
skip_on_sync_branch = true
|
|
67
|
+
base_branch = "main"
|
|
67
68
|
|
|
68
69
|
[llm]
|
|
69
70
|
runner = "opencode"
|
|
70
|
-
model = "openai/gpt-5.
|
|
71
|
+
model = "openai/gpt-5.5"
|
|
71
72
|
variant = ""
|
|
72
73
|
timeout_seconds = 800
|
|
73
74
|
max_parallel = 2
|
|
@@ -23,12 +23,13 @@ class GeneralConfig:
|
|
|
23
23
|
allow_push_on_error: bool = False
|
|
24
24
|
require_clean_worktree: bool = False
|
|
25
25
|
skip_on_sync_branch: bool = True
|
|
26
|
+
base_branch: str = "main"
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
@dataclass(frozen=True)
|
|
29
30
|
class LlmConfig:
|
|
30
31
|
runner: str = "opencode"
|
|
31
|
-
model: str = "openai/gpt-5.
|
|
32
|
+
model: str = "openai/gpt-5.5"
|
|
32
33
|
variant: str = ""
|
|
33
34
|
timeout_seconds: int = 800
|
|
34
35
|
max_parallel: int = 2
|