Skip to content

Commit 94261d2

Browse files
committed
feat(workspace): add git workflow backend APIs
1 parent 573d431 commit 94261d2

5 files changed

Lines changed: 2537 additions & 2 deletions

File tree

app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,8 @@ async def web_search_error_handler(request: Request, exc: WebSearchError):
522522

523523
from routes.workspace_routes import setup_workspace_routes
524524
app.include_router(setup_workspace_routes())
525+
from routes.workspace_git_routes import setup_workspace_git_routes
526+
app.include_router(setup_workspace_git_routes())
525527

526528
# Sessions
527529
from routes.session_routes import setup_session_routes

routes/workspace_git_routes.py

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Callable
4+
5+
from fastapi import APIRouter, Body, Query, Request
6+
from fastapi.responses import JSONResponse
7+
8+
from src.auth_helpers import get_current_user
9+
from src.task_endpoint import resolve_task_endpoint
10+
from src.tool_security import owner_is_admin_or_single_user
11+
from src.workspace_git import (
12+
GitWorkspaceError,
13+
git_blame,
14+
git_branches,
15+
git_checkout,
16+
git_clone,
17+
git_commit,
18+
git_commit_selected,
19+
git_commit_stat,
20+
git_conflicts,
21+
git_create_branch,
22+
git_diff,
23+
git_discard,
24+
git_history,
25+
git_init,
26+
git_remote_action,
27+
git_resolve_conflict,
28+
git_stage,
29+
git_stage_hunk,
30+
git_status,
31+
git_unstage,
32+
git_unstage_hunk,
33+
)
34+
35+
36+
def _error(status_code: int, code: str, message: str) -> JSONResponse:
37+
return JSONResponse({"ok": False, "error": message, "code": code}, status_code=status_code)
38+
39+
40+
def _authorized(request: Request) -> JSONResponse | None:
41+
owner = get_current_user(request)
42+
if not owner_is_admin_or_single_user(owner):
43+
return _error(403, "not_authorized", "Workspace Git APIs are admin-only")
44+
return None
45+
46+
47+
def _run(request: Request, func: Callable[..., dict[str, Any]], *args: Any, **kwargs: Any):
48+
blocked = _authorized(request)
49+
if blocked is not None:
50+
return blocked
51+
try:
52+
return func(*args, **kwargs)
53+
except GitWorkspaceError as exc:
54+
status = 403 if exc.code == "not_authorized" else 400
55+
return _error(status, exc.code, exc.message)
56+
57+
58+
def _body_list(body: dict[str, Any], key: str) -> list[str]:
59+
value = body.get(key)
60+
return value if isinstance(value, list) else []
61+
62+
63+
def _hunk_id(body: dict[str, Any]) -> str:
64+
return str(body.get("hunkId") or body.get("hunk_id") or "")
65+
66+
67+
# ── AI commit-message generation ────────────────────────────────────────────
68+
_COMMIT_MSG_MAX_DIFF = 12000
69+
70+
71+
def _resolve_commit_model(session_id: str | None, owner: str | None):
72+
"""(endpoint_url, model, headers) for the message. Prefer the exact model/
73+
endpoint/key of the chat session the user has selected; fall back to the
74+
configured background-task endpoint when no session model is available."""
75+
if session_id:
76+
try:
77+
from src.ai_interaction import get_session_manager
78+
sm = get_session_manager()
79+
sess = sm.get_session(session_id) if sm else None
80+
if sess and getattr(sess, "model", None):
81+
# Refresh the session's auth exactly like the chat path does.
82+
# Session-backed providers (ChatGPT Subscription, Copilot, …) keep
83+
# a short-lived bearer that is re-resolved per request and never
84+
# persisted to the session, so reading sess.headers alone yields no
85+
# token and the model call goes out unauthorized (401).
86+
try:
87+
from routes.chat_helpers import resolve_session_auth
88+
resolve_session_auth(sess, session_id, owner)
89+
except Exception:
90+
pass
91+
return (
92+
getattr(sess, "endpoint_url", None) or None,
93+
sess.model,
94+
getattr(sess, "headers", None) or None,
95+
)
96+
except Exception:
97+
pass
98+
return resolve_task_endpoint(owner=owner)
99+
100+
101+
def _clean_commit_text(raw: str) -> str:
102+
text = raw or ""
103+
try:
104+
from src.text_helpers import strip_think
105+
text = strip_think(text, prose=False, prompt_echo=False)
106+
except Exception:
107+
pass
108+
text = (text or "").strip()
109+
if text.startswith("```"):
110+
lines = text.split("\n")
111+
if lines and lines[0].startswith("```"):
112+
lines = lines[1:]
113+
if lines and lines[-1].strip().startswith("```"):
114+
lines = lines[:-1]
115+
text = "\n".join(lines).strip()
116+
if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
117+
text = text[1:-1].strip()
118+
return text
119+
120+
121+
def _generate_commit_message(workspace: str | None, session_id: str | None, owner: str | None) -> dict[str, Any]:
122+
# Prefer the staged diff; fall back to the full working-tree diff.
123+
diff = git_diff(workspace, None, True)
124+
patch = (diff.get("patch") or "").strip()
125+
scope = "staged"
126+
if not patch:
127+
diff = git_diff(workspace, None, False)
128+
patch = (diff.get("patch") or "").strip()
129+
scope = "uncommitted"
130+
if not patch:
131+
raise GitWorkspaceError("no_changes", "No changes to describe")
132+
truncated = bool(diff.get("truncated")) or len(patch) > _COMMIT_MSG_MAX_DIFF
133+
if len(patch) > _COMMIT_MSG_MAX_DIFF:
134+
patch = patch[:_COMMIT_MSG_MAX_DIFF]
135+
136+
url, model, headers = _resolve_commit_model(session_id, owner)
137+
if not model:
138+
raise GitWorkspaceError("llm_failed", "No model is available to generate a message")
139+
140+
system = (
141+
"You write clear git commit messages. Return ONLY the commit message: a "
142+
"concise imperative subject line (<= 72 chars), optionally followed by a "
143+
"blank line and a short body. No markdown, no code fences, no surrounding "
144+
"quotes, no preamble."
145+
)
146+
note = "\n\n[diff truncated]" if truncated else ""
147+
user = f"Write a commit message for these {scope} changes:\n\n{patch}{note}"
148+
try:
149+
from src.llm_core import llm_call
150+
raw = llm_call(
151+
url, model,
152+
[{"role": "system", "content": system}, {"role": "user", "content": user}],
153+
temperature=0.3, max_tokens=512, headers=headers or None, timeout=30,
154+
)
155+
except GitWorkspaceError:
156+
raise
157+
except Exception as exc: # provider/network failure
158+
raise GitWorkspaceError("llm_failed", f"Model call failed: {exc}")
159+
160+
message = _clean_commit_text(raw)
161+
if not message:
162+
raise GitWorkspaceError("llm_failed", "The model returned an empty message")
163+
return {"ok": True, "message": message, "model": model, "truncated": truncated}
164+
165+
166+
def setup_workspace_git_routes() -> APIRouter:
167+
router = APIRouter(prefix="/api/workspace/git", tags=["workspace-git"])
168+
169+
@router.get("/status")
170+
def status(request: Request, workspace: str | None = Query(default=None)):
171+
return _run(request, git_status, workspace)
172+
173+
@router.get("/diff")
174+
def diff(
175+
request: Request,
176+
workspace: str | None = Query(default=None),
177+
path: str | None = Query(default=None),
178+
staged: bool = Query(default=False),
179+
):
180+
return _run(request, git_diff, workspace, path, staged)
181+
182+
@router.post("/stage")
183+
def stage(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
184+
return _run(request, git_stage, body.get("workspace"), _body_list(body, "paths"))
185+
186+
@router.post("/unstage")
187+
def unstage(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
188+
return _run(request, git_unstage, body.get("workspace"), _body_list(body, "paths"))
189+
190+
@router.post("/discard")
191+
def discard(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
192+
return _run(
193+
request,
194+
git_discard,
195+
body.get("workspace"),
196+
_body_list(body, "paths"),
197+
bool(body.get("confirmConflict") or body.get("confirm_conflict")),
198+
)
199+
200+
@router.post("/hunks/stage")
201+
def stage_hunk(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
202+
return _run(request, git_stage_hunk, body.get("workspace"), body.get("path"), _hunk_id(body))
203+
204+
@router.post("/hunks/unstage")
205+
def unstage_hunk(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
206+
return _run(request, git_unstage_hunk, body.get("workspace"), body.get("path"), _hunk_id(body))
207+
208+
@router.post("/commit")
209+
def commit(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
210+
return _run(request, git_commit, body.get("workspace"), body.get("message"))
211+
212+
@router.post("/commit-selected")
213+
def commit_selected(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
214+
return _run(request, git_commit_selected, body.get("workspace"), _body_list(body, "paths"), body.get("message"))
215+
216+
@router.get("/branches")
217+
def branches(request: Request, workspace: str | None = Query(default=None)):
218+
return _run(request, git_branches, workspace)
219+
220+
@router.post("/checkout")
221+
def checkout(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
222+
return _run(request, git_checkout, body.get("workspace"), body.get("branch"), stash=False)
223+
224+
@router.post("/checkout-stash")
225+
def checkout_stash(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
226+
return _run(request, git_checkout, body.get("workspace"), body.get("branch"), stash=True)
227+
228+
@router.post("/branch/create")
229+
def branch_create(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
230+
return _run(request, git_create_branch, body.get("workspace"), body.get("branch"))
231+
232+
@router.post("/commit-message")
233+
def commit_message(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
234+
blocked = _authorized(request)
235+
if blocked is not None:
236+
return blocked
237+
owner = get_current_user(request)
238+
session_id = body.get("sessionId") or body.get("session_id")
239+
try:
240+
return _generate_commit_message(body.get("workspace"), session_id, owner)
241+
except GitWorkspaceError as exc:
242+
status = 403 if exc.code == "not_authorized" else 400
243+
return _error(status, exc.code, exc.message)
244+
245+
@router.post("/fetch")
246+
def fetch(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
247+
return _run(request, git_remote_action, body.get("workspace"), "fetch")
248+
249+
@router.post("/pull")
250+
def pull(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
251+
return _run(request, git_remote_action, body.get("workspace"), "pull")
252+
253+
@router.post("/push")
254+
def push(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
255+
return _run(request, git_remote_action, body.get("workspace"), "push")
256+
257+
@router.post("/init")
258+
def init(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
259+
return _run(request, git_init, body.get("workspace"))
260+
261+
@router.post("/clone")
262+
def clone(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
263+
return _run(request, git_clone, body.get("workspace"), body.get("url"), body.get("target"), body.get("name"))
264+
265+
@router.get("/history")
266+
def history(
267+
request: Request,
268+
workspace: str | None = Query(default=None),
269+
path: str | None = Query(default=None),
270+
limit: int = Query(default=50),
271+
):
272+
return _run(request, git_history, workspace, path, limit)
273+
274+
@router.get("/commit")
275+
def commit_stat(
276+
request: Request,
277+
workspace: str | None = Query(default=None),
278+
sha: str | None = Query(default=None),
279+
):
280+
return _run(request, git_commit_stat, workspace, sha)
281+
282+
@router.get("/blame")
283+
def blame(request: Request, workspace: str | None = Query(default=None), path: str | None = Query(default=None)):
284+
return _run(request, git_blame, workspace, path)
285+
286+
@router.get("/conflicts")
287+
def conflicts(request: Request, workspace: str | None = Query(default=None)):
288+
return _run(request, git_conflicts, workspace)
289+
290+
@router.post("/conflict/resolve")
291+
def resolve_conflict(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
292+
blocked = _authorized(request)
293+
if blocked is not None:
294+
return blocked
295+
if "content" not in body or not isinstance(body.get("content"), str):
296+
return _error(400, "invalid_request", "content must be provided as text")
297+
try:
298+
return git_resolve_conflict(body.get("workspace"), body.get("path"), body.get("content"))
299+
except GitWorkspaceError as exc:
300+
status = 403 if exc.code == "not_authorized" else 400
301+
return _error(status, exc.code, exc.message)
302+
303+
return router

routes/workspace_routes.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,40 @@
1-
"""Workspace API — browse server directories to pick a tool workspace folder."""
1+
"""Workspace API — browse and edit files in a selected tool workspace."""
22
import os
3-
from fastapi import APIRouter, Request, HTTPException, Query
3+
from typing import Any
4+
5+
from fastapi import APIRouter, Body, Request, HTTPException, Query
6+
from fastapi.responses import JSONResponse
47

58
from src.auth_helpers import get_current_user
69
from src.tool_security import owner_is_admin_or_single_user
10+
from src.workspace_git import (
11+
GitWorkspaceError,
12+
delete_workspace_file,
13+
list_workspace_files,
14+
read_workspace_file,
15+
save_workspace_file,
16+
)
17+
18+
19+
def _workspace_error(status_code: int, code: str, message: str) -> JSONResponse:
20+
return JSONResponse({"ok": False, "error": message, "code": code}, status_code=status_code)
21+
22+
23+
def _require_workspace_admin(request: Request) -> JSONResponse | None:
24+
owner = get_current_user(request)
25+
if not owner_is_admin_or_single_user(owner):
26+
return _workspace_error(403, "not_authorized", "Workspace APIs are admin-only")
27+
return None
28+
29+
30+
def _run_workspace(request: Request, func, *args):
31+
blocked = _require_workspace_admin(request)
32+
if blocked is not None:
33+
return blocked
34+
try:
35+
return func(*args)
36+
except GitWorkspaceError as exc:
37+
return _workspace_error(400, exc.code, exc.message)
738

839

940
def setup_workspace_routes():
@@ -53,4 +84,34 @@ def browse(request: Request, path: str = Query(default="")):
5384
"dirs": sorted(dirs, key=lambda d: d["name"].lower()),
5485
}
5586

87+
@router.get("/files")
88+
def files(request: Request, workspace: str | None = Query(default=None), path: str = Query(default="")):
89+
return _run_workspace(request, list_workspace_files, workspace, path)
90+
91+
@router.get("/file")
92+
def file(request: Request, workspace: str | None = Query(default=None), path: str = Query(default="")):
93+
return _run_workspace(request, read_workspace_file, workspace, path)
94+
95+
@router.post("/file/save")
96+
def file_save(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
97+
blocked = _require_workspace_admin(request)
98+
if blocked is not None:
99+
return blocked
100+
if "content" not in body or not isinstance(body.get("content"), str):
101+
return _workspace_error(400, "invalid_request", "content must be provided as text")
102+
try:
103+
return save_workspace_file(body.get("workspace"), body.get("path") or "", body.get("content"))
104+
except GitWorkspaceError as exc:
105+
return _workspace_error(400, exc.code, exc.message)
106+
107+
@router.post("/file/delete")
108+
def file_delete(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
109+
blocked = _require_workspace_admin(request)
110+
if blocked is not None:
111+
return blocked
112+
try:
113+
return delete_workspace_file(body.get("workspace"), body.get("path") or "")
114+
except GitWorkspaceError as exc:
115+
return _workspace_error(400, exc.code, exc.message)
116+
56117
return router

0 commit comments

Comments
 (0)