refactorai-cli 0.4.16__tar.gz → 0.5.0__tar.gz
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.
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/PKG-INFO +2 -2
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/pyproject.toml +2 -2
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/__init__.py +1 -1
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/auth.py +4 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/client.py +55 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/hook_cmds.py +89 -4
- refactorai_cli-0.5.0/refactorai_cli/commands/pre_push_cmds.py +151 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/run_cmds.py +153 -14
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/watch_cmds.py +34 -0
- refactorai_cli-0.5.0/refactorai_cli/commit_telemetry.py +196 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/main.py +6 -0
- refactorai_cli-0.5.0/refactorai_cli/pre_push_gate.py +112 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/PKG-INFO +2 -2
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/SOURCES.txt +3 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/requires.txt +1 -1
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/README.md +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/cloud_rr.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/__init__.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/account_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/auth_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/branch_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/cloud_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/engine_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/model_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/request_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/rules_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/runtime_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/runtime_proxy_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/setup_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/toolchains_cmds.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commit_queue.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/control_plane.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/credentials.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/dotenv_loader.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/git_scope.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/local_constitution.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/local_engine_runtime.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/local_paths.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/model_policy.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/refactor_branch_store.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/review_runner.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/runtime_manager.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/settings.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/setup_flow.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/watch_state.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/watch_supervisor.py +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/dependency_links.txt +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/entry_points.txt +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli.egg-info/top_level.txt +0 -0
- {refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local-first CLI for the refactor platform
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
|
|
|
8
8
|
Requires-Dist: httpx>=0.27.0
|
|
9
9
|
Requires-Dist: rich>=13.7.0
|
|
10
10
|
Requires-Dist: PyYAML>=6.0.1
|
|
11
|
-
Requires-Dist: refactorai-core>=3.2.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.2.11
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "refactorai-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = "Local-first CLI for the refactor platform"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
"httpx>=0.27.0",
|
|
13
13
|
"rich>=13.7.0",
|
|
14
14
|
"PyYAML>=6.0.1",
|
|
15
|
-
"refactorai-core>=3.2.
|
|
15
|
+
"refactorai-core>=3.2.11",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
[project.scripts]
|
|
@@ -27,6 +27,7 @@ class AuthContext:
|
|
|
27
27
|
account_id: str
|
|
28
28
|
quota_remaining: int | None
|
|
29
29
|
cached: bool
|
|
30
|
+
account_slug: str = ""
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
def _fingerprint(key: str) -> str:
|
|
@@ -56,6 +57,7 @@ def _write_cache(key: str, payload: dict) -> None:
|
|
|
56
57
|
expires_epoch = _parse_expiry(payload.get("expires_at"))
|
|
57
58
|
record = {
|
|
58
59
|
"account_id": payload.get("account_id"),
|
|
60
|
+
"account_slug": payload.get("account_slug"),
|
|
59
61
|
"quota_remaining": payload.get("quota_remaining"),
|
|
60
62
|
"validation_token": payload.get("validation_token"),
|
|
61
63
|
"expires_at": payload.get("expires_at"),
|
|
@@ -100,6 +102,7 @@ def ensure_authenticated(
|
|
|
100
102
|
account_id=cached.get("account_id", "unknown"),
|
|
101
103
|
quota_remaining=cached.get("quota_remaining"),
|
|
102
104
|
cached=True,
|
|
105
|
+
account_slug=cached.get("account_slug") or "",
|
|
103
106
|
)
|
|
104
107
|
|
|
105
108
|
client = client or PlatformClient()
|
|
@@ -117,4 +120,5 @@ def ensure_authenticated(
|
|
|
117
120
|
account_id=payload.get("account_id", "unknown"),
|
|
118
121
|
quota_remaining=payload.get("quota_remaining"),
|
|
119
122
|
cached=False,
|
|
123
|
+
account_slug=payload.get("account_slug") or "",
|
|
120
124
|
)
|
|
@@ -45,6 +45,61 @@ class PlatformClient:
|
|
|
45
45
|
)
|
|
46
46
|
return response.json()
|
|
47
47
|
|
|
48
|
+
def create_project(
|
|
49
|
+
self,
|
|
50
|
+
developer_key: str,
|
|
51
|
+
account_slug: str,
|
|
52
|
+
name: str,
|
|
53
|
+
*,
|
|
54
|
+
idempotency_key: str,
|
|
55
|
+
provider: str = "github",
|
|
56
|
+
default_branch: str = "main",
|
|
57
|
+
visibility: str = "private",
|
|
58
|
+
repo_url: str | None = None,
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""Create a project under ``account_slug`` and return the created record.
|
|
61
|
+
|
|
62
|
+
Used by ``refactor init`` to auto-provision a cloud project for the local
|
|
63
|
+
commit loop. ``repo_url`` is optional; it can be attached later from the
|
|
64
|
+
web UI. Raises ``PlatformError`` on any rejection or transport failure.
|
|
65
|
+
"""
|
|
66
|
+
body: dict = {
|
|
67
|
+
"name": name,
|
|
68
|
+
"provider": provider,
|
|
69
|
+
"default_branch": default_branch,
|
|
70
|
+
"visibility": visibility,
|
|
71
|
+
}
|
|
72
|
+
if repo_url:
|
|
73
|
+
body["repo_url"] = repo_url
|
|
74
|
+
try:
|
|
75
|
+
response = httpx.post(
|
|
76
|
+
f"{self.base_url}/v1/accounts/{account_slug}/projects",
|
|
77
|
+
headers={
|
|
78
|
+
"Authorization": f"Bearer {developer_key}",
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
"Idempotency-Key": idempotency_key,
|
|
81
|
+
},
|
|
82
|
+
json=body,
|
|
83
|
+
timeout=self.timeout,
|
|
84
|
+
)
|
|
85
|
+
except httpx.HTTPError as exc:
|
|
86
|
+
raise PlatformError(f"Could not reach platform at {self.base_url}: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
if response.status_code == 401:
|
|
89
|
+
raise PlatformError("Developer key is invalid or revoked", status_code=401)
|
|
90
|
+
if response.status_code >= 400:
|
|
91
|
+
detail = ""
|
|
92
|
+
try:
|
|
93
|
+
detail = str((response.json() or {}).get("detail", ""))
|
|
94
|
+
except Exception:
|
|
95
|
+
detail = response.text[:200]
|
|
96
|
+
suffix = f": {detail}" if detail else ""
|
|
97
|
+
raise PlatformError(
|
|
98
|
+
f"Project creation failed ({response.status_code}){suffix}",
|
|
99
|
+
status_code=response.status_code,
|
|
100
|
+
)
|
|
101
|
+
return response.json() if response.content else {}
|
|
102
|
+
|
|
48
103
|
def list_key_accounts(self, developer_key: str) -> list[dict]:
|
|
49
104
|
try:
|
|
50
105
|
response = httpx.get(
|
|
@@ -27,6 +27,7 @@ app = typer.Typer(
|
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
_HOOK_MARKER = "RefactorAI post-commit hook"
|
|
30
|
+
_PRE_PUSH_MARKER = "RefactorAI pre-push hook"
|
|
30
31
|
_DEFAULT_HOOKS_DIR = ".githooks"
|
|
31
32
|
|
|
32
33
|
# Non-blocking + fail-open: capture HEAD, then detach the enqueue so `git commit`
|
|
@@ -46,6 +47,18 @@ fi
|
|
|
46
47
|
exit 0
|
|
47
48
|
"""
|
|
48
49
|
|
|
50
|
+
# Blocking-capable pre-push gate. `refactor pre-push` is itself fail-open and only
|
|
51
|
+
# ever exits non-zero under a `strict` policy, so the default (warn) never blocks.
|
|
52
|
+
_PRE_PUSH_SCRIPT = """#!/usr/bin/env bash
|
|
53
|
+
# RefactorAI pre-push hook (docs/44 Phase 2 gate).
|
|
54
|
+
# Delegates to `refactor pre-push`, which enforces the project's pre-push policy.
|
|
55
|
+
if ! command -v refactor >/dev/null 2>&1; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
refactor pre-push
|
|
59
|
+
exit $?
|
|
60
|
+
"""
|
|
61
|
+
|
|
49
62
|
|
|
50
63
|
def _require_repo(project_root: Path) -> str:
|
|
51
64
|
root = git_scope.repo_root(project_root)
|
|
@@ -98,14 +111,39 @@ def perform_install(
|
|
|
98
111
|
"message": "could not set core.hooksPath",
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
pre_push_status = _install_pre_push(hooks_path, force=force)
|
|
115
|
+
|
|
101
116
|
return {
|
|
102
117
|
"status": "installed",
|
|
103
118
|
"rel_dir": rel_dir,
|
|
104
119
|
"hook_file": hook_file,
|
|
120
|
+
"pre_push_status": pre_push_status,
|
|
121
|
+
"pre_push_file": hooks_path / "pre-push",
|
|
105
122
|
"message": f"installed post-commit hook -> {hook_file}",
|
|
106
123
|
}
|
|
107
124
|
|
|
108
125
|
|
|
126
|
+
def _install_pre_push(hooks_path: Path, *, force: bool) -> str:
|
|
127
|
+
"""Write the pre-push gate hook. Additive + best-effort (never fails install).
|
|
128
|
+
|
|
129
|
+
Returns ``installed`` (written/refreshed), ``exists_foreign`` (a non-RefactorAI
|
|
130
|
+
pre-push hook is present and ``force`` was not set) or ``error``.
|
|
131
|
+
"""
|
|
132
|
+
pre_push_file = hooks_path / "pre-push"
|
|
133
|
+
if (
|
|
134
|
+
pre_push_file.exists()
|
|
135
|
+
and _PRE_PUSH_MARKER not in _safe_read(pre_push_file)
|
|
136
|
+
and not force
|
|
137
|
+
):
|
|
138
|
+
return "exists_foreign"
|
|
139
|
+
try:
|
|
140
|
+
pre_push_file.write_text(_PRE_PUSH_SCRIPT, encoding="utf-8")
|
|
141
|
+
pre_push_file.chmod(pre_push_file.stat().st_mode | 0o755)
|
|
142
|
+
except OSError:
|
|
143
|
+
return "error"
|
|
144
|
+
return "installed"
|
|
145
|
+
|
|
146
|
+
|
|
109
147
|
@app.command("install")
|
|
110
148
|
def install(
|
|
111
149
|
hooks_dir: str | None = typer.Option(
|
|
@@ -133,11 +171,24 @@ def install(
|
|
|
133
171
|
console.print(f"[red]hook: could not install:[/red] {result['message']}")
|
|
134
172
|
raise typer.Exit(code=1)
|
|
135
173
|
|
|
174
|
+
pre_push_status = result.get("pre_push_status")
|
|
175
|
+
if pre_push_status == "installed":
|
|
176
|
+
pre_push_line = f"[green]Installed[/green] pre-push gate hook -> {result.get('pre_push_file')}\n"
|
|
177
|
+
elif pre_push_status == "exists_foreign":
|
|
178
|
+
pre_push_line = (
|
|
179
|
+
"[yellow]Skipped pre-push hook:[/yellow] a non-RefactorAI pre-push hook already exists "
|
|
180
|
+
"(re-run with --force to overwrite).\n"
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
pre_push_line = "[yellow]Could not install the pre-push gate hook.[/yellow]\n"
|
|
184
|
+
|
|
136
185
|
console.print(
|
|
137
186
|
f"[green]Installed[/green] post-commit hook -> {result['hook_file']}\n"
|
|
187
|
+
f"{pre_push_line}"
|
|
138
188
|
f"[dim]core.hooksPath = {result['rel_dir']}[/dim]\n"
|
|
139
189
|
"Every commit now enqueues a local review event. "
|
|
140
190
|
"Open the Refactor Terminal with `refactor watch`.\n"
|
|
191
|
+
"[dim]The pre-push gate is advisory (warn) until a platform admin sets it to strict.[/dim]\n"
|
|
141
192
|
"[dim]Tip: set watch.auto_open + watch.terminal (or REFACTOR_WATCH_AUTO_OPEN / "
|
|
142
193
|
"REFACTOR_WATCH_TERMINAL) to open it automatically on each commit.[/dim]"
|
|
143
194
|
)
|
|
@@ -155,23 +206,32 @@ def uninstall(
|
|
|
155
206
|
root = Path(_require_repo(Path.cwd()))
|
|
156
207
|
rel_dir = git_scope.get_config(root, "core.hooksPath") or _DEFAULT_HOOKS_DIR
|
|
157
208
|
hook_file = (root / rel_dir / "post-commit").resolve()
|
|
209
|
+
pre_push_file = (root / rel_dir / "pre-push").resolve()
|
|
158
210
|
|
|
159
|
-
removed =
|
|
211
|
+
removed = []
|
|
160
212
|
if hook_file.exists() and _HOOK_MARKER in _safe_read(hook_file):
|
|
161
213
|
try:
|
|
162
214
|
hook_file.unlink()
|
|
163
|
-
removed
|
|
215
|
+
removed.append("post-commit")
|
|
164
216
|
except OSError as exc:
|
|
165
217
|
console.print(f"[red]hook: could not remove hook:[/red] {exc}")
|
|
166
218
|
raise typer.Exit(code=1) from exc
|
|
167
219
|
|
|
220
|
+
if pre_push_file.exists() and _PRE_PUSH_MARKER in _safe_read(pre_push_file):
|
|
221
|
+
try:
|
|
222
|
+
pre_push_file.unlink()
|
|
223
|
+
removed.append("pre-push")
|
|
224
|
+
except OSError as exc:
|
|
225
|
+
console.print(f"[red]hook: could not remove pre-push hook:[/red] {exc}")
|
|
226
|
+
raise typer.Exit(code=1) from exc
|
|
227
|
+
|
|
168
228
|
if reset_hooks_path:
|
|
169
229
|
git_scope.unset_config(root, "core.hooksPath")
|
|
170
230
|
|
|
171
231
|
if removed:
|
|
172
|
-
console.print("[green]Removed[/green] the RefactorAI
|
|
232
|
+
console.print(f"[green]Removed[/green] the RefactorAI {' and '.join(removed)} hook(s).")
|
|
173
233
|
else:
|
|
174
|
-
console.print("[dim]hook: no RefactorAI
|
|
234
|
+
console.print("[dim]hook: no RefactorAI hooks found to remove.[/dim]")
|
|
175
235
|
|
|
176
236
|
|
|
177
237
|
@app.command("enqueue")
|
|
@@ -201,6 +261,23 @@ def enqueue(
|
|
|
201
261
|
# and fail-open so it never affects the commit flow (docs/42 `R29`, Phase 4).
|
|
202
262
|
if enqueued:
|
|
203
263
|
_maybe_auto_open(project_root, verbose)
|
|
264
|
+
_maybe_report_commit(project_root, event, verbose)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _maybe_report_commit(project_root: Path, event: dict | None, verbose: bool) -> None:
|
|
268
|
+
"""Best-effort opt-in commit telemetry (docs/44 Phase 3). Never raises."""
|
|
269
|
+
if not event:
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
from refactorai_cli import commit_telemetry
|
|
273
|
+
|
|
274
|
+
body = commit_telemetry.build_commit_event_from_queue(event)
|
|
275
|
+
posted = commit_telemetry.post_commit_event(project_root, body)
|
|
276
|
+
if verbose and posted:
|
|
277
|
+
console.print("[dim]hook: reported commit telemetry.[/dim]")
|
|
278
|
+
except Exception as exc: # fail-open
|
|
279
|
+
if verbose:
|
|
280
|
+
console.print(f"[yellow]hook: telemetry skipped ({exc}).[/yellow]")
|
|
204
281
|
|
|
205
282
|
|
|
206
283
|
def _watch_settings(project_root: Path) -> dict:
|
|
@@ -245,7 +322,9 @@ def status() -> None:
|
|
|
245
322
|
|
|
246
323
|
rel_dir = git_scope.get_config(root, "core.hooksPath")
|
|
247
324
|
hook_file = (root / (rel_dir or _DEFAULT_HOOKS_DIR) / "post-commit").resolve()
|
|
325
|
+
pre_push_file = (root / (rel_dir or _DEFAULT_HOOKS_DIR) / "pre-push").resolve()
|
|
248
326
|
is_ours = hook_file.exists() and _HOOK_MARKER in _safe_read(hook_file)
|
|
327
|
+
pre_push_ours = pre_push_file.exists() and _PRE_PUSH_MARKER in _safe_read(pre_push_file)
|
|
249
328
|
q_path = commit_queue.queue_path(root)
|
|
250
329
|
|
|
251
330
|
console.print(f"Repository: [bold]{root}[/bold]")
|
|
@@ -256,6 +335,12 @@ def status() -> None:
|
|
|
256
335
|
console.print(f"post-commit hook: [yellow]present but not RefactorAI[/yellow] ({hook_file})")
|
|
257
336
|
else:
|
|
258
337
|
console.print("post-commit hook: [red]not installed[/red] (run `refactor hook install`)")
|
|
338
|
+
if pre_push_ours:
|
|
339
|
+
console.print(f"pre-push hook: [green]installed[/green] ({pre_push_file})")
|
|
340
|
+
elif pre_push_file.exists():
|
|
341
|
+
console.print(f"pre-push hook: [yellow]present but not RefactorAI[/yellow] ({pre_push_file})")
|
|
342
|
+
else:
|
|
343
|
+
console.print("pre-push hook: [red]not installed[/red] (run `refactor hook install`)")
|
|
259
344
|
console.print(f"Queue: {q_path}")
|
|
260
345
|
console.print(f"Pending events: [bold]{commit_queue.pending_count(root)}[/bold]")
|
|
261
346
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""`refactor pre-push`: the local pre-push quality gate (docs/44 Phase 2).
|
|
2
|
+
|
|
3
|
+
Invoked by the installed git ``pre-push`` hook. It reads the project's pre-push
|
|
4
|
+
policy from ``refactor.config`` (falling back to safe defaults), inspects the
|
|
5
|
+
local Refactor Request store, and decides whether to allow the push.
|
|
6
|
+
|
|
7
|
+
Contract:
|
|
8
|
+
- Fail-open: any unexpected error allows the push (exit 0). The gate must never
|
|
9
|
+
wedge a developer because of a local error.
|
|
10
|
+
- ``off``/``warn`` never block. Only ``strict`` can exit non-zero.
|
|
11
|
+
- Escape hatch: ``REFACTOR_SKIP=1`` bypasses a strict block when policy allows;
|
|
12
|
+
``REFACTOR_SKIP_REASON`` supplies the reason (recorded when auditing is on).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from refactorai_cli import git_scope, pre_push_gate
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
30
|
+
_HIGH_SEVERITIES = {"high", "critical"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _project_root() -> Path:
|
|
34
|
+
root = git_scope.repo_root(Path.cwd())
|
|
35
|
+
return Path(root) if root else Path.cwd()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_policy(project_root: Path) -> dict:
|
|
39
|
+
"""Best-effort read of the ``pre_push`` policy from refactor.config settings."""
|
|
40
|
+
policy = dict(pre_push_gate.DEFAULT_POLICY)
|
|
41
|
+
policy["block_on"] = list(pre_push_gate.DEFAULT_POLICY["block_on"])
|
|
42
|
+
try:
|
|
43
|
+
from refactor_core.constitution import CONFIG_FILENAME, find_project_files, load_config
|
|
44
|
+
|
|
45
|
+
consti_path, _ = find_project_files(project_root)
|
|
46
|
+
if consti_path is None:
|
|
47
|
+
return policy
|
|
48
|
+
cfg = load_config(consti_path.parent / CONFIG_FILENAME)
|
|
49
|
+
settings = dict(getattr(cfg, "settings", {}) or {})
|
|
50
|
+
stored = settings.get("pre_push")
|
|
51
|
+
if isinstance(stored, dict):
|
|
52
|
+
policy.update(stored)
|
|
53
|
+
if not isinstance(policy.get("block_on"), (list, tuple)):
|
|
54
|
+
policy["block_on"] = list(pre_push_gate.DEFAULT_POLICY["block_on"])
|
|
55
|
+
except Exception:
|
|
56
|
+
# Fail-open on any config error: keep defaults (warn).
|
|
57
|
+
pass
|
|
58
|
+
return policy
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _count_requests(project_root: Path) -> tuple[int, int]:
|
|
62
|
+
"""Return ``(open_requests, high_severity_open)`` from the local RR store."""
|
|
63
|
+
try:
|
|
64
|
+
from refactor_core.refactor_requests import pending_requests
|
|
65
|
+
|
|
66
|
+
pending = pending_requests(project_root)
|
|
67
|
+
except Exception:
|
|
68
|
+
return 0, 0
|
|
69
|
+
high = sum(1 for r in pending if str(getattr(r, "severity", "")).lower() in _HIGH_SEVERITIES)
|
|
70
|
+
return len(pending), high
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _record_skip(project_root: Path, *, reasons: list[str], reason: str) -> None:
|
|
74
|
+
try:
|
|
75
|
+
from refactor_core.paths import ensure_dir, project_store_dir
|
|
76
|
+
|
|
77
|
+
audit_path = ensure_dir(project_store_dir(project_root)) / "pre_push_audit.jsonl"
|
|
78
|
+
entry = {
|
|
79
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
80
|
+
"event": "pre_push_bypass",
|
|
81
|
+
"reason": reason,
|
|
82
|
+
"blockers": reasons,
|
|
83
|
+
"user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
|
|
84
|
+
}
|
|
85
|
+
with audit_path.open("a", encoding="utf-8") as handle:
|
|
86
|
+
handle.write(json.dumps(entry) + "\n")
|
|
87
|
+
except Exception:
|
|
88
|
+
# Auditing is best-effort and must never affect the push decision.
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def pre_push(
|
|
93
|
+
ctx: typer.Context,
|
|
94
|
+
json_out: bool = typer.Option(False, "--json", help="Emit the gate decision as JSON."),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Evaluate the pre-push gate for the current repository."""
|
|
97
|
+
try:
|
|
98
|
+
project_root = _project_root()
|
|
99
|
+
policy = _load_policy(project_root)
|
|
100
|
+
open_requests, high_severity_open = _count_requests(project_root)
|
|
101
|
+
skip_requested = str(os.environ.get("REFACTOR_SKIP", "")).strip().lower() in _TRUTHY
|
|
102
|
+
skip_reason = os.environ.get("REFACTOR_SKIP_REASON", "")
|
|
103
|
+
|
|
104
|
+
decision = pre_push_gate.evaluate(
|
|
105
|
+
policy,
|
|
106
|
+
open_requests=open_requests,
|
|
107
|
+
high_severity_open=high_severity_open,
|
|
108
|
+
skip_requested=skip_requested,
|
|
109
|
+
skip_reason=skip_reason,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if decision.skipped and policy.get("audit_skips", True):
|
|
113
|
+
_record_skip(project_root, reasons=decision.reasons, reason=decision.skip_reason)
|
|
114
|
+
|
|
115
|
+
if json_out:
|
|
116
|
+
console.print_json(
|
|
117
|
+
data={
|
|
118
|
+
"allow": decision.allow,
|
|
119
|
+
"mode": decision.mode,
|
|
120
|
+
"reasons": decision.reasons,
|
|
121
|
+
"skipped": decision.skipped,
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
_render(decision)
|
|
126
|
+
|
|
127
|
+
if not decision.allow:
|
|
128
|
+
raise typer.Exit(code=1)
|
|
129
|
+
except typer.Exit:
|
|
130
|
+
raise
|
|
131
|
+
except Exception as exc: # fail-open: never wedge a push on a local error
|
|
132
|
+
console.print(f"[yellow]pre-push: gate skipped (fail-open): {exc}[/yellow]")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _render(decision: pre_push_gate.GateDecision) -> None:
|
|
136
|
+
if decision.mode == "off":
|
|
137
|
+
return
|
|
138
|
+
if not decision.reasons:
|
|
139
|
+
console.print("[green]pre-push:[/green] gate passed — nothing blocking this push.")
|
|
140
|
+
return
|
|
141
|
+
heading = "[red]pre-push: push blocked[/red]" if not decision.allow else "[yellow]pre-push: warnings[/yellow]"
|
|
142
|
+
console.print(heading)
|
|
143
|
+
for reason in decision.reasons:
|
|
144
|
+
console.print(f" • {reason}")
|
|
145
|
+
if decision.skipped:
|
|
146
|
+
console.print("[yellow] bypassed via REFACTOR_SKIP[/yellow]")
|
|
147
|
+
elif not decision.allow:
|
|
148
|
+
console.print(
|
|
149
|
+
"[dim] Resolve with `refactor watch` / `refactor code`, or bypass with "
|
|
150
|
+
"REFACTOR_SKIP=1 git push.[/dim]"
|
|
151
|
+
)
|
|
@@ -102,11 +102,13 @@ from refactor_core.store import (
|
|
|
102
102
|
)
|
|
103
103
|
from refactor_core.testcmd import resolve_test_command
|
|
104
104
|
|
|
105
|
-
from refactorai_cli.auth import AuthError, ensure_authenticated
|
|
105
|
+
from refactorai_cli.auth import AuthContext, AuthError, ensure_authenticated
|
|
106
|
+
from refactorai_cli.client import PlatformClient, PlatformError
|
|
107
|
+
from refactorai_cli.credentials import load_credentials, save_credentials
|
|
106
108
|
from refactorai_cli.dotenv_loader import DotenvResult, load_project_dotenv
|
|
107
109
|
from refactorai_cli.local_engine_runtime import DEFAULT_ENGINE_PORT, read_engine_state
|
|
108
110
|
from refactorai_cli.runtime_manager import runtime_status, runtime_version_dir
|
|
109
|
-
from refactorai_cli.settings import platform_url
|
|
111
|
+
from refactorai_cli.settings import mask_key, platform_url
|
|
110
112
|
from refactorai_cli.setup_flow import (
|
|
111
113
|
BACKEND_BYOK,
|
|
112
114
|
BACKEND_LOCAL_SERVER,
|
|
@@ -1033,7 +1035,14 @@ def init(
|
|
|
1033
1035
|
help=f"Overwrite existing {CONSTITUTION_FILENAME} and {CONFIG_FILENAME}.",
|
|
1034
1036
|
),
|
|
1035
1037
|
) -> None:
|
|
1036
|
-
"""Create `refactor.consti` + `refactor.config` and register the project.
|
|
1038
|
+
"""Create `refactor.consti` + `refactor.config` and register the project.
|
|
1039
|
+
|
|
1040
|
+
`init` is online-first: it authenticates (prompting for a developer key when
|
|
1041
|
+
needed) and auto-creates a cloud project so the local commit loop reports to
|
|
1042
|
+
the platform out of the box. The folder name becomes the project name (edit
|
|
1043
|
+
it later in the web UI) and the generated project id is written to
|
|
1044
|
+
`refactor.config`. No provider or repository details are required here.
|
|
1045
|
+
"""
|
|
1037
1046
|
try:
|
|
1038
1047
|
project_root = Path.cwd()
|
|
1039
1048
|
except OSError:
|
|
@@ -1052,14 +1061,48 @@ def init(
|
|
|
1052
1061
|
"Use --force to overwrite."
|
|
1053
1062
|
)
|
|
1054
1063
|
raise typer.Exit(code=1)
|
|
1064
|
+
|
|
1065
|
+
# Authenticate and provision the cloud project BEFORE writing any files, so a
|
|
1066
|
+
# failure leaves the working tree untouched (fail-fast, no partial state).
|
|
1067
|
+
auth_ctx = _ensure_init_auth(project_root)
|
|
1068
|
+
account_slug = auth_ctx.account_slug or load_credentials().get("active_account_slug") or ""
|
|
1069
|
+
if not account_slug:
|
|
1070
|
+
console.print(
|
|
1071
|
+
"[red]Could not resolve your account.[/red] Run `refactor login` and retry."
|
|
1072
|
+
)
|
|
1073
|
+
raise typer.Exit(code=1)
|
|
1074
|
+
|
|
1075
|
+
project_name = project_root.name or "refactor-project"
|
|
1076
|
+
client = PlatformClient()
|
|
1077
|
+
try:
|
|
1078
|
+
created = client.create_project(
|
|
1079
|
+
auth_ctx.key.key,
|
|
1080
|
+
account_slug,
|
|
1081
|
+
project_name,
|
|
1082
|
+
idempotency_key=str(uuid.uuid4()),
|
|
1083
|
+
)
|
|
1084
|
+
except PlatformError as exc:
|
|
1085
|
+
console.print(f"[red]Could not create cloud project:[/red] {exc}")
|
|
1086
|
+
raise typer.Exit(code=1) from exc
|
|
1087
|
+
project_id = str(created.get("id") or "").strip()
|
|
1088
|
+
if not project_id:
|
|
1089
|
+
console.print("[red]Cloud project creation returned no id.[/red] Please retry.")
|
|
1090
|
+
raise typer.Exit(code=1)
|
|
1091
|
+
|
|
1055
1092
|
consti_target.write_text(DEFAULT_CONSTITUTION, encoding="utf-8")
|
|
1056
1093
|
config_text, config_profile = _default_config_for_init()
|
|
1094
|
+
config_text = _inject_project_id(config_text, project_id)
|
|
1057
1095
|
config_target.write_text(config_text, encoding="utf-8")
|
|
1058
1096
|
|
|
1059
1097
|
constitution = load_constitution(consti_target)
|
|
1060
1098
|
record = register_project(project_root, constitution_hash=constitution.content_hash)
|
|
1061
1099
|
console.print(f"[green]Created[/green] {consti_target}")
|
|
1062
1100
|
console.print(f"[green]Created[/green] {config_target}")
|
|
1101
|
+
console.print(
|
|
1102
|
+
f"[green]Linked[/green] to cloud project [bold]{project_name}[/bold] "
|
|
1103
|
+
f"(account {account_slug}). The project id is saved in {CONFIG_FILENAME}; "
|
|
1104
|
+
"rename the project or attach a repository anytime from the web UI."
|
|
1105
|
+
)
|
|
1063
1106
|
if config_profile == "local_server":
|
|
1064
1107
|
console.print(
|
|
1065
1108
|
"[green]Configured[/green] refactor.config for local server setup "
|
|
@@ -1081,6 +1124,79 @@ def init(
|
|
|
1081
1124
|
_offer_commit_hook(project_root)
|
|
1082
1125
|
|
|
1083
1126
|
|
|
1127
|
+
def _ensure_init_auth(project_root: Path) -> AuthContext:
|
|
1128
|
+
"""Return a validated ``AuthContext`` for `init`, prompting to log in if needed.
|
|
1129
|
+
|
|
1130
|
+
Tries the resolved developer key first (env/config/credentials). When none is
|
|
1131
|
+
configured or it is rejected, an interactive session prompts for a developer
|
|
1132
|
+
key and stores it in the central credentials file. Non-interactive runs fail
|
|
1133
|
+
fast with guidance rather than hanging on a prompt.
|
|
1134
|
+
"""
|
|
1135
|
+
try:
|
|
1136
|
+
return ensure_authenticated(project_root, force_remote=True)
|
|
1137
|
+
except AuthError:
|
|
1138
|
+
pass
|
|
1139
|
+
|
|
1140
|
+
if not sys.stdin.isatty():
|
|
1141
|
+
console.print(
|
|
1142
|
+
"[red]`refactor init` requires authentication.[/red] "
|
|
1143
|
+
"Set REFACTOR_API_KEY or run `refactor login` first."
|
|
1144
|
+
)
|
|
1145
|
+
raise typer.Exit(code=1)
|
|
1146
|
+
|
|
1147
|
+
console.print("[bold]Sign in to Refactor[/bold] to create your cloud project.")
|
|
1148
|
+
console.print(
|
|
1149
|
+
f"[dim]No account yet? Create one and generate a developer key at "
|
|
1150
|
+
f"{platform_url()}, then paste it below.[/dim]"
|
|
1151
|
+
)
|
|
1152
|
+
developer_key = typer.prompt("Developer key", hide_input=True).strip()
|
|
1153
|
+
if not developer_key:
|
|
1154
|
+
console.print("[red]No key provided.[/red]")
|
|
1155
|
+
raise typer.Exit(code=1)
|
|
1156
|
+
|
|
1157
|
+
client = PlatformClient()
|
|
1158
|
+
try:
|
|
1159
|
+
payload = client.validate_key(developer_key)
|
|
1160
|
+
except PlatformError as exc:
|
|
1161
|
+
console.print(f"[red]Login failed:[/red] {exc}")
|
|
1162
|
+
raise typer.Exit(code=1) from exc
|
|
1163
|
+
if not payload.get("valid", False):
|
|
1164
|
+
console.print("[red]Login failed:[/red] key is invalid or revoked.")
|
|
1165
|
+
raise typer.Exit(code=1)
|
|
1166
|
+
|
|
1167
|
+
creds = load_credentials()
|
|
1168
|
+
creds.update(
|
|
1169
|
+
{
|
|
1170
|
+
"developer_key": developer_key,
|
|
1171
|
+
"account_id": payload.get("account_id"),
|
|
1172
|
+
"active_account_id": payload.get("account_id"),
|
|
1173
|
+
"active_account_slug": payload.get("account_slug"),
|
|
1174
|
+
"platform_url": platform_url(),
|
|
1175
|
+
"masked_key": mask_key(developer_key),
|
|
1176
|
+
}
|
|
1177
|
+
)
|
|
1178
|
+
save_credentials(creds)
|
|
1179
|
+
console.print(
|
|
1180
|
+
f"[green]Logged in[/green] as account "
|
|
1181
|
+
f"[bold]{payload.get('account_id', 'unknown')}[/bold] ({mask_key(developer_key)})."
|
|
1182
|
+
)
|
|
1183
|
+
return ensure_authenticated(project_root, force_remote=False)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _inject_project_id(config_text: str, project_id: str) -> str:
|
|
1187
|
+
"""Persist the bound cloud ``project_id`` into refactor.config text.
|
|
1188
|
+
|
|
1189
|
+
Written as a top-level key so ``constitution.load_config`` exposes it under
|
|
1190
|
+
``settings['project_id']`` (which the commit-loop telemetry reads). Inserted
|
|
1191
|
+
right after the ``version:`` line, or prepended if that anchor is absent.
|
|
1192
|
+
"""
|
|
1193
|
+
line = f"project_id: {project_id}\n"
|
|
1194
|
+
anchor = "version: 1\n"
|
|
1195
|
+
if anchor in config_text:
|
|
1196
|
+
return config_text.replace(anchor, anchor + line, 1)
|
|
1197
|
+
return line + config_text
|
|
1198
|
+
|
|
1199
|
+
|
|
1084
1200
|
def _offer_commit_hook(project_root: Path) -> None:
|
|
1085
1201
|
"""Offer to wire the local commit-review hook during `init` (docs/41 `R28`).
|
|
1086
1202
|
|
|
@@ -4518,7 +4634,34 @@ def _cloud_doctor(
|
|
|
4518
4634
|
if execution_mode == "cloud_byok":
|
|
4519
4635
|
if provider_key:
|
|
4520
4636
|
# Env-key path: perform a lightweight inference ping so entitlement +
|
|
4521
|
-
# provider auth are validated together.
|
|
4637
|
+
# provider auth are validated together. Forward the configured
|
|
4638
|
+
# ``model_id`` (already ${ENV} interpolated by the constitution) so the
|
|
4639
|
+
# ping validates the *same* model real runs use. Doctor must not let the
|
|
4640
|
+
# backend/provider silently choose a default model because that can pass
|
|
4641
|
+
# while the configured run model is missing or unreachable.
|
|
4642
|
+
model_hint = str(constitution.get_setting("model_id", "") or "").strip()
|
|
4643
|
+
if not model_hint:
|
|
4644
|
+
console.print(
|
|
4645
|
+
"[red]model[/red]: no `model_id` configured in refactor.config."
|
|
4646
|
+
)
|
|
4647
|
+
console.print(
|
|
4648
|
+
"[yellow]doctor[/yellow]: you need to set `model_id` in "
|
|
4649
|
+
"refactor.config for the selected provider before doctor can "
|
|
4650
|
+
"validate model reachability."
|
|
4651
|
+
)
|
|
4652
|
+
raise typer.Exit(code=1)
|
|
4653
|
+
console.print(f"[bold]model[/bold]: {model_hint}")
|
|
4654
|
+
payload = {
|
|
4655
|
+
"stage": "review",
|
|
4656
|
+
"execution_mode": "cloud_byok",
|
|
4657
|
+
"provider": provider_name,
|
|
4658
|
+
"provider_key": provider_key,
|
|
4659
|
+
"constitution_hash": "doctor",
|
|
4660
|
+
"system_prompt": "doctor ping",
|
|
4661
|
+
"user_prompt": "doctor ping",
|
|
4662
|
+
"max_alerts": 1,
|
|
4663
|
+
"model_hint": model_hint,
|
|
4664
|
+
}
|
|
4522
4665
|
try:
|
|
4523
4666
|
response = httpx.post(
|
|
4524
4667
|
f"{platform_url()}/v1/inference/refactor",
|
|
@@ -4527,16 +4670,7 @@ def _cloud_doctor(
|
|
|
4527
4670
|
"Content-Type": "application/json",
|
|
4528
4671
|
"Idempotency-Key": f"doctor-cloud-byok-{uuid.uuid4().hex}",
|
|
4529
4672
|
},
|
|
4530
|
-
json=
|
|
4531
|
-
"stage": "review",
|
|
4532
|
-
"execution_mode": "cloud_byok",
|
|
4533
|
-
"provider": provider_name,
|
|
4534
|
-
"provider_key": provider_key,
|
|
4535
|
-
"constitution_hash": "doctor",
|
|
4536
|
-
"system_prompt": "doctor ping",
|
|
4537
|
-
"user_prompt": "doctor ping",
|
|
4538
|
-
"max_alerts": 1,
|
|
4539
|
-
},
|
|
4673
|
+
json=payload,
|
|
4540
4674
|
timeout=20.0,
|
|
4541
4675
|
)
|
|
4542
4676
|
except httpx.HTTPError as exc:
|
|
@@ -4561,6 +4695,11 @@ def _cloud_doctor(
|
|
|
4561
4695
|
f"[red]cloud BYOK ping failed[/red] ({response.status_code})"
|
|
4562
4696
|
+ (f": {detail}" if detail else ".")
|
|
4563
4697
|
)
|
|
4698
|
+
console.print(
|
|
4699
|
+
"[yellow]doctor[/yellow]: could not reach the configured model. "
|
|
4700
|
+
"Set a reachable `model_id` in refactor.config for the selected "
|
|
4701
|
+
"provider, then rerun `refactor doctor`."
|
|
4702
|
+
)
|
|
4564
4703
|
raise typer.Exit(code=1)
|
|
4565
4704
|
console.print("[green]cloud endpoint[/green]: reachable.")
|
|
4566
4705
|
console.print("[green]BYOK provider auth[/green]: OK")
|
|
@@ -316,6 +316,20 @@ def _load_initial_events(project_root: Path, since: str | None, limit: int) -> l
|
|
|
316
316
|
return events
|
|
317
317
|
|
|
318
318
|
|
|
319
|
+
def _report_node(project_root: Path, node) -> None:
|
|
320
|
+
"""Best-effort opt-in commit telemetry for a node transition (docs/44 Phase 3).
|
|
321
|
+
|
|
322
|
+
Fail-open: no-ops instantly unless the repo is bound to a platform project
|
|
323
|
+
(``REFACTOR_PROJECT_ID`` / ``project_id`` in config) and never raises.
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
from refactorai_cli import commit_telemetry
|
|
327
|
+
|
|
328
|
+
commit_telemetry.emit_node(project_root, node)
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
|
|
319
333
|
def process_pending(
|
|
320
334
|
project_root: Path,
|
|
321
335
|
state: WatchState,
|
|
@@ -349,6 +363,7 @@ def process_pending(
|
|
|
349
363
|
state.mark_reviewing(node.sha)
|
|
350
364
|
if on_update:
|
|
351
365
|
on_update(state)
|
|
366
|
+
_report_node(project_root, node)
|
|
352
367
|
intent = intent_for(node.sha) if intent_for else None
|
|
353
368
|
outcome = runner.run(
|
|
354
369
|
sha=node.sha,
|
|
@@ -361,6 +376,7 @@ def process_pending(
|
|
|
361
376
|
reviewed += 1
|
|
362
377
|
if on_update:
|
|
363
378
|
on_update(state)
|
|
379
|
+
_report_node(project_root, node)
|
|
364
380
|
# docs/43 Phase 1: fold current refactor-branch data into reviewed nodes so
|
|
365
381
|
# their phase (review/finalize/done) reflects any branch built by `[r]`.
|
|
366
382
|
refresh_finalize(project_root, state)
|
|
@@ -765,6 +781,7 @@ def watch(
|
|
|
765
781
|
|
|
766
782
|
project_root = _resolve_repo(Path.cwd())
|
|
767
783
|
_ensure_auth_in_window(json_output)
|
|
784
|
+
_report_heartbeat(project_root)
|
|
768
785
|
|
|
769
786
|
state = WatchState(repo_name=project_root.name)
|
|
770
787
|
try:
|
|
@@ -814,6 +831,23 @@ def _open_watch_window(
|
|
|
814
831
|
console.print("[dim]watch: closed (the commit hook/queue are unaffected).[/dim]")
|
|
815
832
|
|
|
816
833
|
|
|
834
|
+
def _report_heartbeat(project_root: Path) -> None:
|
|
835
|
+
"""Best-effort opt-in watch liveness ping (docs/44 Phase 3). Never raises."""
|
|
836
|
+
try:
|
|
837
|
+
import socket
|
|
838
|
+
|
|
839
|
+
from refactorai_cli import commit_telemetry
|
|
840
|
+
|
|
841
|
+
branch = ""
|
|
842
|
+
try:
|
|
843
|
+
branch = git_scope.current_branch(project_root) or ""
|
|
844
|
+
except Exception:
|
|
845
|
+
branch = ""
|
|
846
|
+
commit_telemetry.post_heartbeat(project_root, host=socket.gethostname(), branch=branch)
|
|
847
|
+
except Exception:
|
|
848
|
+
pass
|
|
849
|
+
|
|
850
|
+
|
|
817
851
|
def _load_watch_config(project_root: Path) -> dict:
|
|
818
852
|
"""Best-effort project-config settings dict (never raises)."""
|
|
819
853
|
try:
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Commit-loop telemetry: build + post commit events to the platform (docs/44 Phase 3).
|
|
2
|
+
|
|
3
|
+
The local loop is authoritative and offline-first (docs/41 Part G): telemetry is
|
|
4
|
+
strictly opt-in and *fail-open*. Every function here swallows errors and returns a
|
|
5
|
+
falsey result rather than raising, so a network hiccup, missing key, or unbound
|
|
6
|
+
repo can never delay or break a commit / review / push.
|
|
7
|
+
|
|
8
|
+
Telemetry only activates when the repo is bound to a platform project — resolved
|
|
9
|
+
from ``REFACTOR_PROJECT_ID`` or a ``project_id`` setting in ``refactor.config`` —
|
|
10
|
+
and can be turned off entirely with ``REFACTOR_NO_TELEMETRY=1``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from refactorai_cli import __version__
|
|
21
|
+
from refactorai_cli.settings import platform_url
|
|
22
|
+
|
|
23
|
+
ENV_PROJECT_ID = "REFACTOR_PROJECT_ID"
|
|
24
|
+
ENV_DISABLE = "REFACTOR_NO_TELEMETRY"
|
|
25
|
+
WATCH_VERSION = __version__
|
|
26
|
+
|
|
27
|
+
_TRUTHY = {"1", "true", "yes", "on"}
|
|
28
|
+
|
|
29
|
+
# Local watch-node states -> server commit-event states.
|
|
30
|
+
_STATE_MAP = {
|
|
31
|
+
"queued": "queued",
|
|
32
|
+
"reviewing": "reviewing",
|
|
33
|
+
"reviewed": "reviewed",
|
|
34
|
+
"resolved": "resolved",
|
|
35
|
+
"pushed": "pushed",
|
|
36
|
+
"error": "error",
|
|
37
|
+
}
|
|
38
|
+
# Local gate labels -> server gate_status enum.
|
|
39
|
+
_GATE_MAP = {
|
|
40
|
+
"": "unknown",
|
|
41
|
+
"unknown": "unknown",
|
|
42
|
+
"passed": "passed",
|
|
43
|
+
"pass": "passed",
|
|
44
|
+
"warn": "warn",
|
|
45
|
+
"warning": "warn",
|
|
46
|
+
"high": "high",
|
|
47
|
+
"blocked": "high",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def telemetry_disabled() -> bool:
|
|
52
|
+
return str(os.environ.get(ENV_DISABLE, "")).strip().lower() in _TRUTHY
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_settings(project_root: Path | str) -> dict:
|
|
56
|
+
"""Best-effort project-config settings dict (never raises)."""
|
|
57
|
+
try:
|
|
58
|
+
from refactor_core.constitution import (
|
|
59
|
+
CONFIG_FILENAME,
|
|
60
|
+
find_project_files,
|
|
61
|
+
load_config,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
consti_path, _ = find_project_files(Path(project_root))
|
|
65
|
+
if consti_path is None:
|
|
66
|
+
return {}
|
|
67
|
+
cfg = load_config(consti_path.parent / CONFIG_FILENAME)
|
|
68
|
+
return dict(getattr(cfg, "settings", {}) or {})
|
|
69
|
+
except Exception:
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve_project_id(project_root: Path | str, config: dict | None = None) -> str:
|
|
74
|
+
"""Resolve the bound platform project id, or ``""`` when unbound."""
|
|
75
|
+
env_pid = os.environ.get(ENV_PROJECT_ID, "").strip()
|
|
76
|
+
if env_pid:
|
|
77
|
+
return env_pid
|
|
78
|
+
settings = config if isinstance(config, dict) else _load_settings(project_root)
|
|
79
|
+
value = settings.get("project_id") if isinstance(settings, dict) else None
|
|
80
|
+
return str(value).strip() if value else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_commit_event(node_contract: dict, *, source: str = "local_commit") -> dict:
|
|
84
|
+
"""Map a watch node contract (``watch_state.to_contract``) to the ingest body."""
|
|
85
|
+
nc = node_contract or {}
|
|
86
|
+
scope = nc.get("scope") or {}
|
|
87
|
+
gate = nc.get("gate") or {}
|
|
88
|
+
state = _STATE_MAP.get(str(nc.get("state") or "").strip().lower(), "reviewed")
|
|
89
|
+
gate_status = _GATE_MAP.get(str(gate.get("status") or "").strip().lower(), "unknown")
|
|
90
|
+
return {
|
|
91
|
+
"sha": str(nc.get("sha") or ""),
|
|
92
|
+
"branch": str(nc.get("branch") or ""),
|
|
93
|
+
"subject": str(nc.get("subject") or ""),
|
|
94
|
+
"intent_summary": str(nc.get("intent_summary") or ""),
|
|
95
|
+
"state": state,
|
|
96
|
+
"gate_status": gate_status,
|
|
97
|
+
"high_findings": int(gate.get("high_findings") or 0),
|
|
98
|
+
"refactor_request_count": int(nc.get("refactor_requests") or 0),
|
|
99
|
+
"changed_files": int(scope.get("changed") or 0),
|
|
100
|
+
"associated_files": int(scope.get("associated") or 0),
|
|
101
|
+
"push_eligible": bool(nc.get("push_eligible", False)),
|
|
102
|
+
"source": source,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_commit_event_from_queue(event: dict) -> dict:
|
|
107
|
+
"""Map a raw commit-queue event (post-commit hook) to a ``queued`` ingest body."""
|
|
108
|
+
ev = event or {}
|
|
109
|
+
changed = ev.get("changed_files")
|
|
110
|
+
if isinstance(changed, list):
|
|
111
|
+
changed_count = len(changed)
|
|
112
|
+
else:
|
|
113
|
+
try:
|
|
114
|
+
changed_count = int(changed or 0)
|
|
115
|
+
except (TypeError, ValueError):
|
|
116
|
+
changed_count = 0
|
|
117
|
+
return {
|
|
118
|
+
"sha": str(ev.get("sha") or ""),
|
|
119
|
+
"branch": str(ev.get("branch") or ""),
|
|
120
|
+
"subject": str(ev.get("subject") or ""),
|
|
121
|
+
"intent_summary": "",
|
|
122
|
+
"state": "queued",
|
|
123
|
+
"gate_status": "unknown",
|
|
124
|
+
"high_findings": 0,
|
|
125
|
+
"refactor_request_count": 0,
|
|
126
|
+
"changed_files": changed_count,
|
|
127
|
+
"associated_files": 0,
|
|
128
|
+
"push_eligible": False,
|
|
129
|
+
"source": str(ev.get("trigger_source") or "local_commit"),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _auth_and_project(project_root: Path | str, config: dict | None) -> tuple[object | None, str]:
|
|
134
|
+
if telemetry_disabled():
|
|
135
|
+
return None, ""
|
|
136
|
+
project_id = resolve_project_id(project_root, config)
|
|
137
|
+
if not project_id:
|
|
138
|
+
return None, ""
|
|
139
|
+
try:
|
|
140
|
+
from refactorai_cli.auth import ensure_authenticated
|
|
141
|
+
|
|
142
|
+
auth = ensure_authenticated(Path(project_root))
|
|
143
|
+
except Exception:
|
|
144
|
+
return None, ""
|
|
145
|
+
return auth, project_id
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _post(url: str, key: str, body: dict, timeout: float) -> bool:
|
|
149
|
+
try:
|
|
150
|
+
resp = httpx.post(
|
|
151
|
+
url,
|
|
152
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
153
|
+
json=body,
|
|
154
|
+
timeout=timeout,
|
|
155
|
+
)
|
|
156
|
+
return resp.status_code < 400
|
|
157
|
+
except Exception:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def post_commit_event(
|
|
162
|
+
project_root: Path | str, body: dict, *, config: dict | None = None, timeout: float = 8.0
|
|
163
|
+
) -> bool:
|
|
164
|
+
"""Upsert one commit event. Best-effort + fail-open (returns success flag)."""
|
|
165
|
+
auth, project_id = _auth_and_project(project_root, config)
|
|
166
|
+
if not auth or not project_id:
|
|
167
|
+
return False
|
|
168
|
+
url = f"{platform_url()}/v1/projects/{project_id}/commit-events"
|
|
169
|
+
return _post(url, auth.key.key, body, timeout)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def post_heartbeat(
|
|
173
|
+
project_root: Path | str,
|
|
174
|
+
*,
|
|
175
|
+
host: str = "",
|
|
176
|
+
branch: str = "",
|
|
177
|
+
watch_version: str = WATCH_VERSION,
|
|
178
|
+
config: dict | None = None,
|
|
179
|
+
timeout: float = 8.0,
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""Report a ``refactor watch`` liveness ping. Best-effort + fail-open."""
|
|
182
|
+
auth, project_id = _auth_and_project(project_root, config)
|
|
183
|
+
if not auth or not project_id:
|
|
184
|
+
return False
|
|
185
|
+
url = f"{platform_url()}/v1/projects/{project_id}/watch/heartbeat"
|
|
186
|
+
body = {"host": host, "branch": branch, "watch_version": watch_version}
|
|
187
|
+
return _post(url, auth.key.key, body, timeout)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def emit_node(project_root: Path | str, node, *, config: dict | None = None) -> bool:
|
|
191
|
+
"""Build + post a commit event from a watch ``CommitNode``. Never raises."""
|
|
192
|
+
try:
|
|
193
|
+
body = build_commit_event(node.to_contract())
|
|
194
|
+
return post_commit_event(project_root, body, config=config)
|
|
195
|
+
except Exception:
|
|
196
|
+
return False
|
|
@@ -16,6 +16,7 @@ from refactorai_cli.commands import (
|
|
|
16
16
|
engine_cmds,
|
|
17
17
|
hook_cmds,
|
|
18
18
|
model_cmds,
|
|
19
|
+
pre_push_cmds,
|
|
19
20
|
request_cmds,
|
|
20
21
|
runtime_cmds,
|
|
21
22
|
runtime_proxy_cmds,
|
|
@@ -155,6 +156,11 @@ app.add_typer(toolchains_cmds.app, name="toolchains")
|
|
|
155
156
|
# Local commit-triggered review hook (R28 Phase 2, docs/41).
|
|
156
157
|
app.add_typer(hook_cmds.app, name="hook")
|
|
157
158
|
|
|
159
|
+
# Pre-push quality gate (docs/44 Phase 2); invoked by the git pre-push hook.
|
|
160
|
+
app.command("pre-push", context_settings={"ignore_unknown_options": True, "allow_extra_args": True})(
|
|
161
|
+
pre_push_cmds.pre_push
|
|
162
|
+
)
|
|
163
|
+
|
|
158
164
|
# Persistent Refactor Terminal (R28 Phase 3, docs/41).
|
|
159
165
|
app.add_typer(watch_cmds.watch_app, name="watch")
|
|
160
166
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Pure pre-push gate evaluation (docs/44 Phase 2).
|
|
2
|
+
|
|
3
|
+
Dependency-free so it is trivially unit-testable and safe to import from the
|
|
4
|
+
``refactor pre-push`` command. The command layer supplies the observed counts
|
|
5
|
+
(pending refactor requests, high-severity count, doctor status) and the resolved
|
|
6
|
+
policy; this module decides whether the push is allowed and why.
|
|
7
|
+
|
|
8
|
+
Modes:
|
|
9
|
+
- ``off`` — never inspect; always allow.
|
|
10
|
+
- ``warn`` — surface blockers but always allow the push (never exit non-zero).
|
|
11
|
+
- ``strict`` — block the push when a selected condition is unmet, unless the
|
|
12
|
+
developer uses the escape hatch (``REFACTOR_SKIP``) and policy permits it.
|
|
13
|
+
|
|
14
|
+
The gate is fail-open by contract: the *command* wraps evaluation in a
|
|
15
|
+
try/except and allows the push on any unexpected error. This module only encodes
|
|
16
|
+
the policy decision for well-formed inputs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
VALID_MODES = ("off", "warn", "strict")
|
|
24
|
+
|
|
25
|
+
DEFAULT_POLICY: dict = {
|
|
26
|
+
"mode": "warn",
|
|
27
|
+
"block_on": ["high_severity", "unresolved_requests"],
|
|
28
|
+
"allow_skip": True,
|
|
29
|
+
"require_skip_reason": False,
|
|
30
|
+
"audit_skips": True,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class GateDecision:
|
|
36
|
+
allow: bool
|
|
37
|
+
mode: str
|
|
38
|
+
reasons: list[str] = field(default_factory=list)
|
|
39
|
+
skipped: bool = False
|
|
40
|
+
skip_reason: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _normalize_mode(policy: dict) -> str:
|
|
44
|
+
mode = str((policy or {}).get("mode") or "warn").strip().lower()
|
|
45
|
+
return mode if mode in VALID_MODES else "warn"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def blocking_reasons(
|
|
49
|
+
policy: dict,
|
|
50
|
+
*,
|
|
51
|
+
open_requests: int,
|
|
52
|
+
high_severity_open: int,
|
|
53
|
+
doctor_failed: bool = False,
|
|
54
|
+
) -> list[str]:
|
|
55
|
+
"""Reasons the push would be gated, independent of mode/escape-hatch."""
|
|
56
|
+
block_on = set((policy or {}).get("block_on") or [])
|
|
57
|
+
reasons: list[str] = []
|
|
58
|
+
if "high_severity" in block_on and high_severity_open > 0:
|
|
59
|
+
reasons.append(
|
|
60
|
+
f"{high_severity_open} high-severity refactor request(s) still open"
|
|
61
|
+
)
|
|
62
|
+
if "unresolved_requests" in block_on and open_requests > 0:
|
|
63
|
+
reasons.append(f"{open_requests} unresolved refactor request(s)")
|
|
64
|
+
if "failed_doctor" in block_on and doctor_failed:
|
|
65
|
+
reasons.append("engine doctor check is failing")
|
|
66
|
+
return reasons
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def evaluate(
|
|
70
|
+
policy: dict,
|
|
71
|
+
*,
|
|
72
|
+
open_requests: int,
|
|
73
|
+
high_severity_open: int,
|
|
74
|
+
doctor_failed: bool = False,
|
|
75
|
+
skip_requested: bool = False,
|
|
76
|
+
skip_reason: str = "",
|
|
77
|
+
) -> GateDecision:
|
|
78
|
+
"""Decide whether a push is allowed under ``policy``."""
|
|
79
|
+
mode = _normalize_mode(policy)
|
|
80
|
+
reasons = blocking_reasons(
|
|
81
|
+
policy,
|
|
82
|
+
open_requests=open_requests,
|
|
83
|
+
high_severity_open=high_severity_open,
|
|
84
|
+
doctor_failed=doctor_failed,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if mode == "off" or not reasons:
|
|
88
|
+
return GateDecision(allow=True, mode=mode, reasons=[])
|
|
89
|
+
|
|
90
|
+
if mode == "warn":
|
|
91
|
+
# Surface the blockers but never stop the push.
|
|
92
|
+
return GateDecision(allow=True, mode=mode, reasons=reasons)
|
|
93
|
+
|
|
94
|
+
# strict
|
|
95
|
+
allow_skip = bool((policy or {}).get("allow_skip", True))
|
|
96
|
+
require_reason = bool((policy or {}).get("require_skip_reason", False))
|
|
97
|
+
if skip_requested and allow_skip:
|
|
98
|
+
if require_reason and not str(skip_reason or "").strip():
|
|
99
|
+
return GateDecision(
|
|
100
|
+
allow=False,
|
|
101
|
+
mode=mode,
|
|
102
|
+
reasons=reasons + ["bypass requires a reason (set REFACTOR_SKIP_REASON)"],
|
|
103
|
+
)
|
|
104
|
+
return GateDecision(
|
|
105
|
+
allow=True,
|
|
106
|
+
mode=mode,
|
|
107
|
+
reasons=reasons,
|
|
108
|
+
skipped=True,
|
|
109
|
+
skip_reason=str(skip_reason or "").strip(),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return GateDecision(allow=False, mode=mode, reasons=reasons)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: refactorai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Local-first CLI for the refactor platform
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -8,7 +8,7 @@ Requires-Dist: typer>=0.12.0
|
|
|
8
8
|
Requires-Dist: httpx>=0.27.0
|
|
9
9
|
Requires-Dist: rich>=13.7.0
|
|
10
10
|
Requires-Dist: PyYAML>=6.0.1
|
|
11
|
-
Requires-Dist: refactorai-core>=3.2.
|
|
11
|
+
Requires-Dist: refactorai-core>=3.2.11
|
|
12
12
|
|
|
13
13
|
# refactorai-cli
|
|
14
14
|
|
|
@@ -5,6 +5,7 @@ refactorai_cli/auth.py
|
|
|
5
5
|
refactorai_cli/client.py
|
|
6
6
|
refactorai_cli/cloud_rr.py
|
|
7
7
|
refactorai_cli/commit_queue.py
|
|
8
|
+
refactorai_cli/commit_telemetry.py
|
|
8
9
|
refactorai_cli/control_plane.py
|
|
9
10
|
refactorai_cli/credentials.py
|
|
10
11
|
refactorai_cli/dotenv_loader.py
|
|
@@ -14,6 +15,7 @@ refactorai_cli/local_engine_runtime.py
|
|
|
14
15
|
refactorai_cli/local_paths.py
|
|
15
16
|
refactorai_cli/main.py
|
|
16
17
|
refactorai_cli/model_policy.py
|
|
18
|
+
refactorai_cli/pre_push_gate.py
|
|
17
19
|
refactorai_cli/refactor_branch_store.py
|
|
18
20
|
refactorai_cli/review_runner.py
|
|
19
21
|
refactorai_cli/runtime_manager.py
|
|
@@ -35,6 +37,7 @@ refactorai_cli/commands/cloud_cmds.py
|
|
|
35
37
|
refactorai_cli/commands/engine_cmds.py
|
|
36
38
|
refactorai_cli/commands/hook_cmds.py
|
|
37
39
|
refactorai_cli/commands/model_cmds.py
|
|
40
|
+
refactorai_cli/commands/pre_push_cmds.py
|
|
38
41
|
refactorai_cli/commands/request_cmds.py
|
|
39
42
|
refactorai_cli/commands/rules_cmds.py
|
|
40
43
|
refactorai_cli/commands/run_cmds.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{refactorai_cli-0.4.16 → refactorai_cli-0.5.0}/refactorai_cli/commands/runtime_proxy_cmds.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|