devops-bot-sdk 1.4.139__tar.gz → 1.4.141__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 (92) hide show
  1. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/devops_bot_sdk.egg-info/SOURCES.txt +2 -1
  4. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/__init__.py +2 -2
  5. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/git_ops.py +33 -0
  6. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/api.py +42 -0
  7. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/models.py +39 -0
  8. devops_bot_sdk-1.4.141/tests/test_git_action.py +103 -0
  9. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/README.md +0 -0
  10. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  11. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/devops_bot_sdk.egg-info/entry_points.txt +0 -0
  12. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/devops_bot_sdk.egg-info/requires.txt +0 -0
  13. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  14. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/pyproject.toml +0 -0
  15. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agentd.py +0 -0
  16. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/__init__.py +0 -0
  17. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/builder.py +0 -0
  18. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/chat.py +0 -0
  19. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/connectors.py +0 -0
  20. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/engines/__init__.py +0 -0
  21. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/engines/claude_code.py +0 -0
  22. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/engines/http.py +0 -0
  23. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/engines/mcp.py +0 -0
  24. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/engines/n8n.py +0 -0
  25. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/engines/shell.py +0 -0
  26. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/integrations.py +0 -0
  27. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/inventory.py +0 -0
  28. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/library/__init__.py +0 -0
  29. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/manifest.py +0 -0
  30. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/prompt_studio.py +0 -0
  31. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/prompt_template.py +0 -0
  32. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/prompt_vars.py +0 -0
  33. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/run_log.py +0 -0
  34. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/secrets.py +0 -0
  35. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/usage.py +0 -0
  36. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/agents/varstore.py +0 -0
  37. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/cli.py +0 -0
  38. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/client.py +0 -0
  39. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/collectors/__init__.py +0 -0
  40. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/collectors/files.py +0 -0
  41. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/collectors/process.py +0 -0
  42. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/collectors/screenshot.py +0 -0
  43. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/config.py +0 -0
  44. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/crucial.py +0 -0
  45. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/design_verify/__init__.py +0 -0
  46. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/design_verify/assets.py +0 -0
  47. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/design_verify/bootstrap.py +0 -0
  48. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/design_verify/browser.py +0 -0
  49. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/design_verify/compare.py +0 -0
  50. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/design_verify/loop.py +0 -0
  51. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/exceptions.py +0 -0
  52. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/graphify.py +0 -0
  53. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/hooks/__init__.py +0 -0
  54. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/hooks/crucial_guard.py +0 -0
  55. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ipc/__init__.py +0 -0
  56. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ipc/electron_bridge.py +0 -0
  57. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ipc/handlers.py +0 -0
  58. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/local_exec.py +0 -0
  59. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/models/__init__.py +0 -0
  60. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/models/envelope.py +0 -0
  61. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/models/requests.py +0 -0
  62. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/models/responses.py +0 -0
  63. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/models/snapshots.py +0 -0
  64. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/platform_compat.py +0 -0
  65. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/py.typed +0 -0
  66. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/run_auto.py +0 -0
  67. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/sse.py +0 -0
  68. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/test.py +0 -0
  69. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/test_pipeline.py +0 -0
  70. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/__init__.py +0 -0
  71. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/dashboard.py +0 -0
  72. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/ip_allowlist.py +0 -0
  73. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/jobs.py +0 -0
  74. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/runner.py +0 -0
  75. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/security.py +0 -0
  76. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/server.py +0 -0
  77. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/service.py +0 -0
  78. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/setup_manager.py +0 -0
  79. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/agents.html +0 -0
  80. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/app.js +0 -0
  81. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/custom-agents.html +0 -0
  82. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/default-agents.html +0 -0
  83. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/index.html +0 -0
  84. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/runs.html +0 -0
  85. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/setup.html +0 -0
  86. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/styles.css +0 -0
  87. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/ui/static/tickets.html +0 -0
  88. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/sdk/updater.py +0 -0
  89. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/setup.cfg +0 -0
  90. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/tests/test_agent_chat_features.py +0 -0
  91. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/tests/test_agent_prompt_models.py +0 -0
  92. {devops_bot_sdk-1.4.139 → devops_bot_sdk-1.4.141}/tests/test_agents_connectors_builder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.139
3
+ Version: 1.4.141
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.139
3
+ Version: 1.4.141
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
@@ -86,4 +86,5 @@ sdk/ui/static/styles.css
86
86
  sdk/ui/static/tickets.html
