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.
Files changed (36) hide show
  1. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/SOURCES.txt +5 -0
  4. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/entry_points.txt +1 -0
  5. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/pyproject.toml +3 -2
  6. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/__init__.py +2 -2
  7. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/client.py +128 -1
  8. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/config.py +23 -1
  9. devops_bot_sdk-1.4.3/sdk/crucial.py +61 -0
  10. devops_bot_sdk-1.4.3/sdk/git_ops.py +214 -0
  11. devops_bot_sdk-1.4.3/sdk/graphify.py +172 -0
  12. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/ipc/handlers.py +523 -6
  13. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/local_exec.py +69 -6
  14. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/requests.py +10 -3
  15. devops_bot_sdk-1.4.3/sdk/run_auto.py +155 -0
  16. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/sse.py +19 -12
  17. devops_bot_sdk-1.4.3/sdk/updater.py +280 -0
  18. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/README.md +0 -0
  19. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  20. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/requires.txt +0 -0
  21. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  22. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/files.py +0 -0
  24. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/process.py +0 -0
  25. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/collectors/screenshot.py +0 -0
  26. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/exceptions.py +0 -0
  27. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/ipc/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/ipc/electron_bridge.py +0 -0
  29. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/__init__.py +0 -0
  30. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/envelope.py +0 -0
  31. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/responses.py +0 -0
  32. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/models/snapshots.py +0 -0
  33. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/py.typed +0 -0
  34. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/test.py +0 -0
  35. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/sdk/test_pipeline.py +0 -0
  36. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.0
3
+ Version: 1.4.3
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.0
3
+ Version: 1.4.3
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
@@ -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
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  agentos = sdk.config:configure_cli
3
+ agentos-auto = sdk.run_auto:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.0"
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" # `agentos configure [--rotate]`
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.0
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.0"
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.0"
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()}:{os.getlogin()}:agentos-sdk"
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}