ai-push-hooks 0.1.18 → 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 +52 -15
- 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/config.py +7 -86
- 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/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
|
|
@@ -30,36 +47,52 @@ Requirements:
|
|
|
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
|
|
54
|
+
mise exec -- ai-push-hooks init --template minimal-docs
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
3. Add the single repository-owned runner at `scripts/hooks/pre-push-runner.sh`:
|
|
37
58
|
|
|
38
59
|
```bash
|
|
39
|
-
|
|
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"
|
|
40
75
|
```
|
|
41
76
|
|
|
42
|
-
|
|
77
|
+
Make the runner executable:
|
|
43
78
|
|
|
44
79
|
```bash
|
|
45
|
-
|
|
46
|
-
# or
|
|
47
|
-
pnpm exec ai-push-hooks init --template minimal-docs
|
|
80
|
+
chmod +x scripts/hooks/pre-push-runner.sh
|
|
48
81
|
```
|
|
49
82
|
|
|
50
|
-
|
|
51
|
-
4. Wire it into your pre-push hook manager. Lefthook example:
|
|
83
|
+
4. Configure Lefthook to invoke only that runner in `lefthook.yml`:
|
|
52
84
|
|
|
53
85
|
```yaml
|
|
54
86
|
pre-push:
|
|
55
87
|
commands:
|
|
56
|
-
|
|
57
|
-
run:
|
|
88
|
+
repository-pre-push:
|
|
89
|
+
run: bash scripts/hooks/pre-push-runner.sh {1} {2}
|
|
90
|
+
use_stdin: true
|
|
58
91
|
```
|
|
59
92
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
63
96
|
|
|
64
97
|
## Commands
|
|
65
98
|
|
|
@@ -99,13 +132,14 @@ If installed as a local npm/pnpm dependency, run commands with `npx` or `pnpm ex
|
|
|
99
132
|
| `allow_push_on_error` | bool | `false` | If `true`, push continues even when workflow fails. |
|
|
100
133
|
| `require_clean_worktree` | bool | `false` | If `true`, aborts when local changes exist. |
|
|
101
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. |
|
|
102
136
|
|
|
103
137
|
### `[llm]`
|
|
104
138
|
|
|
105
139
|
| Key | Type | Default | Description |
|
|
106
140
|
| --- | --- | --- | --- |
|
|
107
141
|
| `runner` | string | `"opencode"` | LLM runner label (currently OpenCode flow). |
|
|
108
|
-
| `model` | string | `"openai/gpt-5.
|
|
142
|
+
| `model` | string | `"openai/gpt-5.5"` | Model passed to OpenCode. |
|
|
109
143
|
| `variant` | string | `""` | Optional OpenCode variant. |
|
|
110
144
|
| `timeout_seconds` | int | `800` | Timeout per LLM invocation and related OpenCode calls. |
|
|
111
145
|
| `max_parallel` | int | `2` | Max concurrent read-only steps (`collect`, `llm`). |
|
|
@@ -161,6 +195,8 @@ If installed as a local npm/pnpm dependency, run commands with `npx` or `pnpm ex
|
|
|
161
195
|
|
|
162
196
|
`llm` and `apply` are promptable step types: at least one of `prompt`, `prompt_file`, or `fallback_prompt_id` must be set.
|
|
163
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
|
+
|
|
164
200
|
### Supported handler and schema values
|
|
165
201
|
|
|
166
202
|
#### Collectors
|
|
@@ -214,6 +250,7 @@ Boolean env parsing accepts: `1`, `true`, `yes`, `y`, `on` and `0`, `false`, `no
|
|
|
214
250
|
| `AI_PUSH_HOOKS_ALLOW_PUSH_ON_ERROR` | Overrides `general.allow_push_on_error`. |
|
|
215
251
|
| `AI_PUSH_HOOKS_REQUIRE_CLEAN` | Overrides `general.require_clean_worktree`. |
|
|
216
252
|
| `AI_PUSH_HOOKS_ALLOW_DIRTY` | If true, forces `general.require_clean_worktree = false`. |
|
|
253
|
+
| `AI_PUSH_HOOKS_BASE_BRANCH` | Overrides `general.base_branch`. |
|
|
217
254
|
| `AI_PUSH_HOOKS_LOG_LEVEL` | Overrides `logging.level`. |
|
|
218
255
|
| `AI_PUSH_HOOKS_PRINT_LLM_OUTPUT` | Overrides `logging.print_llm_output`. |
|
|
219
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
|
|
|
@@ -1,104 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import pathlib
|
|
6
|
-
import re
|
|
7
5
|
from typing import Any
|
|
8
6
|
|
|
7
|
+
from .executors.exec import env_bool
|
|
9
8
|
from .prompts_builtin import BUILTIN_PROMPTS
|
|
10
9
|
from .types import GeneralConfig, HookConfig, HookError, LlmConfig, LoggingConfig, ModuleConfig, StepConfig, SUPPORTED_STEP_TYPES, WorkflowConfig
|
|
11
|
-
from .executors.exec import env_bool
|
|
12
10
|
|
|
13
11
|
try:
|
|
14
12
|
import tomllib
|
|
15
13
|
except ModuleNotFoundError: # pragma: no cover
|
|
16
|
-
|
|
14
|
+
import tomli as tomllib
|
|
17
15
|
|
|
18
16
|
ALLOWED_TOP_LEVEL_KEYS = {"general", "llm", "logging", "workflow", "modules"}
|
|
19
17
|
|
|
20
18
|
|
|
21
|
-
def _parse_multiline_string(lines: list[str], index: int, initial: str) -> tuple[str, int]:
|
|
22
|
-
chunks: list[str] = []
|
|
23
|
-
value = initial[3:]
|
|
24
|
-
while True:
|
|
25
|
-
end_index = value.find('"""')
|
|
26
|
-
if end_index >= 0:
|
|
27
|
-
chunks.append(value[:end_index])
|
|
28
|
-
return "\n".join(chunks), index
|
|
29
|
-
chunks.append(value)
|
|
30
|
-
index += 1
|
|
31
|
-
if index >= len(lines):
|
|
32
|
-
raise HookError("Unterminated multiline string in TOML fallback parser")
|
|
33
|
-
value = lines[index]
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _assign_path(root: dict[str, Any], path: list[str], value: Any, array_mode: bool = False) -> dict[str, Any]:
|
|
37
|
-
current: Any = root
|
|
38
|
-
for part in path[:-1]:
|
|
39
|
-
if isinstance(current, list):
|
|
40
|
-
if not current:
|
|
41
|
-
current.append({})
|
|
42
|
-
current = current[-1]
|
|
43
|
-
current = current.setdefault(part, {})
|
|
44
|
-
key = path[-1]
|
|
45
|
-
if array_mode:
|
|
46
|
-
items = current.setdefault(key, [])
|
|
47
|
-
if not isinstance(items, list):
|
|
48
|
-
raise HookError(f"Invalid array-of-table path: {'.'.join(path)}")
|
|
49
|
-
item: dict[str, Any] = {}
|
|
50
|
-
items.append(item)
|
|
51
|
-
return item
|
|
52
|
-
current[key] = value
|
|
53
|
-
return current
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def _parse_scalar(raw: str) -> Any:
|
|
57
|
-
raw = raw.strip()
|
|
58
|
-
if raw.startswith('"') and raw.endswith('"'):
|
|
59
|
-
return raw[1:-1]
|
|
60
|
-
if raw in {"true", "false"}:
|
|
61
|
-
return raw == "true"
|
|
62
|
-
if re.fullmatch(r"-?\d+", raw):
|
|
63
|
-
return int(raw)
|
|
64
|
-
if raw.startswith("[") and raw.endswith("]"):
|
|
65
|
-
return json.loads(raw)
|
|
66
|
-
return raw
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def parse_toml_fallback(raw: str) -> dict[str, Any]:
|
|
70
|
-
parsed: dict[str, Any] = {}
|
|
71
|
-
lines = raw.splitlines()
|
|
72
|
-
current: Any = parsed
|
|
73
|
-
index = 0
|
|
74
|
-
while index < len(lines):
|
|
75
|
-
line = lines[index].strip()
|
|
76
|
-
index += 1
|
|
77
|
-
if not line or line.startswith("#"):
|
|
78
|
-
continue
|
|
79
|
-
if line.startswith("[[") and line.endswith("]]"):
|
|
80
|
-
path = [part.strip() for part in line[2:-2].split(".") if part.strip()]
|
|
81
|
-
current = _assign_path(parsed, path, None, array_mode=True)
|
|
82
|
-
continue
|
|
83
|
-
if line.startswith("[") and line.endswith("]"):
|
|
84
|
-
path = [part.strip() for part in line[1:-1].split(".") if part.strip()]
|
|
85
|
-
current = parsed
|
|
86
|
-
for part in path:
|
|
87
|
-
current = current.setdefault(part, {})
|
|
88
|
-
continue
|
|
89
|
-
if "=" not in line:
|
|
90
|
-
continue
|
|
91
|
-
key, value = line.split("=", 1)
|
|
92
|
-
key = key.strip()
|
|
93
|
-
value = value.strip()
|
|
94
|
-
if value.startswith('"""'):
|
|
95
|
-
parsed_value, index = _parse_multiline_string(lines, index - 1, value)
|
|
96
|
-
else:
|
|
97
|
-
parsed_value = _parse_scalar(value)
|
|
98
|
-
current[key] = parsed_value
|
|
99
|
-
return parsed
|
|
100
|
-
|
|
101
|
-
|
|
102
19
|
def _normalize_step(raw: dict[str, Any]) -> StepConfig:
|
|
103
20
|
step_type = str(raw.get("type", "")).strip()
|
|
104
21
|
if step_type not in SUPPORTED_STEP_TYPES:
|
|
@@ -187,6 +104,7 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
187
104
|
"allow_push_on_error": config.general.allow_push_on_error,
|
|
188
105
|
"require_clean_worktree": config.general.require_clean_worktree,
|
|
189
106
|
"skip_on_sync_branch": config.general.skip_on_sync_branch,
|
|
107
|
+
"base_branch": config.general.base_branch,
|
|
190
108
|
},
|
|
191
109
|
"llm": config.llm.__dict__.copy(),
|
|
192
110
|
"logging": config.logging.__dict__.copy(),
|
|
@@ -211,6 +129,9 @@ def _apply_env_overrides(config: HookConfig) -> HookConfig:
|
|
|
211
129
|
allow_dirty = env_bool("AI_PUSH_HOOKS_ALLOW_DIRTY")
|
|
212
130
|
if allow_dirty is True:
|
|
213
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"
|
|
214
135
|
|
|
215
136
|
logging_level = os.getenv("AI_PUSH_HOOKS_LOG_LEVEL")
|
|
216
137
|
if logging_level:
|
|
@@ -238,7 +159,7 @@ def load_config(repo_root: pathlib.Path) -> tuple[HookConfig, pathlib.Path]:
|
|
|
238
159
|
"Run `ai-push-hooks init --template minimal-docs` first"
|
|
239
160
|
)
|
|
240
161
|
text = config_path.read_text(encoding="utf-8")
|
|
241
|
-
loaded = tomllib.loads(text)
|
|
162
|
+
loaded = tomllib.loads(text)
|
|
242
163
|
if not isinstance(loaded, dict):
|
|
243
164
|
raise HookError(f"Invalid config format in {config_path}")
|
|
244
165
|
return _apply_env_overrides(_build_config(loaded)), config_path
|
|
@@ -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()
|
|
@@ -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
|