devops-bot-sdk 1.4.0__tar.gz → 1.4.3__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.0 → devops_bot_sdk-1.4.3}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/SOURCES.txt +5 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/entry_points.txt +1 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/pyproject.toml +3 -2
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/client.py +128 -1
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/config.py +23 -1
- devops_bot_sdk-1.4.3/sdk/crucial.py +61 -0
- devops_bot_sdk-1.4.3/sdk/git_ops.py +214 -0
- devops_bot_sdk-1.4.3/sdk/graphify.py +172 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/ipc/handlers.py +523 -6
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/local_exec.py +69 -6
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/requests.py +10 -3
- devops_bot_sdk-1.4.3/sdk/run_auto.py +155 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/sse.py +19 -12
- devops_bot_sdk-1.4.3/sdk/updater.py +280 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/README.md +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/setup.cfg +0 -0
|
@@ -9,12 +9,17 @@ devops_bot_sdk.egg-info/top_level.txt
|
|
|
9
9
|
sdk/__init__.py
|
|
10
10
|
sdk/client.py
|
|
11
11
|
sdk/config.py
|
|
12
|
+
sdk/crucial.py
|
|
12
13
|
sdk/exceptions.py
|
|
14
|
+
sdk/git_ops.py
|
|
15
|
+
sdk/graphify.py
|
|
13
16
|
sdk/local_exec.py
|
|
14
17
|
sdk/py.typed
|
|
18
|
+
sdk/run_auto.py
|
|
15
19
|
sdk/sse.py
|
|
16
20
|
sdk/test.py
|
|
17
21
|
sdk/test_pipeline.py
|
|
22
|
+
sdk/updater.py
|
|
18
23
|
sdk/collectors/__init__.py
|
|
19
24
|
sdk/collectors/files.py
|
|
20
25
|
sdk/collectors/process.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.
|
|
7
|
+
version = "1.4.3"
|
|
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"
|
|
@@ -32,7 +32,8 @@ all = [
|
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[project.scripts]
|
|
35
|
-
agentos = "sdk.config:configure_cli"
|
|
35
|
+
agentos = "sdk.config:configure_cli" # `agentos configure [--rotate]`
|
|
36
|
+
agentos-auto = "sdk.run_auto:main" # run orchestrate-auto LOCALLY (co_ token from config)
|
|
36
37
|
|
|
37
38
|
[project.urls]
|
|
38
39
|
Homepage = "https://agentos.io"
|
|
@@ -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.3
|
|
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.3"
|
|
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.3"
|
|
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
|
|
@@ -578,6 +578,133 @@ class BackendClient:
|
|
|
578
578
|
_check_status(resp.status_code, self._base_url)
|
|
579
579
|
return resp.json()
|
|
580
580
|
|
|
581
|
+
async def list_todo_tasks_raw(self, status: str = "To Do", limit: int = 10000) -> list[dict]:
|
|
582
|
+
"""GET /tasks-ml (ML backend) — RAW task records.
|
|
583
|
+
|
|
584
|
+
Unlike list_tasks (the AI backend's refined view), this returns the raw
|
|
585
|
+
records that include `agentMode`, `jiraIssueKey`, `projectKey`, and
|
|
586
|
+
`humanInstructions` — needed to drive the local auto loop (autonomy
|
|
587
|
+
switch, branch naming, folder routing). Best-effort: returns [] on error.
|
|
588
|
+
"""
|
|
589
|
+
from sdk.config import BE_DEFAULT_URL
|
|
590
|
+
|
|
591
|
+
url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml"
|
|
592
|
+
headers = {**self._headers, "accept": "*/*", "developer-name": "null", "product": ""}
|
|
593
|
+
try:
|
|
594
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
595
|
+
resp = await client.get(url, params={"status": status, "limit": limit}, headers=headers)
|
|
596
|
+
if resp.status_code >= 400:
|
|
597
|
+
return []
|
|
598
|
+
return ((resp.json() or {}).get("data") or {}).get("records") or []
|
|
599
|
+
except Exception:
|
|
600
|
+
return []
|
|
601
|
+
|
|
602
|
+
async def approval_record(self, task_id: str) -> dict | None:
|
|
603
|
+
"""GET /api/v1/mgmt/approve/{task_id} → the full decision record (or None).
|
|
604
|
+
|
|
605
|
+
Includes `decision` + `timestamp`. The timestamp lets the caller tell a
|
|
606
|
+
FRESH decision (for a new gate) from a stale one left by an earlier gate.
|
|
607
|
+
"""
|
|
608
|
+
try:
|
|
609
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
610
|
+
resp = await client.get(
|
|
611
|
+
self._url(f"/api/v1/mgmt/approve/{task_id}"), headers=self._headers,
|
|
612
|
+
)
|
|
613
|
+
if resp.status_code >= 400:
|
|
614
|
+
return None
|
|
615
|
+
return (resp.json() or {}).get("record")
|
|
616
|
+
except Exception:
|
|
617
|
+
return None
|
|
618
|
+
|
|
619
|
+
async def approval_status(self, task_id: str) -> str:
|
|
620
|
+
"""GET /api/v1/mgmt/approve/{task_id} → 'approved' | 'rejected' | 'pending'.
|
|
621
|
+
|
|
622
|
+
Polled by the local loop to learn the human's WhatsApp decision (the
|
|
623
|
+
backend records it under approval_log:{task_id}). Returns 'pending' on
|
|
624
|
+
any error so the caller keeps polling rather than acting prematurely.
|
|
625
|
+
"""
|
|
626
|
+
try:
|
|
627
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
628
|
+
resp = await client.get(
|
|
629
|
+
self._url(f"/api/v1/mgmt/approve/{task_id}"), headers=self._headers,
|
|
630
|
+
)
|
|
631
|
+
if resp.status_code >= 400:
|
|
632
|
+
return "pending"
|
|
633
|
+
return str((resp.json() or {}).get("status") or "pending").lower()
|
|
634
|
+
except Exception:
|
|
635
|
+
return "pending"
|
|
636
|
+
|
|
637
|
+
async def github_token(self) -> str | None:
|
|
638
|
+
"""GET /ml-api/github/token → the user's GitHub access token (gho_…).
|
|
639
|
+
|
|
640
|
+
Used by the LOCAL agent to authenticate git/gh for clone/push/PR. The
|
|
641
|
+
endpoint lives on the ML backend (devopsbot-be), authorised by the same
|
|
642
|
+
co_ token. Best-effort: returns None on any failure so a run without a
|
|
643
|
+
connected GitHub account still proceeds (clone/PR steps just won't auth).
|
|
644
|
+
"""
|
|
645
|
+
from sdk.config import BE_DEFAULT_URL
|
|
646
|
+
|
|
647
|
+
url = f"{BE_DEFAULT_URL.rstrip('/')}/ml-api/github/token"
|
|
648
|
+
headers = {
|
|
649
|
+
**self._headers,
|
|
650
|
+
"accept": "*/*",
|
|
651
|
+
"developer-name": "null",
|
|
652
|
+
"product": "",
|
|
653
|
+
}
|
|
654
|
+
try:
|
|
655
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
656
|
+
resp = await client.get(url, headers=headers)
|
|
657
|
+
if resp.status_code >= 400:
|
|
658
|
+
return None
|
|
659
|
+
data = (resp.json() or {}).get("data") or {}
|
|
660
|
+
return data.get("accessToken") or None
|
|
661
|
+
except Exception:
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
async def notify_whatsapp(
|
|
665
|
+
self,
|
|
666
|
+
task_id: str,
|
|
667
|
+
message: str,
|
|
668
|
+
*,
|
|
669
|
+
summary: str = "",
|
|
670
|
+
risk_level: str = "LOW",
|
|
671
|
+
approver_role: str = "TEAM_LEAD",
|
|
672
|
+
) -> bool:
|
|
673
|
+
"""Push a WhatsApp message to the user via POST /approvals-ml.
|
|
674
|
+
|
|
675
|
+
Used for alerts — a hard error during local execution, or the context
|
|
676
|
+
window being exceeded. `message` is what the user receives on WhatsApp
|
|
677
|
+
(the backend forwards it). Best-effort: returns False and never raises so
|
|
678
|
+
a notification failure can't break the run.
|
|
679
|
+
"""
|
|
680
|
+
from sdk.config import BE_DEFAULT_URL
|
|
681
|
+
|
|
682
|
+
url = f"{BE_DEFAULT_URL.rstrip('/')}/approvals-ml"
|
|
683
|
+
headers = {
|
|
684
|
+
**self._headers,
|
|
685
|
+
"accept": "*/*",
|
|
686
|
+
"Content-Type": "application/json",
|
|
687
|
+
"developer-name": "null",
|
|
688
|
+
"product": "",
|
|
689
|
+
}
|
|
690
|
+
payload = {
|
|
691
|
+
"taskId": task_id,
|
|
692
|
+
"gateNumber": 1,
|
|
693
|
+
"riskLevel": risk_level,
|
|
694
|
+
"message": (message or "")[:300],
|
|
695
|
+
"costEstimateUsd": 0,
|
|
696
|
+
"approverRole": approver_role,
|
|
697
|
+
"approvalSummary": (summary or message or "")[:300],
|
|
698
|
+
"inputTokens": 0,
|
|
699
|
+
"outputTokens": 0,
|
|
700
|
+
}
|
|
701
|
+
try:
|
|
702
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
703
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
704
|
+
return resp.status_code < 400
|
|
705
|
+
except Exception:
|
|
706
|
+
return False
|
|
707
|
+
|
|
581
708
|
# ── Webhook (sole data egress) ─────────────────────────────────────
|
|
582
709
|
|
|
583
710
|
async def submit_webhook(
|
|
@@ -29,9 +29,31 @@ BE_DEFAULT_URL = "https://devopsbot-be.apiswagger.co.uk"
|
|
|
29
29
|
|
|
30
30
|
# ── Machine-derived Fernet key ────────────────────────────────────────
|
|
31
31
|
|
|
32
|
+
def _get_username() -> str:
|
|
33
|
+
"""Return the current username without relying on a controlling terminal.
|
|
34
|
+
|
|
35
|
+
os.getlogin() raises FileNotFoundError in non-interactive shells (SSH
|
|
36
|
+
sessions without TTY, subprocesses, systemd services). Fall back through
|
|
37
|
+
environment variables and pwd before giving up.
|
|
38
|
+
"""
|
|
39
|
+
for fn in (
|
|
40
|
+
lambda: os.environ.get("USER") or "",
|
|
41
|
+
lambda: os.environ.get("LOGNAME") or "",
|
|
42
|
+
lambda: __import__("pwd").getpwuid(os.getuid()).pw_name,
|
|
43
|
+
lambda: os.getlogin(),
|
|
44
|
+
):
|
|
45
|
+
try:
|
|
46
|
+
name = fn()
|
|
47
|
+
if name:
|
|
48
|
+
return name
|
|
49
|
+
except Exception: # noqa: BLE001
|
|
50
|
+
continue
|
|
51
|
+
return "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
32
54
|
def _machine_key() -> bytes:
|
|
33
55
|
from cryptography.fernet import Fernet
|
|
34
|
-
seed = f"{platform.node()}:{platform.system()}:{
|
|
56
|
+
seed = f"{platform.node()}:{platform.system()}:{_get_username()}:agentos-sdk"
|
|
35
57
|
key_bytes = hashlib.sha256(seed.encode()).digest()
|
|
36
58
|
return base64.urlsafe_b64encode(key_bytes)
|
|
37
59
|
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
MARKER = "AGENTOS_APPROVAL_REQUIRED:"
|
|
17
|
+
|
|
18
|
+
_BUILTIN_CRUCIAL = (
|
|
19
|
+
"Database schema changes or migrations (CREATE/ALTER/DROP TABLE, "
|
|
20
|
+
"prisma/alembic/knex/typeorm migrate, etc.)",
|
|
21
|
+
"Deleting or dropping data (DROP/TRUNCATE/DELETE, dropdb)",
|
|
22
|
+
"Destructive shell commands (rm -rf, git push --force, terraform destroy, "
|
|
23
|
+
"kubectl delete, dropping volumes)",
|
|
24
|
+
"Production deployment or release",
|
|
25
|
+
"Changes to authentication, secrets/credentials, billing or payment logic",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def crucial_prompt(human_instructions: str | None) -> str:
|
|
30
|
+
"""Prompt block telling the agent when and how to pause for approval."""
|
|
31
|
+
items = "\n".join(f"- {x}" for x in _BUILTIN_CRUCIAL)
|
|
32
|
+
|
|
33
|
+
extra = ""
|
|
34
|
+
hi = human_instructions or ""
|
|
35
|
+
flagged = [
|
|
36
|
+
ln.strip() for ln in hi.splitlines()
|
|
37
|
+
if any(k in ln.lower() for k in ("crucial", "approval", "sensitive", "do not"))
|
|
38
|
+
]
|
|
39
|
+
if flagged:
|
|
40
|
+
extra = "\nThe operator additionally marked these as crucial:\n" + "\n".join(
|
|
41
|
+
f"- {ln}" for ln in flagged[:10]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
"\n\n=== CRUCIAL-DECISION GATE (mandatory) ===\n"
|
|
46
|
+
"Before performing any CRUCIAL or IRREVERSIBLE action you MUST pause for "
|
|
47
|
+
"human approval. Crucial actions include:\n" + items + extra + "\n"
|
|
48
|
+
"When you reach such a step, DO NOT perform it. Output EXACTLY one line:\n"
|
|
49
|
+
f"{MARKER} <one-line description of the action needing approval>\n"
|
|
50
|
+
"then STOP and end your turn. Do not proceed until you are told it is approved."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_marker(text: str | None) -> str | None:
|
|
55
|
+
"""Return the description after the marker, or None if not present."""
|
|
56
|
+
if not text:
|
|
57
|
+
return None
|
|
58
|
+
for line in text.splitlines():
|
|
59
|
+
if MARKER in line:
|
|
60
|
+
return line.split(MARKER, 1)[1].strip() or "crucial action"
|
|
61
|
+
return None
|
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
env["GH_TOKEN"] = github_token
|
|
30
|
+
env["GITHUB_TOKEN"] = github_token
|
|
31
|
+
env["GIT_CONFIG_COUNT"] = "1"
|
|
32
|
+
env["GIT_CONFIG_KEY_0"] = (
|
|
33
|
+
f"url.https://x-access-token:{github_token}@github.com/.insteadOf"
|
|
34
|
+
)
|
|
35
|
+
env["GIT_CONFIG_VALUE_0"] = "https://github.com/"
|
|
36
|
+
env.setdefault("GIT_AUTHOR_NAME", "AgentOS")
|
|
37
|
+
env.setdefault("GIT_AUTHOR_EMAIL", "agentos@users.noreply.github.com")
|
|
38
|
+
env.setdefault("GIT_COMMITTER_NAME", "AgentOS")
|
|
39
|
+
env.setdefault("GIT_COMMITTER_EMAIL", "agentos@users.noreply.github.com")
|
|
40
|
+
return env
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _run(cmd: list[str], cwd: str, env: dict, timeout: float = 120.0):
|
|
44
|
+
"""Run a command; return (returncode, stdout, stderr). Never raises."""
|
|
45
|
+
try:
|
|
46
|
+
proc = await asyncio.create_subprocess_exec(
|
|
47
|
+
*cmd, cwd=cwd, env=env,
|
|
48
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
49
|
+
)
|
|
50
|
+
out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
51
|
+
return (
|
|
52
|
+
proc.returncode,
|
|
53
|
+
out.decode("utf-8", "replace").strip(),
|
|
54
|
+
err.decode("utf-8", "replace").strip(),
|
|
55
|
+
)
|
|
56
|
+
except Exception as exc: # noqa: BLE001
|
|
57
|
+
return 1, "", str(exc)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _is_git_repo(path: str, env: dict) -> bool:
|
|
61
|
+
rc, out, _ = await _run(["git", "rev-parse", "--is-inside-work-tree"], path, env)
|
|
62
|
+
return rc == 0 and out == "true"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def detect_default_branch(path: str, env: dict) -> str:
|
|
66
|
+
"""Repo default branch (origin/HEAD), falling back to the current branch."""
|
|
67
|
+
rc, out, _ = await _run(
|
|
68
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"], path, env,
|
|
69
|
+
)
|
|
70
|
+
if rc == 0 and "/" in out:
|
|
71
|
+
return out.rsplit("/", 1)[-1]
|
|
72
|
+
rc, out, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
|
|
73
|
+
return out if (rc == 0 and out) else "main"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def start_branch(
|
|
77
|
+
project_path: str, jira_key: str | None, summary: str | None, github_token: str | None,
|
|
78
|
+
) -> dict | None:
|
|
79
|
+
"""Create + check out `feature/<jira_key>-<slug>` off the default branch.
|
|
80
|
+
|
|
81
|
+
Returns {"branch", "base"} or None when the path isn't a git repo (e.g. a
|
|
82
|
+
fresh scratch dir) or the checkout failed.
|
|
83
|
+
"""
|
|
84
|
+
path = str(Path(project_path).expanduser())
|
|
85
|
+
env = _git_env(github_token)
|
|
86
|
+
if not await _is_git_repo(path, env):
|
|
87
|
+
return None
|
|
88
|
+
base = await detect_default_branch(path, env)
|
|
89
|
+
key = slugify(jira_key or "task", max_len=24)
|
|
90
|
+
branch = f"feature/{key}-{slugify(summary)}"
|
|
91
|
+
await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
|
|
92
|
+
rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
|
|
93
|
+
if rc != 0:
|
|
94
|
+
return None
|
|
95
|
+
return {"branch": branch, "base": base}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _remote_owner_repo(path: str, env: dict) -> tuple[str, str] | None:
|
|
99
|
+
"""Parse origin's URL into (owner, repo). Handles https and ssh forms."""
|
|
100
|
+
rc, out, _ = await _run(["git", "remote", "get-url", "origin"], path, env)
|
|
101
|
+
if rc != 0 or not out:
|
|
102
|
+
return None
|
|
103
|
+
m = re.search(r"github\.com[:/]+([^/]+)/(.+?)(?:\.git)?/?$", out.strip())
|
|
104
|
+
if not m:
|
|
105
|
+
return None
|
|
106
|
+
return m.group(1), m.group(2)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _create_pr_via_api(
|
|
110
|
+
owner: str, repo: str, base: str, head: str, title: str, body: str, token: str,
|
|
111
|
+
) -> str | None:
|
|
112
|
+
"""Open a PR through the GitHub REST API (no `gh` CLI needed).
|
|
113
|
+
|
|
114
|
+
The token from /ml-api/github/token carries `repo` scope, so it can both push
|
|
115
|
+
and open PRs. Returns the PR html_url, or the existing open PR's url when one
|
|
116
|
+
already exists for this head (422). Returns None (and logs) on any other error.
|
|
117
|
+
"""
|
|
118
|
+
import httpx
|
|
119
|
+
|
|
120
|
+
api = f"https://api.github.com/repos/{owner}/{repo}/pulls"
|
|
121
|
+
headers = {
|
|
122
|
+
"Authorization": f"token {token}",
|
|
123
|
+
"Accept": "application/vnd.github+json",
|
|
124
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
125
|
+
}
|
|
126
|
+
try:
|
|
127
|
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
|
128
|
+
resp = await c.post(
|
|
129
|
+
api, headers=headers,
|
|
130
|
+
json={"title": title, "head": head, "base": base, "body": body},
|
|
131
|
+
)
|
|
132
|
+
if resp.status_code == 201:
|
|
133
|
+
return resp.json().get("html_url")
|
|
134
|
+
# 422 = a PR already exists for this head (or validation issue). Try to
|
|
135
|
+
# surface the existing open PR rather than reporting "no PR".
|
|
136
|
+
if resp.status_code == 422:
|
|
137
|
+
async with httpx.AsyncClient(timeout=30.0) as c:
|
|
138
|
+
r2 = await c.get(
|
|
139
|
+
api, headers=headers,
|
|
140
|
+
params={"head": f"{owner}:{head}", "state": "open"},
|
|
141
|
+
)
|
|
142
|
+
if r2.status_code == 200 and r2.json():
|
|
143
|
+
return r2.json()[0].get("html_url")
|
|
144
|
+
logger.warning(
|
|
145
|
+
"git_ops.pr_api_failed owner=%s repo=%s head=%s status=%s body=%s",
|
|
146
|
+
owner, repo, head, resp.status_code, resp.text[:300],
|
|
147
|
+
)
|
|
148
|
+
except Exception as exc: # noqa: BLE001
|
|
149
|
+
logger.warning("git_ops.pr_api_error head=%s error=%s", head, exc)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def finish_pr(
|
|
154
|
+
project_path: str, branch: str, base: str, title: str, body: str,
|
|
155
|
+
github_token: str | None,
|
|
156
|
+
) -> dict:
|
|
157
|
+
"""Stage all, commit, push the branch, open a PR. Best-effort.
|
|
158
|
+
|
|
159
|
+
PR creation prefers the GitHub REST API (works with just the token — no `gh`
|
|
160
|
+
install required) and falls back to the `gh` CLI. Every failure path is
|
|
161
|
+
logged so a missing PR is diagnosable instead of silent.
|
|
162
|
+
|
|
163
|
+
Returns {"pushed": bool, "pr_url": str | None, "pr_error": str | None}.
|
|
164
|
+
"""
|
|
165
|
+
path = str(Path(project_path).expanduser())
|
|
166
|
+
env = _git_env(github_token)
|
|
167
|
+
|
|
168
|
+
await _run(["git", "add", "-A"], path, env)
|
|
169
|
+
# commit — no-op (non-zero) when there's nothing staged; ignore that case.
|
|
170
|
+
await _run(["git", "commit", "-m", title], path, env)
|
|
171
|
+
|
|
172
|
+
rc, _, push_err = await _run(
|
|
173
|
+
["git", "push", "-u", "origin", branch, "--force-with-lease"],
|
|
174
|
+
path, env, timeout=300.0,
|
|
175
|
+
)
|
|
176
|
+
pushed = rc == 0
|
|
177
|
+
if not pushed:
|
|
178
|
+
logger.warning("git_ops.push_failed branch=%s error=%s", branch, push_err[:300])
|
|
179
|
+
return {"pushed": False, "pr_url": None, "pr_error": f"push failed: {push_err[:200]}"}
|
|
180
|
+
|
|
181
|
+
pr_url: str | None = None
|
|
182
|
+
pr_error: str | None = None
|
|
183
|
+
|
|
184
|
+
# 1. Preferred: REST API (no `gh` dependency).
|
|
185
|
+
owner_repo = await _remote_owner_repo(path, env)
|
|
186
|
+
if github_token and owner_repo:
|
|
187
|
+
owner, repo = owner_repo
|
|
188
|
+
pr_url = await _create_pr_via_api(owner, repo, base, branch, title, body, github_token)
|
|
189
|
+
|
|
190
|
+
# 2. Fallback: gh CLI, if installed.
|
|
191
|
+
if not pr_url and shutil.which("gh"):
|
|
192
|
+
rc, out, gh_err = await _run(
|
|
193
|
+
["gh", "pr", "create", "--base", base, "--head", branch,
|
|
194
|
+
"--title", title, "--body", body],
|
|
195
|
+
path, env, timeout=120.0,
|
|
196
|
+
)
|
|
197
|
+
if rc == 0 and out:
|
|
198
|
+
pr_url = out.strip().splitlines()[-1]
|
|
199
|
+
else:
|
|
200
|
+
pr_error = f"gh pr create failed: {gh_err[:200]}"
|
|
201
|
+
logger.warning("git_ops.gh_pr_failed branch=%s error=%s", branch, gh_err[:300])
|
|
202
|
+
|
|
203
|
+
if not pr_url and pr_error is None:
|
|
204
|
+
# Pushed, but no PR and no specific error captured above.
|
|
205
|
+
pr_error = (
|
|
206
|
+
"no PR created — REST API returned no URL"
|
|
207
|
+
if (github_token and owner_repo)
|
|
208
|
+
else "no PR created — missing GitHub token or unrecognized origin remote"
|
|
209
|
+
if not shutil.which("gh")
|
|
210
|
+
else "no PR created"
|
|
211
|
+
)
|
|
212
|
+
logger.warning("git_ops.no_pr branch=%s reason=%s", branch, pr_error)
|
|
213
|
+
|
|
214
|
+
return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error}
|