devops-bot-sdk 1.4.3__tar.gz → 1.4.8__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.
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/pyproject.toml +1 -1
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/client.py +1 -1
- devops_bot_sdk-1.4.8/sdk/crucial.py +217 -0
- devops_bot_sdk-1.4.8/sdk/git_ops.py +350 -0
- devops_bot_sdk-1.4.8/sdk/hooks/__init__.py +1 -0
- devops_bot_sdk-1.4.8/sdk/hooks/crucial_guard.py +77 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/ipc/handlers.py +488 -150
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/local_exec.py +19 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/updater.py +33 -29
- devops_bot_sdk-1.4.3/sdk/crucial.py +0 -61
- devops_bot_sdk-1.4.3/sdk/git_ops.py +0 -214
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/README.md +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/config.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/graphify.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/requests.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/run_auto.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/sse.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devops-bot-sdk"
|
|
7
|
-
version = "1.4.
|
|
7
|
+
version = "1.4.8"
|
|
8
8
|
description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "LicenseRef-Proprietary"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
|
|
2
2
|
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.8
|
|
4
4
|
|
|
5
5
|
Public surface:
|
|
6
6
|
BackendClient.from_config() — create client from ~/.agentos/config.toml
|
|
@@ -30,7 +30,7 @@ Rules:
|
|
|
30
30
|
- All data egress through submit_webhook only
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
|
-
__version__ = "1.4.
|
|
33
|
+
__version__ = "1.4.8"
|
|
34
34
|
__author__ = "AgentOS"
|
|
35
35
|
|
|
36
36
|
from sdk.client import BackendClient
|
|
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
|
|
|
36
36
|
|
|
37
37
|
logger = logging.getLogger(__name__)
|
|
38
38
|
|
|
39
|
-
SDK_VERSION = "1.4.
|
|
39
|
+
SDK_VERSION = "1.4.8"
|
|
40
40
|
_POLL_INTERVAL = 3.0
|
|
41
41
|
_POLL_TIMEOUT = 600.0
|
|
42
42
|
_ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Mid-run crucial-decision gate.
|
|
2
|
+
|
|
3
|
+
When agentMode=true, the agent must PAUSE for human approval before a crucial /
|
|
4
|
+
irreversible action (DB schema changes/migrations, dropping data, destructive
|
|
5
|
+
shell, prod deploy, auth/billing changes, or anything the operator flagged in
|
|
6
|
+
humanInstructions).
|
|
7
|
+
|
|
8
|
+
Mechanism: the prompt instructs the agent to STOP and emit a one-line marker
|
|
9
|
+
before any such action. The SDK detects the marker, opens an approval gate, and
|
|
10
|
+
resumes the session on approval. (A Claude Code PreToolUse *blocking* hook is a
|
|
11
|
+
planned hardening on top of this — its block protocol needs verifying against
|
|
12
|
+
the installed claude version.)
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shlex
|
|
20
|
+
|
|
21
|
+
MARKER = "AGENTOS_APPROVAL_REQUIRED:"
|
|
22
|
+
|
|
23
|
+
# ── Hard-enforcement file protocol (PreToolUse hook ⇄ SDK) ─────────────
|
|
24
|
+
# Both files live inside a per-task approval dir (AGENTOS_APPROVAL_DIR). The
|
|
25
|
+
# hook writes PENDING_FILE when it blocks a destructive call; the SDK writes
|
|
26
|
+
# GRANTED_FILE (one-shot) after a human approves, and the hook consumes it.
|
|
27
|
+
GRANTED_FILE = "granted"
|
|
28
|
+
PENDING_FILE = "pending"
|
|
29
|
+
|
|
30
|
+
_BUILTIN_CRUCIAL = (
|
|
31
|
+
"Database schema changes or migrations (CREATE/ALTER/DROP TABLE, "
|
|
32
|
+
"prisma/alembic/knex/typeorm migrate, etc.)",
|
|
33
|
+
"Deleting or dropping data (DROP/TRUNCATE/DELETE, dropdb)",
|
|
34
|
+
"Destructive shell commands (rm -rf, git push --force, terraform destroy, "
|
|
35
|
+
"kubectl delete, dropping volumes)",
|
|
36
|
+
"Production deployment or release",
|
|
37
|
+
"Changes to authentication, secrets/credentials, billing or payment logic",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def crucial_prompt(human_instructions: str | None) -> str:
|
|
42
|
+
"""Prompt block telling the agent when and how to pause for approval."""
|
|
43
|
+
items = "\n".join(f"- {x}" for x in _BUILTIN_CRUCIAL)
|
|
44
|
+
|
|
45
|
+
extra = ""
|
|
46
|
+
hi = human_instructions or ""
|
|
47
|
+
flagged = [
|
|
48
|
+
ln.strip() for ln in hi.splitlines()
|
|
49
|
+
if any(k in ln.lower() for k in ("crucial", "approval", "sensitive", "do not"))
|
|
50
|
+
]
|
|
51
|
+
if flagged:
|
|
52
|
+
extra = "\nThe operator additionally marked these as crucial:\n" + "\n".join(
|
|
53
|
+
f"- {ln}" for ln in flagged[:10]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
"\n\n=== CRUCIAL-DECISION GATE (mandatory) ===\n"
|
|
58
|
+
"Before performing any CRUCIAL or IRREVERSIBLE action you MUST pause for "
|
|
59
|
+
"human approval. Crucial actions include:\n" + items + extra + "\n"
|
|
60
|
+
"When you reach such a step, DO NOT perform it. Output EXACTLY one line:\n"
|
|
61
|
+
f"{MARKER} <one-line description of the action needing approval>\n"
|
|
62
|
+
"then STOP and end your turn. Do not proceed until you are told it is approved."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_marker(text: str | None) -> str | None:
|
|
67
|
+
"""Return the description after the marker, or None if not present."""
|
|
68
|
+
if not text:
|
|
69
|
+
return None
|
|
70
|
+
for line in text.splitlines():
|
|
71
|
+
if MARKER in line:
|
|
72
|
+
return line.split(MARKER, 1)[1].strip() or "crucial action"
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Destructive-action detection (used by the PreToolUse hook) ─────────
|
|
77
|
+
|
|
78
|
+
# Bash command patterns that are crucial/irreversible. High-precision on
|
|
79
|
+
# purpose: a false positive stalls the run on a WhatsApp approval, so routine
|
|
80
|
+
# commands must NOT match (see _RM_SAFE_TARGET for the rm carve-out).
|
|
81
|
+
_DESTRUCTIVE_BASH = (
|
|
82
|
+
(re.compile(r"\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|(^|\s)-f\b)", re.I), "git force-push"),
|
|
83
|
+
(re.compile(r"\bgit\s+reset\s+--hard\b", re.I), "git reset --hard"),
|
|
84
|
+
(re.compile(r"\bgit\s+clean\s+-\w*f", re.I), "git clean -f"),
|
|
85
|
+
(re.compile(r"\bterraform\s+destroy\b", re.I), "terraform destroy"),
|
|
86
|
+
(re.compile(r"\bkubectl\s+delete\b", re.I), "kubectl delete"),
|
|
87
|
+
(re.compile(r"\bdropdb\b", re.I), "dropdb"),
|
|
88
|
+
(re.compile(r"\bDROP\s+(TABLE|DATABASE|SCHEMA)\b", re.I), "SQL DROP"),
|
|
89
|
+
(re.compile(r"\bTRUNCATE\b", re.I), "SQL TRUNCATE"),
|
|
90
|
+
(re.compile(r"\bDELETE\s+FROM\b", re.I), "SQL DELETE"),
|
|
91
|
+
(re.compile(r"\b(prisma|alembic|knex|sequelize|typeorm|rails|php\s+artisan)\b[^\n]*\bmigrat", re.I), "database migration"),
|
|
92
|
+
(re.compile(r"\b(mkfs|dd)\s", re.I), "disk format/overwrite"),
|
|
93
|
+
(re.compile(r"\b(npm|yarn|pnpm)\s+publish\b", re.I), "package publish"),
|
|
94
|
+
(re.compile(r"\bchmod\s+-R\s+777\b", re.I), "recursive chmod 777"),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# `rm -rf <target>` is routine for build artifacts but catastrophic for source
|
|
98
|
+
# /data/system paths. Allow when EVERY target is a known throwaway dir; block
|
|
99
|
+
# otherwise (incl. /, ~, .., the repo root, src, etc.).
|
|
100
|
+
_RM_RECURSIVE = re.compile(r"\brm\s+-\S*[rf]", re.I)
|
|
101
|
+
_RM_SAFE_TARGET = re.compile(
|
|
102
|
+
r"^[./]*(node_modules|dist|build|out|coverage|\.next|\.nuxt|\.svelte-kit|\.cache|"
|
|
103
|
+
r"\.turbo|\.angular|target|__pycache__|\.pytest_cache|\.parcel-cache|\.venv|venv|"
|
|
104
|
+
r"vendor|tmp|temp)([/\\].*)?$",
|
|
105
|
+
re.I,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Files whose modification needs approval (secrets / private keys). Conservative:
|
|
109
|
+
# exact `.env` and key/cert files only — scaffolding `.env.example`/`.env.local`
|
|
110
|
+
# stays unblocked.
|
|
111
|
+
_SENSITIVE_FILE = re.compile(r"(^|/)(\.env|id_rsa|id_ed25519|.*\.pem|.*\.key)$", re.I)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rm_is_dangerous(cmd: str) -> bool:
|
|
115
|
+
"""True if a recursive rm targets anything outside the safe throwaway set."""
|
|
116
|
+
if not _RM_RECURSIVE.search(cmd):
|
|
117
|
+
return False
|
|
118
|
+
# Targets = non-flag tokens after the first `rm`.
|
|
119
|
+
toks = cmd.split()
|
|
120
|
+
try:
|
|
121
|
+
start = toks.index("rm") + 1
|
|
122
|
+
except ValueError:
|
|
123
|
+
return True
|
|
124
|
+
targets = [t.strip("'\"") for t in toks[start:] if not t.startswith("-")]
|
|
125
|
+
if not targets:
|
|
126
|
+
return True
|
|
127
|
+
return any(not _RM_SAFE_TARGET.match(t) for t in targets)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_destructive(tool_name: str, tool_input: dict | None) -> str | None:
|
|
131
|
+
"""Return a short description if this tool call is crucial/destructive, else None."""
|
|
132
|
+
inp = tool_input or {}
|
|
133
|
+
if tool_name == "Bash":
|
|
134
|
+
cmd = str(inp.get("command", ""))
|
|
135
|
+
if _rm_is_dangerous(cmd):
|
|
136
|
+
return f"recursive delete (rm -rf): {cmd[:120]}"
|
|
137
|
+
for rx, desc in _DESTRUCTIVE_BASH:
|
|
138
|
+
if rx.search(cmd):
|
|
139
|
+
return f"{desc}: {cmd[:120]}"
|
|
140
|
+
return None
|
|
141
|
+
if tool_name in ("Write", "Edit", "MultiEdit"):
|
|
142
|
+
fp = str(inp.get("file_path", ""))
|
|
143
|
+
if fp and _SENSITIVE_FILE.search(fp):
|
|
144
|
+
return f"write to sensitive file: {fp}"
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── File protocol helpers ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def _granted_path(approval_dir: str) -> str:
|
|
151
|
+
return os.path.join(approval_dir, GRANTED_FILE)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _pending_path(approval_dir: str) -> str:
|
|
155
|
+
return os.path.join(approval_dir, PENDING_FILE)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def grant(approval_dir: str) -> None:
|
|
159
|
+
"""SDK: authorize the NEXT destructive action (one-shot)."""
|
|
160
|
+
try:
|
|
161
|
+
with open(_granted_path(approval_dir), "w") as f:
|
|
162
|
+
f.write("1")
|
|
163
|
+
except Exception: # noqa: BLE001
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def consume_grant(approval_dir: str) -> bool:
|
|
168
|
+
"""Hook: if a one-shot grant exists, consume it and return True."""
|
|
169
|
+
try:
|
|
170
|
+
os.remove(_granted_path(approval_dir))
|
|
171
|
+
return True
|
|
172
|
+
except OSError:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_pending(approval_dir: str, desc: str) -> None:
|
|
177
|
+
"""Hook: record the blocked action so the SDK can open the gate."""
|
|
178
|
+
try:
|
|
179
|
+
with open(_pending_path(approval_dir), "w") as f:
|
|
180
|
+
json.dump({"desc": desc}, f)
|
|
181
|
+
except Exception: # noqa: BLE001
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_pending(approval_dir: str) -> str | None:
|
|
186
|
+
"""SDK: read + clear the pending blocked-action description, if any."""
|
|
187
|
+
try:
|
|
188
|
+
with open(_pending_path(approval_dir)) as f:
|
|
189
|
+
data = json.load(f) or {}
|
|
190
|
+
os.remove(_pending_path(approval_dir))
|
|
191
|
+
return data.get("desc") or "crucial action"
|
|
192
|
+
except (OSError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def hook_settings(python_exe: str, *, timeout: int = 15) -> dict:
|
|
197
|
+
"""A `--settings` block registering this module as a PreToolUse guard.
|
|
198
|
+
|
|
199
|
+
Invoked as `<python_exe> -m sdk.hooks.crucial_guard` so it runs in the SDK's
|
|
200
|
+
own interpreter (where the `sdk` package is importable) regardless of cwd.
|
|
201
|
+
"""
|
|
202
|
+
return {
|
|
203
|
+
"hooks": {
|
|
204
|
+
"PreToolUse": [
|
|
205
|
+
{
|
|
206
|
+
"matcher": "Bash|Edit|Write|MultiEdit",
|
|
207
|
+
"hooks": [
|
|
208
|
+
{
|
|
209
|
+
"type": "command",
|
|
210
|
+
"command": f"{shlex.quote(python_exe)} -m sdk.hooks.crucial_guard",
|
|
211
|
+
"timeout": timeout,
|
|
212
|
+
}
|
|
213
|
+
],
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Local git operations — feature branch + PR via the user's GitHub token.
|
|
2
|
+
|
|
3
|
+
Runs `git`/`gh` as subprocesses on the user's machine, authenticated with the
|
|
4
|
+
token fetched from the backend (`/ml-api/github/token`). The SDK owns branching,
|
|
5
|
+
commit, push and PR for the PRIMARY project so it's deterministic; the agent is
|
|
6
|
+
told not to do git for that repo (it may still clone/PR other repos the ticket
|
|
7
|
+
names). Everything here is best-effort and never raises into the run.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def slugify(text: str | None, max_len: int = 40) -> str:
|
|
22
|
+
s = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
|
|
23
|
+
return s[:max_len].strip("-") or "task"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _git_env(github_token: str | None) -> dict:
|
|
27
|
+
env = dict(os.environ)
|
|
28
|
+
if github_token:
|
|
29
|
+
# Token present → authenticate git transport over HTTPS with the token.
|
|
30
|
+
env["GH_TOKEN"] = github_token
|
|
31
|
+
env["GITHUB_TOKEN"] = github_token
|
|
32
|
+
env["GIT_CONFIG_COUNT"] = "1"
|
|
33
|
+
env["GIT_CONFIG_KEY_0"] = (
|
|
34
|
+
f"url.https://x-access-token:{github_token}@github.com/.insteadOf"
|
|
35
|
+
)
|
|
36
|
+
env["GIT_CONFIG_VALUE_0"] = "https://github.com/"
|
|
37
|
+
else:
|
|
38
|
+
# No token → use the box's SSH keys: rewrite GitHub HTTPS URLs to SSH so
|
|
39
|
+
# clone / fetch / push authenticate via SSH. (PR creation itself still
|
|
40
|
+
# goes through `gh`, using its own stored auth — SSH can't open a PR.)
|
|
41
|
+
env.pop("GH_TOKEN", None)
|
|
42
|
+
env.pop("GITHUB_TOKEN", None)
|
|
43
|
+
env["GIT_CONFIG_COUNT"] = "1"
|
|
44
|
+
env["GIT_CONFIG_KEY_0"] = "url.git@github.com:.insteadOf"
|
|
45
|
+
env["GIT_CONFIG_VALUE_0"] = "https://github.com/"
|
|
46
|
+
env.setdefault("GIT_AUTHOR_NAME", "AgentOS")
|
|
47
|
+
env.setdefault("GIT_AUTHOR_EMAIL", "agentos@users.noreply.github.com")
|
|
48
|
+
env.setdefault("GIT_COMMITTER_NAME", "AgentOS")
|
|
49
|
+
env.setdefault("GIT_COMMITTER_EMAIL", "agentos@users.noreply.github.com")
|
|
50
|
+
return env
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _run(cmd: list[str], cwd: str, env: dict, timeout: float = 120.0):
|
|
54
|
+
"""Run a command; return (returncode, stdout, stderr). Never raises."""
|
|
55
|
+
try:
|
|
56
|
+
proc = await asyncio.create_subprocess_exec(
|
|
57
|
+
*cmd, cwd=cwd, env=env,
|
|
58
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
59
|
+
)
|
|
60
|
+
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
61
|
+
return (
|
|
62
|
+
proc.returncode,
|
|
63
|
+
out.decode("utf-8", "replace").strip(),
|
|
64
|
+
err.decode("utf-8", "replace").strip(),
|
|
65
|
+
)
|
|
66
|
+
except Exception as exc: # noqa: BLE001
|
|
67
|
+
return 1, "", str(exc)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _is_git_repo(path: str, env: dict) -> bool:
|
|
71
|
+
rc, out, _ = await _run(["git", "rev-parse", "--is-inside-work-tree"], path, env)
|
|
72
|
+
return rc == 0 and out == "true"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def detect_default_branch(path: str, env: dict) -> str:
|
|
76
|
+
"""Repo default branch — the PR base.
|
|
77
|
+
|
|
78
|
+
Must NEVER return a `feature/*` branch: on a re-run the working tree may
|
|
79
|
+
already be checked out ON the feature branch, and returning that as the base
|
|
80
|
+
makes base == head (GitHub rejects with "No commits between X and X"). Tries,
|
|
81
|
+
in order: origin/HEAD → the remote's advertised HEAD → main/master if they
|
|
82
|
+
exist → the current branch only if it isn't a feature branch → "main".
|
|
83
|
+
"""
|
|
84
|
+
# 1. Local symbolic ref for origin/HEAD (set by `git clone` / `remote set-head`).
|
|
85
|
+
rc, out, _ = await _run(
|
|
86
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"], path, env,
|
|
87
|
+
)
|
|
88
|
+
if rc == 0 and "/" in out:
|
|
89
|
+
return out.rsplit("/", 1)[-1]
|
|
90
|
+
# 2. Ask the remote directly — works even when origin/HEAD isn't set locally.
|
|
91
|
+
rc, out, _ = await _run(["git", "remote", "show", "origin"], path, env, timeout=60.0)
|
|
92
|
+
if rc == 0:
|
|
93
|
+
m = re.search(r"HEAD branch:\s*(\S+)", out)
|
|
94
|
+
if m and m.group(1) not in ("", "(unknown)"):
|
|
95
|
+
return m.group(1)
|
|
96
|
+
# 3. Common defaults, if they exist as remote-tracking branches.
|
|
97
|
+
for cand in ("main", "master"):
|
|
98
|
+
rc, _, _ = await _run(
|
|
99
|
+
["git", "rev-parse", "--verify", "--quiet", f"origin/{cand}"], path, env,
|
|
100
|
+
)
|
|
101
|
+
if rc == 0:
|
|
102
|
+
return cand
|
|
103
|
+
# 4. Last resort: the current branch, but never a feature branch.
|
|
104
|
+
rc, out, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
|
|
105
|
+
if rc == 0 and out and not out.startswith("feature/"):
|
|
106
|
+
return out
|
|
107
|
+
return "main"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def ensure_repo(
|
|
111
|
+
project_path: str, repo_url: str | None, github_token: str | None,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""Make sure ``project_path`` is a usable git repo, cloning if needed.
|
|
114
|
+
|
|
115
|
+
- Already a populated git repo → True.
|
|
116
|
+
- Missing or empty dir + a known ``repo_url`` → clone it there, then True.
|
|
117
|
+
- Non-empty but not a git repo → False (don't clobber the user's files).
|
|
118
|
+
Best-effort; never raises.
|
|
119
|
+
"""
|
|
120
|
+
path = Path(project_path).expanduser()
|
|
121
|
+
env = _git_env(github_token)
|
|
122
|
+
|
|
123
|
+
has_content = path.is_dir() and any(path.iterdir())
|
|
124
|
+
if has_content:
|
|
125
|
+
if await _is_git_repo(str(path), env):
|
|
126
|
+
return True
|
|
127
|
+
logger.warning("git_ops.path_not_repo path=%s (non-empty, not cloning)", path)
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
if not repo_url:
|
|
131
|
+
logger.warning("git_ops.no_repo_url path=%s (empty/missing, nothing to clone)", path)
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
rc, _, err = await _run(
|
|
136
|
+
["git", "clone", repo_url, str(path)], str(path.parent), env, timeout=600.0,
|
|
137
|
+
)
|
|
138
|
+
if rc != 0:
|
|
139
|
+
logger.warning("git_ops.clone_failed url=%s path=%s err=%s", repo_url, path, err[:300])
|
|
140
|
+
return False
|
|
141
|
+
logger.info("git_ops.cloned url=%s path=%s", repo_url, path)
|
|
142
|
+
return await _is_git_repo(str(path), env)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def start_branch(
|
|
146
|
+
project_path: str,
|
|
147
|
+
jira_key: str | None,
|
|
148
|
+
summary: str | None,
|
|
149
|
+
github_token: str | None,
|
|
150
|
+
base: str | None = None,
|
|
151
|
+
) -> dict | None:
|
|
152
|
+
"""Create + check out `feature/<jira_key>-<slug>` off the resolved base.
|
|
153
|
+
|
|
154
|
+
Base resolution (per repo):
|
|
155
|
+
- `base` given (named in the Jira humanInstructions, possibly per-repo) →
|
|
156
|
+
branch off the FRESH `origin/<base>` (fetched), falling back to a local
|
|
157
|
+
`<base>` ref. This is the explicit-override path.
|
|
158
|
+
- `base` omitted → the repo's CURRENT checked-out branch (the branch the
|
|
159
|
+
user is actually working on); the feature branch is cut from local HEAD
|
|
160
|
+
so the user's working state is preserved. If HEAD is detached or already
|
|
161
|
+
one of OUR `feature/*` branches (a stale checkout from a prior re-run in
|
|
162
|
+
a shared working tree), fall back to `detect_default_branch` so we never
|
|
163
|
+
chain a feature branch onto another feature branch.
|
|
164
|
+
|
|
165
|
+
Returns {"branch", "base"} or None when the path isn't a git repo (e.g. a
|
|
166
|
+
fresh scratch dir) or the checkout failed. The returned `base` is the branch
|
|
167
|
+
the feature was actually cut from — `finish_pr` uses it as the PR base.
|
|
168
|
+
"""
|
|
169
|
+
path = str(Path(project_path).expanduser())
|
|
170
|
+
env = _git_env(github_token)
|
|
171
|
+
if not await _is_git_repo(path, env):
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
key = slugify(jira_key or "task", max_len=24)
|
|
175
|
+
branch = f"feature/{key}-{slugify(summary)}"
|
|
176
|
+
# Fetch so any remote base ref (origin/HEAD, origin/<base>) is present/fresh.
|
|
177
|
+
await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
|
|
178
|
+
|
|
179
|
+
explicit = (base or "").strip()
|
|
180
|
+
if explicit:
|
|
181
|
+
# Explicit base from instructions → branch off the FRESH remote tip
|
|
182
|
+
# (satisfies "pull latest from <base>, then branch"). Fall back to a
|
|
183
|
+
# local ref of that name, then to a bare branch, if the remote lacks it.
|
|
184
|
+
await _run(["git", "fetch", "origin", explicit], path, env, timeout=120.0)
|
|
185
|
+
resolved = "main" if explicit == branch else explicit
|
|
186
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{resolved}"], path, env)
|
|
187
|
+
if rc != 0:
|
|
188
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch, resolved], path, env)
|
|
189
|
+
if rc != 0:
|
|
190
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
|
|
191
|
+
if rc != 0:
|
|
192
|
+
return None
|
|
193
|
+
logger.info("git_ops.branch | %s ← explicit base %s", branch, resolved)
|
|
194
|
+
return {"branch": branch, "base": resolved}
|
|
195
|
+
|
|
196
|
+
# Default: base = the CURRENT checked-out branch (the user's working branch).
|
|
197
|
+
rc, cur, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
|
|
198
|
+
cur = cur.strip() if rc == 0 else ""
|
|
199
|
+
if cur and cur != "HEAD" and not cur.startswith("feature/"):
|
|
200
|
+
# Cut from local HEAD — preserve the user's working state, do not pull.
|
|
201
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
|
|
202
|
+
if rc != 0:
|
|
203
|
+
return None
|
|
204
|
+
logger.info("git_ops.branch | %s ← current branch %s", branch, cur)
|
|
205
|
+
return {"branch": branch, "base": cur}
|
|
206
|
+
|
|
207
|
+
# HEAD detached or a stale feature/* checkout → fall back to repo default.
|
|
208
|
+
resolved = await detect_default_branch(path, env)
|
|
209
|
+
if resolved == branch:
|
|
210
|
+
resolved = "main"
|
|
211
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch, f"origin/{resolved}"], path, env)
|
|
212
|
+
if rc != 0:
|
|
213
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
|
|
214
|
+
if rc != 0:
|
|
215
|
+
return None
|
|
216
|
+
logger.info("git_ops.branch | %s ← default base %s (HEAD was %r)", branch, resolved, cur or "detached")
|
|
217
|
+
return {"branch": branch, "base": resolved}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _remote_owner_repo(path: str, env: dict) -> tuple[str, str] | None:
|
|
221
|
+
"""Parse origin's URL into (owner, repo). Handles https and ssh forms."""
|
|
222
|
+
rc, out, _ = await _run(["git", "remote", "get-url", "origin"], path, env)
|
|
223
|
+
if rc != 0 or not out:
|
|
224
|
+
return None
|
|
225
|
+
m = re.search(r"github\.com[:/]+([^/]+)/(.+?)(?:\.git)?/?$", out.strip())
|
|
226
|
+
if not m:
|
|
227
|
+
return None
|
|
228
|
+
return m.group(1), m.group(2)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _create_pr_via_api(
|
|
232
|
+
owner: str, repo: str, base: str, head: str, title: str, body: str, token: str,
|
|
233
|
+
) -> str | None:
|
|
234
|
+
"""Open a PR through the GitHub REST API (no `gh` CLI needed).
|
|
235
|
+
|
|
236
|
+
The token from /ml-api/github/token carries `repo` scope, so it can both push
|
|
237
|
+
and open PRs. Returns the PR html_url, or the existing open PR's url when one
|
|
238
|
+
already exists for this head (422). Returns None (and logs) on any other error.
|
|
239
|
+
"""
|
|
240
|
+
import httpx
|
|
241
|
+
|
|
242
|
+
api = f"https://api.github.com/repos/{owner}/{repo}/pulls"
|
|
243
|
+
headers = {
|
|
244
|
+
"Authorization": f"token {token}",
|
|
245
|
+
"Accept": "application/vnd.github+json",
|
|
246
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
247
|
+
}
|
|
248
|
+
try:
|
|
249
|
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
|
250
|
+
resp = await c.post(
|
|
251
|
+
api, headers=headers,
|
|
252
|
+
json={"title": title, "head": head, "base": base, "body": body},
|
|
253
|
+
)
|
|
254
|
+
if resp.status_code == 201:
|
|
255
|
+
return resp.json().get("html_url")
|
|
256
|
+
# 422 = a PR already exists for this head (or validation issue). Try to
|
|
257
|
+
# surface the existing open PR rather than reporting "no PR".
|
|
258
|
+
if resp.status_code == 422:
|
|
259
|
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
|
260
|
+
r2 = await c.get(
|
|
261
|
+
api, headers=headers,
|
|
262
|
+
params={"head": f"{owner}:{head}", "state": "open"},
|
|
263
|
+
)
|
|
264
|
+
if r2.status_code == 200 and r2.json():
|
|
265
|
+
return r2.json()[0].get("html_url")
|
|
266
|
+
logger.warning(
|
|
267
|
+
"git_ops.pr_api_failed owner=%s repo=%s head=%s status=%s body=%s",
|
|
268
|
+
owner, repo, head, resp.status_code, resp.text[:300],
|
|
269
|
+
)
|
|
270
|
+
except Exception as exc: # noqa: BLE001
|
|
271
|
+
logger.warning("git_ops.pr_api_error head=%s error=%s", head, exc)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def finish_pr(
|
|
276
|
+
project_path: str, branch: str, base: str, title: str, body: str,
|
|
277
|
+
github_token: str | None,
|
|
278
|
+
) -> dict:
|
|
279
|
+
"""Stage all, commit, push the branch, open a PR. Best-effort.
|
|
280
|
+
|
|
281
|
+
Auth modes (decided by whether a token is available, see _git_env):
|
|
282
|
+
- token present → push over HTTPS+token; PR via REST API (no `gh` needed),
|
|
283
|
+
falling back to `gh`.
|
|
284
|
+
- no token → push over SSH (the box's keys); PR via `gh pr create`
|
|
285
|
+
(GitHub has no SSH path for opening a PR, so `gh` must be authenticated:
|
|
286
|
+
`gh auth login`). SSH alone covers git transport, not the PR.
|
|
287
|
+
|
|
288
|
+
Every failure path is logged so a missing PR is diagnosable instead of silent.
|
|
289
|
+
Returns {"pushed": bool, "pr_url": str | None, "pr_error": str | None}.
|
|
290
|
+
"""
|
|
291
|
+
path = str(Path(project_path).expanduser())
|
|
292
|
+
env = _git_env(github_token)
|
|
293
|
+
|
|
294
|
+
await _run(["git", "add", "-A"], path, env)
|
|
295
|
+
# commit — no-op (non-zero) when there's nothing staged; ignore that case.
|
|
296
|
+
await _run(["git", "commit", "-m", title], path, env)
|
|
297
|
+
|
|
298
|
+
rc, _, push_err = await _run(
|
|
299
|
+
["git", "push", "-u", "origin", branch, "--force-with-lease"],
|
|
300
|
+
path, env, timeout=300.0,
|
|
301
|
+
)
|
|
302
|
+
pushed = rc == 0
|
|
303
|
+
if not pushed:
|
|
304
|
+
logger.warning("git_ops.push_failed branch=%s error=%s", branch, push_err[:300])
|
|
305
|
+
return {"pushed": False, "pr_url": None, "pr_error": f"push failed: {push_err[:200]}"}
|
|
306
|
+
|
|
307
|
+
pr_url: str | None = None
|
|
308
|
+
pr_error: str | None = None
|
|
309
|
+
|
|
310
|
+
# Never open a PR with base == head (GitHub: "No commits between X and X").
|
|
311
|
+
if base == branch:
|
|
312
|
+
base = await detect_default_branch(path, env)
|
|
313
|
+
if base == branch:
|
|
314
|
+
base = "main"
|
|
315
|
+
logger.warning("git_ops.base_equals_head_fixed branch=%s base=%s", branch, base)
|
|
316
|
+
|
|
317
|
+
# 1. Preferred: REST API (no `gh` dependency).
|
|
318
|
+
owner_repo = await _remote_owner_repo(path, env)
|
|
319
|
+
if github_token and owner_repo:
|
|
320
|
+
owner, repo = owner_repo
|
|
321
|
+
pr_url = await _create_pr_via_api(owner, repo, base, branch, title, body, github_token)
|
|
322
|
+
|
|
323
|
+
# 2. Fallback: gh CLI, if installed.
|
|
324
|
+
if not pr_url and shutil.which("gh"):
|
|
325
|
+
rc, out, gh_err = await _run(
|
|
326
|
+
["gh", "pr", "create", "--base", base, "--head", branch,
|
|
327
|
+
"--title", title, "--body", body],
|
|
328
|
+
path, env, timeout=120.0,
|
|
329
|
+
)
|
|
330
|
+
if rc == 0 and out:
|
|
331
|
+
pr_url = out.strip().splitlines()[-1]
|
|
332
|
+
else:
|
|
333
|
+
pr_error = f"gh pr create failed: {gh_err[:200]}"
|
|
334
|
+
logger.warning("git_ops.gh_pr_failed branch=%s error=%s", branch, gh_err[:300])
|
|
335
|
+
|
|
336
|
+
if not pr_url and pr_error is None:
|
|
337
|
+
# Pushed, but no PR and no specific error captured above.
|
|
338
|
+
if github_token and owner_repo:
|
|
339
|
+
pr_error = "no PR created — REST API returned no URL"
|
|
340
|
+
elif not github_token and not shutil.which("gh"):
|
|
341
|
+
pr_error = ("no PR created — no token and `gh` not installed; SSH "
|
|
342
|
+
"transport can't open a PR. Install gh + `gh auth login`, "
|
|
343
|
+
"or provide a GitHub token.")
|
|
344
|
+
elif not shutil.which("gh"):
|
|
345
|
+
pr_error = "no PR created — missing GitHub token or unrecognized origin remote"
|
|
346
|
+
else:
|
|
347
|
+
pr_error = "no PR created"
|
|
348
|
+
logger.warning("git_ops.no_pr branch=%s reason=%s", branch, pr_error)
|
|
349
|
+
|
|
350
|
+
return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Claude Code hooks shipped with the SDK (run in the SDK interpreter)."""
|