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.
Files changed (40) hide show
  1. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/SOURCES.txt +2 -0
  4. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/pyproject.toml +1 -1
  5. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/__init__.py +2 -2
  6. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/client.py +1 -1
  7. devops_bot_sdk-1.4.8/sdk/crucial.py +217 -0
  8. devops_bot_sdk-1.4.8/sdk/git_ops.py +350 -0
  9. devops_bot_sdk-1.4.8/sdk/hooks/__init__.py +1 -0
  10. devops_bot_sdk-1.4.8/sdk/hooks/crucial_guard.py +77 -0
  11. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/ipc/handlers.py +488 -150
  12. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/local_exec.py +19 -0
  13. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/updater.py +33 -29
  14. devops_bot_sdk-1.4.3/sdk/crucial.py +0 -61
  15. devops_bot_sdk-1.4.3/sdk/git_ops.py +0 -214
  16. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/README.md +0 -0
  17. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  18. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  19. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/requires.txt +0 -0
  20. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  21. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/__init__.py +0 -0
  22. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/files.py +0 -0
  23. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/process.py +0 -0
  24. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/collectors/screenshot.py +0 -0
  25. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/config.py +0 -0
  26. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/exceptions.py +0 -0
  27. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/graphify.py +0 -0
  28. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/ipc/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/ipc/electron_bridge.py +0 -0
  30. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/__init__.py +0 -0
  31. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/envelope.py +0 -0
  32. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/requests.py +0 -0
  33. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/responses.py +0 -0
  34. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/models/snapshots.py +0 -0
  35. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/py.typed +0 -0
  36. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/run_auto.py +0 -0
  37. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/sse.py +0 -0
  38. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/test.py +0 -0
  39. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/sdk/test_pipeline.py +0 -0
  40. {devops_bot_sdk-1.4.3 → devops_bot_sdk-1.4.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.3
3
+ Version: 1.4.8
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.3
3
+ Version: 1.4.8
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -24,6 +24,8 @@ sdk/collectors/__init__.py
24
24
  sdk/collectors/files.py
25
25
  sdk/collectors/process.py
26
26
  sdk/collectors/screenshot.py
27
+ sdk/hooks/__init__.py
28
+ sdk/hooks/crucial_guard.py
27
29
  sdk/ipc/__init__.py
28
30
  sdk/ipc/electron_bridge.py
29
31
  sdk/ipc/handlers.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.3"
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
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.3"
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.3"
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)."""