Skip to content
This repository was archived by the owner on Jun 5, 2025. It is now read-only.

Commit 81dacd6

Browse files
authored
Merge branch 'main' into run-on-main
2 parents 8d966cf + 84c8a6e commit 81dacd6

File tree

6 files changed

+330
-4
lines changed

6 files changed

+330
-4
lines changed

api/openapi.json

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,108 @@
306306
}
307307
}
308308
},
309+
"/api/v1/workspaces/archive": {
310+
"get": {
311+
"tags": [
312+
"CodeGate API",
313+
"Workspaces"
314+
],
315+
"summary": "List Archived Workspaces",
316+
"description": "List all archived workspaces.",
317+
"operationId": "v1_list_archived_workspaces",
318+
"responses": {
319+
"200": {
320+
"description": "Successful Response",
321+
"content": {
322+
"application/json": {
323+
"schema": {
324+
"$ref": "#/components/schemas/ListWorkspacesResponse"
325+
}
326+
}
327+
}
328+
}
329+
}
330+
}
331+
},
332+
"/api/v1/workspaces/archive/{workspace_name}/recover": {
333+
"post": {
334+
"tags": [
335+
"CodeGate API",
336+
"Workspaces"
337+
],
338+
"summary": "Recover Workspace",
339+
"description": "Recover an archived workspace by name.",
340+
"operationId": "v1_recover_workspace",
341+
"parameters": [
342+
{
343+
"name": "workspace_name",
344+
"in": "path",
345+
"required": true,
346+
"schema": {
347+
"type": "string",
348+
"title": "Workspace Name"
349+
}
350+
}
351+
],
352+
"responses": {
353+
"204": {
354+
"description": "Successful Response"
355+
},
356+
"422": {
357+
"description": "Validation Error",
358+
"content": {
359+
"application/json": {
360+
"schema": {
361+
"$ref": "#/components/schemas/HTTPValidationError"
362+
}
363+
}
364+
}
365+
}
366+
}
367+
}
368+
},
369+
"/api/v1/workspaces/archive/{workspace_name}": {
370+
"delete": {
371+
"tags": [
372+
"CodeGate API",
373+
"Workspaces"
374+
],
375+
"summary": "Hard Delete Workspace",
376+
"description": "Hard delete an archived workspace by name.",
377+
"operationId": "v1_hard_delete_workspace",
378+
"parameters": [
379+
{
380+
"name": "workspace_name",
381+
"in": "path",
382+
"required": true,
383+
"schema": {
384+
"type": "string",
385+
"title": "Workspace Name"
386+
}
387+
}
388+
],
389+
"responses": {
390+
"200": {
391+
"description": "Successful Response",
392+
"content": {
393+
"application/json": {
394+
"schema": {}
395+
}
396+
}
397+
},
398+
"422": {
399+
"description": "Validation Error",
400+
"content": {
401+
"application/json": {
402+
"schema": {
403+
"$ref": "#/components/schemas/HTTPValidationError"
404+
}
405+
}
406+
}
407+
}
408+
}
409+
}
410+
},
309411
"/api/v1/workspaces/{workspace_name}/alerts": {
310412
"get": {
311413
"tags": [

src/codegate/api/v1.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async def list_workspaces() -> v1_models.ListWorkspacesResponse:
2727
"""List all workspaces."""
2828
wslist = await wscrud.get_workspaces()
2929

30-
resp = v1_models.ListWorkspacesResponse.from_db_workspaces(wslist)
30+
resp = v1_models.ListWorkspacesResponse.from_db_workspaces_active(wslist)
3131

3232
return resp
3333

@@ -136,6 +136,55 @@ async def delete_workspace(workspace_name: str):
136136
return Response(status_code=204)
137137

138138

139+
@v1.get("/workspaces/archive", tags=["Workspaces"], generate_unique_id_function=uniq_name)
140+
async def list_archived_workspaces() -> v1_models.ListWorkspacesResponse:
141+
"""List all archived workspaces."""
142+
wslist = await wscrud.get_archived_workspaces()
143+
144+
resp = v1_models.ListWorkspacesResponse.from_db_workspaces(wslist)
145+
146+
return resp
147+
148+
149+
@v1.post(
150+
"/workspaces/archive/{workspace_name}/recover",
151+
tags=["Workspaces"],
152+
generate_unique_id_function=uniq_name,
153+
status_code=204,
154+
)
155+
async def recover_workspace(workspace_name: str):
156+
"""Recover an archived workspace by name."""
157+
try:
158+
_ = await wscrud.recover_workspace(workspace_name)
159+
except crud.WorkspaceDoesNotExistError:
160+
raise HTTPException(status_code=404, detail="Workspace does not exist")
161+
except crud.WorkspaceCrudError as e:
162+
raise HTTPException(status_code=400, detail=str(e))
163+
except Exception:
164+
raise HTTPException(status_code=500, detail="Internal server error")
165+
166+
return Response(status_code=204)
167+
168+
169+
@v1.delete(
170+
"/workspaces/archive/{workspace_name}",
171+
tags=["Workspaces"],
172+
generate_unique_id_function=uniq_name,
173+
)
174+
async def hard_delete_workspace(workspace_name: str):
175+
"""Hard delete an archived workspace by name."""
176+
try:
177+
_ = await wscrud.hard_delete_workspace(workspace_name)
178+
except crud.WorkspaceDoesNotExistError:
179+
raise HTTPException(status_code=404, detail="Workspace does not exist")
180+
except crud.WorkspaceCrudError as e:
181+
raise HTTPException(status_code=400, detail=str(e))
182+
except Exception:
183+
raise HTTPException(status_code=500, detail="Internal server error")
184+
185+
return Response(status_code=204)
186+
187+
139188
@v1.get(
140189
"/workspaces/{workspace_name}/alerts",
141190
tags=["Workspaces"],

src/codegate/api/v1_models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class ListWorkspacesResponse(pydantic.BaseModel):
2323
workspaces: list[Workspace]
2424

2525
@classmethod
26-
def from_db_workspaces(
26+
def from_db_workspaces_active(
2727
cls, db_workspaces: List[db_models.WorkspaceActive]
2828
) -> "ListWorkspacesResponse":
2929
return cls(
@@ -33,6 +33,12 @@ def from_db_workspaces(
3333
]
3434
)
3535

36+
@classmethod
37+
def from_db_workspaces(
38+
cls, db_workspaces: List[db_models.Workspace]
39+
) -> "ListWorkspacesResponse":
40+
return cls(workspaces=[Workspace(name=ws.name, is_active=False) for ws in db_workspaces])
41+
3642

3743
class ListActiveWorkspacesResponse(pydantic.BaseModel):
3844
workspaces: list[ActiveWorkspace]

src/codegate/db/connection.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
from alembic import command as alembic_command
99
from alembic.config import Config as AlembicConfig
1010
from pydantic import BaseModel
11-
from sqlalchemy import CursorResult, TextClause, text
11+
from sqlalchemy import CursorResult, TextClause, event, text
12+
from sqlalchemy.engine import Engine
1213
from sqlalchemy.exc import IntegrityError, OperationalError
1314
from sqlalchemy.ext.asyncio import create_async_engine
1415

@@ -35,6 +36,20 @@ class AlreadyExistsError(Exception):
3536
pass
3637

3738

39+
@event.listens_for(Engine, "connect")
40+
def set_sqlite_pragma(dbapi_connection, connection_record):
41+
"""
42+
Ensures that foreign keys are enabled for the SQLite database at every connection.
43+
SQLite does not enforce foreign keys by default, so we need to enable them manually.
44+
[SQLAlchemy docs](https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#foreign-key-support)
45+
[SQLite docs](https://www.sqlite.org/foreignkeys.html)
46+
[SO](https://stackoverflow.com/questions/2614984/sqlite-sqlalchemy-how-to-enforce-foreign-keys)
47+
"""
48+
cursor = dbapi_connection.cursor()
49+
cursor.execute("PRAGMA foreign_keys=ON")
50+
cursor.close()
51+
52+
3853
class DbCodeGate:
3954
_instance = None
4055

@@ -318,6 +333,33 @@ async def soft_delete_workspace(self, workspace: Workspace) -> Optional[Workspac
318333
)
319334
return deleted_workspace
320335

336+
async def hard_delete_workspace(self, workspace: Workspace) -> Optional[Workspace]:
337+
sql = text(
338+
"""
339+
DELETE FROM workspaces
340+
WHERE id = :id
341+
RETURNING *
342+
"""
343+
)
344+
deleted_workspace = await self._execute_update_pydantic_model(
345+
workspace, sql, should_raise=True
346+
)
347+
return deleted_workspace
348+
349+
async def recover_workspace(self, workspace: Workspace) -> Optional[Workspace]:
350+
sql = text(
351+
"""
352+
UPDATE workspaces
353+
SET deleted_at = NULL
354+
WHERE id = :id
355+
RETURNING *
356+
"""
357+
)
358+
recovered_workspace = await self._execute_update_pydantic_model(
359+
workspace, sql, should_raise=True
360+
)
361+
return recovered_workspace
362+
321363

322364
class DbReader(DbCodeGate):
323365

@@ -431,6 +473,19 @@ async def get_workspaces(self) -> List[WorkspaceActive]:
431473
workspaces = await self._execute_select_pydantic_model(WorkspaceActive, sql)
432474
return workspaces
433475

476+
async def get_archived_workspaces(self) -> List[Workspace]:
477+
sql = text(
478+
"""
479+
SELECT
480+
id, name, system_prompt
481+
FROM workspaces
482+
WHERE deleted_at IS NOT NULL
483+
ORDER BY deleted_at DESC
484+
"""
485+
)
486+
workspaces = await self._execute_select_pydantic_model(Workspace, sql)
487+
return workspaces
488+
434489
async def get_workspace_by_name(self, name: str) -> Optional[Workspace]:
435490
sql = text(
436491
"""
@@ -446,6 +501,21 @@ async def get_workspace_by_name(self, name: str) -> Optional[Workspace]:
446501
)
447502
return workspaces[0] if workspaces else None
448503

504+
async def get_archived_workspace_by_name(self, name: str) -> Optional[Workspace]:
505+
sql = text(
506+
"""
507+
SELECT
508+
id, name, system_prompt
509+
FROM workspaces
510+
WHERE name = :name AND deleted_at IS NOT NULL
511+
"""
512+
)
513+
conditions = {"name": name}
514+
workspaces = await self._exec_select_conditions_to_pydantic(
515+
Workspace, sql, conditions, should_raise=True
516+
)
517+
return workspaces[0] if workspaces else None
518+
449519
async def get_sessions(self) -> List[Session]:
450520
sql = text(
451521
"""

src/codegate/pipeline/cli/commands.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ def subcommands(self) -> Dict[str, Callable[[List[str]], Awaitable[str]]]:
155155
"activate": self._activate_workspace,
156156
"remove": self._remove_workspace,
157157
"rename": self._rename_workspace,
158+
"list-archived": self._list_archived_workspaces,
159+
"restore": self._restore_workspace,
160+
"delete-archived": self._delete_archived_workspace,
158161
}
159162

160163
async def _list_workspaces(self, flags: Dict[str, str], args: List[str]) -> str:
@@ -267,6 +270,58 @@ async def _remove_workspace(self, flags: Dict[str, str], args: List[str]) -> str
267270
return "An error occurred while removing the workspace"
268271
return f"Workspace **{workspace_name}** has been removed"
269272

273+
async def _list_archived_workspaces(self, flags: Dict[str, str], args: List[str]) -> str:
274+
"""
275+
List all archived workspaces
276+
"""
277+
workspaces = await self.workspace_crud.get_archived_workspaces()
278+
respond_str = ""
279+
for workspace in workspaces:
280+
respond_str += f"- {workspace.name}\n"
281+
return respond_str
282+
283+
async def _restore_workspace(self, flags: Dict[str, str], args: List[str]) -> str:
284+
"""
285+
Restore an archived workspace
286+
"""
287+
if args is None or len(args) == 0:
288+
return "Please provide a name. Use `codegate workspace restore workspace_name`"
289+
290+
workspace_name = args[0]
291+
if not workspace_name:
292+
return "Please provide a name. Use `codegate workspace restore workspace_name`"
293+
294+
try:
295+
await self.workspace_crud.recover_workspace(workspace_name)
296+
except crud.WorkspaceDoesNotExistError:
297+
return f"Workspace **{workspace_name}** does not exist"
298+
except crud.WorkspaceCrudError as e:
299+
return str(e)
300+
except Exception:
301+
return "An error occurred while restoring the workspace"
302+
return f"Workspace **{workspace_name}** has been restored"
303+
304+
async def _delete_archived_workspace(self, flags: Dict[str, str], args: List[str]) -> str:
305+
"""
306+
Hard delete an archived workspace
307+
"""
308+
if args is None or len(args) == 0:
309+
return "Please provide a name. Use `codegate workspace delete-archived workspace_name`"
310+
311+
workspace_name = args[0]
312+
if not workspace_name:
313+
return "Please provide a name. Use `codegate workspace delete-archived workspace_name`"
314+
315+
try:
316+
await self.workspace_crud.hard_delete_workspace(workspace_name)
317+
except crud.WorkspaceDoesNotExistError:
318+
return f"Workspace **{workspace_name}** does not exist"
319+
except crud.WorkspaceCrudError as e:
320+
return str(e)
321+
except Exception:
322+
return "An error occurred while deleting the workspace"
323+
return f"Workspace **{workspace_name}** has been deleted"
324+
270325
@property
271326
def help(self) -> str:
272327
return (
@@ -289,6 +344,14 @@ def help(self) -> str:
289344
" - *args*:\n\n"
290345
" - `workspace_name`\n"
291346
" - `new_workspace_name`\n\n"
347+
"- `list-archived`: List all archived workspaces\n\n"
348+
" - *args*: None\n\n"
349+
"- `restore`: Restore an archived workspace\n\n"
350+
" - *args*:\n\n"
351+
" - `workspace_name`\n\n"
352+
"- `delete-archived`: Hard delete an archived workspace\n\n"
353+
" - *args*:\n\n"
354+
" - `workspace_name`\n\n"
292355
)
293356

294357

0 commit comments

Comments
 (0)