87
87
  tests/test_agent_chat_features.py
88
88
  tests/test_agent_prompt_models.py
89
- tests/test_agents_connectors_builder.py
89
+ tests/test_agents_connectors_builder.py
90
+ tests/test_git_action.py
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.139
3
+ Version: 1.4.141
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.139" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
33
+ __version__ = "1.4.141" # SINGLE SOURCE OF TRUTH — bump on every change; pyproject,
34
34
  __author__ = "AgentOS" # sdk.client.SDK_VERSION and the UI server version all read this.
35
35
 
36
36
  from sdk.client import BackendClient
@@ -410,3 +410,36 @@ async def finish_pr(
410
410
 
411
411
  return {"pushed": pushed, "pr_url": pr_url, "pr_error": pr_error,
412
412
  "committed": committed, "pr_unavailable": pr_unavailable}
413
+
414
+
415
+ async def push_branch(
416
+ project_path: str, branch: str, github_token: str | None,
417
+ title: str = "chore: sync work",
418
+ ) -> dict:
419
+ """Stage all, commit, and push ``branch`` — WITHOUT opening a PR. Best-effort.
420
+
421
+ The push-only sibling of :func:`finish_pr`: same commit + ``--force-with-lease``
422
+ push transport (HTTPS+token when a token is available, else the box's SSH keys),
423
+ but it deliberately never opens, merges, or touches a pull request. Used by the
424
+ cross-VPS ``git_push`` action (a Lead pushing a member's work to the remote
425
+ without raising a PR).
426
+
427
+ Returns ``{"pushed": bool, "committed": bool, "branch": str,
428
+ "push_error": str | None}`` — never raises.
429
+ """
430
+ path = str(Path(project_path).expanduser())
431
+ env = _git_env(github_token)
432
+
433
+ await _run(["git", "add", "-A"], path, env)
434
+ commit_rc, _, _ = await _run(["git", "commit", "-m", title], path, env)
435
+ committed = commit_rc == 0
436
+
437
+ rc, _, push_err = await _run(
438
+ ["git", "push", "-u", "origin", branch, "--force-with-lease"],
439
+ path, env, timeout=300.0,
440
+ )
441
+ pushed = rc == 0
442
+ if not pushed:
443
+ logger.warning("git_ops.push_only_failed branch=%s error=%s", branch, push_err[:300])
444
+ return {"pushed": pushed, "committed": committed, "branch": branch,
445
+ "push_error": None if pushed else f"push failed: {push_err[:200]}"}
@@ -19,6 +19,7 @@ from sdk.ui.models import (
19
19
  AgentRunRequest,
20
20
  AllowlistEntry,
21
21
  ConfigureRequest,
22
+ GitActionRequest,
22
23
  PromptRegenerateRequest,
23
24
  PromptRunRequest,
24
25
  PromptVerifyRequest,
@@ -142,6 +143,47 @@ async def remove_from_allowlist(ip: str = Query(...)) -> dict:
142
143
  raise HTTPException(status_code=400, detail=str(exc))
143
144
 
144
145
 
146
+ # ── GitHub trigger (PR-only / push-only; never merges) ───────────────────────
147
+ # Standalone git action for the cross-VPS `git_pr` / `git_push` intents: a Lead
148
+ # opens a PR or pushes a member's work on THIS VPS. Delegates to `git_ops`
149
+ # (finish_pr / push_branch — both best-effort, neither ever merges). The GitHub
150
+ # token is fetched server-side from the backend, never taken from the request.
151
+
152
+ @router.post("/git", dependencies=[_auth])
153
+ async def git_action(body: GitActionRequest) -> dict:
154
+ """Open a PR (``operation="pr"``) or push a branch (``operation="push"``).
155
+
156
+ Body: ``GitActionRequest{operation, project_path, branch, base?, title?, body?,
157
+ repo_url?}``. Returns the ``git_ops`` result verbatim (``pushed``, ``pr_url``,
158
+ ``pr_unavailable`` …). An SSH-only box with no token/``gh`` reports
159
+ ``pr_unavailable`` (a soft warning), not an error. 502 if the path is not a
160
+ usable git repo.
161
+ """
162
+ from sdk.client import BackendClient
163
+ from sdk.git_ops import ensure_repo, finish_pr, push_branch
164
+
165
+ github_token = await BackendClient.from_config().github_token()
166
+
167
+ ok = await ensure_repo(body.project_path, body.repo_url or None, github_token)
168
+ if not ok:
169
+ raise HTTPException(
170
+ status_code=502,
171
+ detail=f"{body.project_path} is not a usable git repo (and could not be cloned).",
172
+ )
173
+
174
+ title = body.title or f"chore: {body.operation} {body.branch}"
175
+ if body.operation == "push":
176
+ result = await push_branch(body.project_path, body.branch, github_token, title=title)
177
+ else:
178
+ # base blank → pass the branch so finish_pr's base==head guard resolves
179
+ # the real default branch (never opens a PR against itself).
180
+ base = body.base or body.branch
181
+ result = await finish_pr(
182
+ body.project_path, body.branch, base, title, body.body, github_token,
183
+ )
184
+ return {"engine": "git", "operation": body.operation, **result}
185
+
186
+
145
187
  @router.post("/configure", dependencies=[_auth])
146
188
  async def configure(body: ConfigureRequest) -> dict:
147
189
  from sdk import config as sdk_config
@@ -93,6 +93,45 @@ class WorkspaceRequest(BaseModel):
93
93
  return value or None
94
94
 
95
95
 
96
+ class GitActionRequest(BaseModel):
97
+ """Body for ``POST /api/ui/git`` — a standalone GitHub trigger.
98
+
99
+ Mirrors the cross-VPS ``git_pr`` / ``git_push`` actions (devops-bot-ai
100
+ ``modules/cross_vps/action_router.py``). PR-only or push-only; the SDK NEVER
101
+ merges (see ``git_ops.finish_pr`` contract). The GitHub token is fetched
102
+ server-side (``BackendClient.github_token``) — it is not accepted in the body.
103
+ """
104
+
105
+ operation: str = Field(..., examples=["pr", "push"])
106
+ project_path: str = Field(..., description="Absolute path to the repo working dir on this VPS.")
107
+ branch: str = Field(..., description="The feature branch to commit + push (never the base).")
108
+ base: str = Field("", description="PR base branch; the default branch is resolved when blank.")
109
+ title: str = Field("", description="Commit message + PR title.")
110
+ body: str = Field("", description="PR description (ignored for push).")
111
+ repo_url: str = Field("", description="Optional clone URL if the path is empty/missing.")
112
+
113
+ @field_validator("operation")
114
+ @classmethod
115
+ def _op(cls, value: str) -> str:
116
+ value = (value or "").strip().lower()
117
+ if value not in {"pr", "push"}:
118
+ raise ValueError("operation must be 'pr' or 'push'.")
119
+ return value
120
+
121
+ @field_validator("project_path", "branch")
122
+ @classmethod
123
+ def _required(cls, value: str) -> str:
124
+ value = (value or "").strip()
125
+ if not value:
126
+ raise ValueError("project_path and branch are required and must be non-empty.")
127
+ return value
128
+
129
+ @field_validator("base", "title", "body", "repo_url")
130
+ @classmethod
131
+ def _strip_opt(cls, value: str) -> str:
132
+ return (value or "").strip()
133
+
134
+
96
135
  class TicketRequest(BaseModel):
97
136
  """Body for ``POST /api/ui/ticket`` — one or more Jira ticket keys."""
98
137
 
@@ -0,0 +1,103 @@
1
+ """Tests for the standalone GitHub trigger — model + route (PRD-3 / DBAI-103).
2
+
3
+ ``POST /api/ui/git`` (``sdk/ui/api.py``) backs the cross-VPS ``git_pr`` / ``git_push``
4
+ actions: the devops-bot-ai action router proxies here so a Lead can open a PR or
5
+ push a member's work on this VPS. The SDK NEVER merges (``git_ops.finish_pr`` /
6
+ ``push_branch`` contract). The GitHub token is fetched server-side, never taken
7
+ from the request body.
8
+
9
+ Validation tests need only ``pydantic``; the route test needs the ``[ui]`` extra
10
+ (``fastapi``) and is skipped when it is absent.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from unittest.mock import AsyncMock, patch
15
+
16
+ import pytest
17
+ from pydantic import ValidationError
18
+
19
+ from sdk.ui.models import GitActionRequest
20
+
21
+
22
+ # ── GitActionRequest validation ───────────────────────────────────────
23
+ class TestGitActionRequest:
24
+ def test_pr_minimal_ok(self):
25
+ m = GitActionRequest(operation="pr", project_path="/srv/repo", branch="feature/x")
26
+ assert (m.operation, m.project_path, m.branch) == ("pr", "/srv/repo", "feature/x")
27
+ assert m.base == "" and m.title == "" and m.body == "" and m.repo_url == ""
28
+
29
+ def test_operation_normalised(self):
30
+ assert GitActionRequest(operation="PUSH", project_path="/r", branch="b").operation == "push"
31
+
32
+ def test_strips_path_and_branch(self):
33
+ m = GitActionRequest(operation="pr", project_path=" /srv/r ", branch=" feat ")
34
+ assert m.project_path == "/srv/r" and m.branch == "feat"
35
+
36
+ @pytest.mark.parametrize("op", ["merge", "rebase", "", "delete"])
37
+ def test_bad_operation_rejected(self, op):
38
+ with pytest.raises(ValidationError):
39
+ GitActionRequest(operation=op, project_path="/r", branch="b")
40
+
41
+ @pytest.mark.parametrize("field", ["project_path", "branch"])
42
+ def test_blank_required_rejected(self, field):
43
+ kwargs = {"operation": "pr", "project_path": "/r", "branch": "b"}
44
+ kwargs[field] = " "
45
+ with pytest.raises(ValidationError):
46
+ GitActionRequest(**kwargs)
47
+
48
+
49
+ # ── POST /api/ui/git route ────────────────────────────────────────────
50
+ fastapi = pytest.importorskip("fastapi")
51
+ from fastapi.testclient import TestClient # noqa: E402
52
+
53
+ from sdk.ui.api import require_token, router # noqa: E402
54
+
55
+
56
+ def _app():
57
+ app = fastapi.FastAPI()
58
+ app.include_router(router)
59
+ app.dependency_overrides[require_token] = lambda: None
60
+ return TestClient(app)
61
+
62
+
63
+ class TestGitRoute:
64
+ def test_pr_calls_finish_pr_never_merges(self):
65
+ finish = AsyncMock(return_value={"pushed": True, "pr_url": "http://pr/1", "pr_unavailable": False})
66
+ with patch("sdk.client.BackendClient.from_config") as m_cfg, \
67
+ patch("sdk.git_ops.ensure_repo", AsyncMock(return_value=True)), \
68
+ patch("sdk.git_ops.finish_pr", finish), \
69
+ patch("sdk.git_ops.push_branch", AsyncMock()) as m_push:
70
+ m_cfg.return_value.github_token = AsyncMock(return_value="gho_x")
71
+ resp = _app().post("/api/ui/git", json={
72
+ "operation": "pr", "project_path": "/srv/repo", "branch": "feature/x",
73
+ "title": "My PR", "body": "desc",
74
+ })
75
+ assert resp.status_code == 200
76
+ data = resp.json()
77
+ assert data["engine"] == "git" and data["operation"] == "pr" and data["pr_url"] == "http://pr/1"
78
+ finish.assert_awaited_once()
79
+ m_push.assert_not_called()
80
+
81
+ def test_push_calls_push_branch_no_pr(self):
82
+ push = AsyncMock(return_value={"pushed": True, "committed": True, "branch": "feature/x", "push_error": None})
83
+ with patch("sdk.client.BackendClient.from_config") as m_cfg, \
84
+ patch("sdk.git_ops.ensure_repo", AsyncMock(return_value=True)), \
85
+ patch("sdk.git_ops.push_branch", push), \
86
+ patch("sdk.git_ops.finish_pr", AsyncMock()) as m_finish:
87
+ m_cfg.return_value.github_token = AsyncMock(return_value=None)
88
+ resp = _app().post("/api/ui/git", json={
89
+ "operation": "push", "project_path": "/srv/repo", "branch": "feature/x",
90
+ })
91
+ assert resp.status_code == 200
92
+ assert resp.json()["operation"] == "push"
93
+ push.assert_awaited_once()
94
+ m_finish.assert_not_called()
95
+
96
+ def test_unusable_repo_502(self):
97
+ with patch("sdk.client.BackendClient.from_config") as m_cfg, \
98
+ patch("sdk.git_ops.ensure_repo", AsyncMock(return_value=False)):
99
+ m_cfg.return_value.github_token = AsyncMock(return_value=None)
100
+ resp = _app().post("/api/ui/git", json={
101
+ "operation": "pr", "project_path": "/nope", "branch": "b",
102
+ })
103
+ assert resp.status_code == 502