diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 1a58249..593cef4 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -24,7 +24,18 @@ from src.schemas.config_profile import ( DefaultProfilesUpdate, ) from src.services.config_profiles import ( + check_duplicate_include, + check_duplicate_mount_path, + check_duplicate_name, + get_default_profile_for_tool_type, + get_default_profiles, get_owned_profile, + include_to_dict, + list_includes_for_profile, + list_mounts_for_profile, + mount_to_dict, + profile_to_dict, + set_default_profiles, validate_includes_no_cycle, ) @@ -33,11 +44,8 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) -# --------------------------------------------------------------------------- -# Profile CRUD -# --------------------------------------------------------------------------- -@router.get("", summary="List config profiles") +@router.get("") async def list_config_profiles( tool_type_id: str | None = None, user_id: uuid.UUID = Depends(get_current_user_id), @@ -48,107 +56,51 @@ async def list_config_profiles( tool_type = await session.get(ToolType, uuid.UUID(tool_type_id)) if tool_type is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found") - result = await session.execute(query.order_by(ConfigProfile.name)) - profiles = result.scalars().all() - - return { - "profiles": [ - { - "id": str(p.id), - "user_id": str(p.user_id), - "name": p.name, - "description": p.description, - "created_at": p.created_at.isoformat() if p.created_at else None, - "updated_at": p.updated_at.isoformat() if p.updated_at else None, - } - for p in profiles - ] - } + return {"profiles": [profile_to_dict(p) for p in result.scalars().all()]} -@router.post("", status_code=status.HTTP_201_CREATED, summary="Create config profile") +@router.post("", status_code=status.HTTP_201_CREATED) async def create_config_profile( data: ConfigProfileCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - existing = await session.scalar( - select(ConfigProfile).where( - ConfigProfile.user_id == user_id, - ConfigProfile.name == data.name, - ) - ) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"config profile with name '{data.name}' already exists", - ) - + await check_duplicate_name(session, user_id, data.name) profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description) session.add(profile) await session.commit() await session.refresh(profile) - - return { - "id": str(profile.id), - "user_id": str(profile.user_id), - "name": profile.name, - "description": profile.description, - "created_at": profile.created_at.isoformat() if profile.created_at else None, - "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, - } + return profile_to_dict(profile) -@router.get("/defaults", summary="Get default profiles") -async def get_default_profiles( +@router.get("/defaults") +async def get_default_profiles_endpoint( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) - user_config = result.scalar_one_or_none() - return {"default_profiles": user_config.default_profiles if user_config else {}} + return await get_default_profiles(session, user_id) -@router.put("/defaults", summary="Set default profiles") -async def set_default_profiles( +@router.put("/defaults") +async def set_default_profiles_endpoint( data: DefaultProfilesUpdate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) - user_config = result.scalar_one_or_none() - - if user_config is None: - user_config = UserConfig(user_id=user_id, config={}) - session.add(user_config) - - for tool_type_id, profile_id_str in data.default_profiles.items(): - profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str)) - if profile is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found") - if profile.user_id != user_id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user") - - user_config.config = {**user_config.config, "default_profiles": data.default_profiles} - await session.commit() - await session.refresh(user_config) - return {"default_profiles": user_config.default_profiles} + return await set_default_profiles(session, user_id, data.default_profiles) -@router.get("/defaults/{tool_type_id}", summary="Get default profile for tool type") -async def get_default_profile_for_tool_type( +@router.get("/defaults/{tool_type_id}") +async def get_default_profile_for_tool_type_endpoint( tool_type_id: str, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) - user_config = result.scalar_one_or_none() - profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None - return {"tool_type_id": tool_type_id, "profile_id": profile_id} + return await get_default_profile_for_tool_type(session, user_id, tool_type_id) -@router.get("/{profile_id}", summary="Get config profile") +@router.get("/{profile_id}") async def get_config_profile( profile_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), @@ -161,47 +113,18 @@ async def get_config_profile( ) if profile is None or profile.user_id != user_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found") - includes_data = [] for inc in profile.includes: included_profile = await session.get(ConfigProfile, inc.included_profile_id) - includes_data.append({ - "id": str(inc.id), - "profile_id": str(inc.profile_id), - "included_profile_id": str(inc.included_profile_id), - "included_profile_name": included_profile.name if included_profile else None, - "order_index": inc.order_index, - "created_at": inc.created_at.isoformat() if inc.created_at else None, - "updated_at": inc.updated_at.isoformat() if inc.updated_at else None, - }) - - mounts_data = [ - { - "id": str(m.id), - "profile_id": str(m.profile_id), - "target_path": m.target_path, - "mode": m.mode, - "files": m.files, - "order_index": m.order_index, - "created_at": m.created_at.isoformat() if m.created_at else None, - "updated_at": m.updated_at.isoformat() if m.updated_at else None, - } - for m in profile.mounts - ] - + includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None)) return { - "id": str(profile.id), - "user_id": str(profile.user_id), - "name": profile.name, - "description": profile.description, + **profile_to_dict(profile), "includes": includes_data, - "mounts": mounts_data, - "created_at": profile.created_at.isoformat() if profile.created_at else None, - "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + "mounts": [mount_to_dict(m) for m in profile.mounts], } -@router.put("/{profile_id}", summary="Update config profile") +@router.put("/{profile_id}") async def update_config_profile( profile_id: uuid.UUID, data: ConfigProfileUpdate, @@ -209,39 +132,17 @@ async def update_config_profile( session: AsyncSession = Depends(get_db_session), ) -> dict: profile = await get_owned_profile(profile_id, user_id, session) - if data.name is not None: - existing = await session.scalar( - select(ConfigProfile).where( - ConfigProfile.user_id == user_id, - ConfigProfile.name == data.name, - ConfigProfile.id != profile_id, - ) - ) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"config profile with name '{data.name}' already exists", - ) + await check_duplicate_name(session, user_id, data.name, exclude_id=profile_id) profile.name = data.name - if data.description is not None: profile.description = data.description - await session.commit() await session.refresh(profile) - - return { - "id": str(profile.id), - "user_id": str(profile.user_id), - "name": profile.name, - "description": profile.description, - "created_at": profile.created_at.isoformat() if profile.created_at else None, - "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, - } + return profile_to_dict(profile) -@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete config profile") +@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_config_profile( profile_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), @@ -252,41 +153,18 @@ async def delete_config_profile( await session.commit() -# --------------------------------------------------------------------------- -# Include management -# --------------------------------------------------------------------------- -@router.get("/{profile_id}/includes", summary="List profile includes") +@router.get("/{profile_id}/includes") async def list_profile_includes( profile_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: await get_owned_profile(profile_id, user_id, session) - result = await session.execute( - select(ConfigInclude) - .where(ConfigInclude.profile_id == profile_id) - .order_by(ConfigInclude.order_index) - ) - includes = result.scalars().all() - - includes_data = [] - for inc in includes: - included_profile = await session.get(ConfigProfile, inc.included_profile_id) - includes_data.append({ - "id": str(inc.id), - "profile_id": str(inc.profile_id), - "included_profile_id": str(inc.included_profile_id), - "included_profile_name": included_profile.name if included_profile else None, - "order_index": inc.order_index, - "created_at": inc.created_at.isoformat() if inc.created_at else None, - "updated_at": inc.updated_at.isoformat() if inc.updated_at else None, - }) - - return {"includes": includes_data} + return await list_includes_for_profile(session, profile_id) -@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED, summary="Add profile include") +@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED) async def add_profile_include( profile_id: uuid.UUID, data: ConfigIncludeCreate, @@ -295,27 +173,15 @@ async def add_profile_include( ) -> dict: profile = await get_owned_profile(profile_id, user_id, session) included_profile_id = uuid.UUID(data.included_profile_id) - if included_profile_id == profile_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself") - included_profile = await session.get(ConfigProfile, included_profile_id) if included_profile is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="included profile not found") if included_profile.user_id != user_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user") - - existing = await session.scalar( - select(ConfigInclude).where( - ConfigInclude.profile_id == profile_id, - ConfigInclude.included_profile_id == included_profile_id, - ) - ) - if existing: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="this include already exists") - + await check_duplicate_include(session, profile_id, included_profile_id) await validate_includes_no_cycle(session, profile_id, included_profile_id) - include = ConfigInclude( profile_id=profile_id, included_profile_id=included_profile_id, @@ -324,19 +190,10 @@ async def add_profile_include( session.add(include) await session.commit() await session.refresh(include) - - return { - "id": str(include.id), - "profile_id": str(include.profile_id), - "included_profile_id": str(include.included_profile_id), - "included_profile_name": included_profile.name, - "order_index": include.order_index, - "created_at": include.created_at.isoformat() if include.created_at else None, - "updated_at": include.updated_at.isoformat() if include.updated_at else None, - } + return include_to_dict(include, included_profile.name) -@router.put("/{profile_id}/includes/{include_id}", summary="Update profile include") +@router.put("/{profile_id}/includes/{include_id}") async def update_profile_include( profile_id: uuid.UUID, include_id: uuid.UUID, @@ -348,24 +205,14 @@ async def update_profile_include( include = await session.get(ConfigInclude, include_id) if include is None or include.profile_id != profile_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found") - include.order_index = data.order_index await session.commit() await session.refresh(include) - included_profile = await session.get(ConfigProfile, include.included_profile_id) - return { - "id": str(include.id), - "profile_id": str(include.profile_id), - "included_profile_id": str(include.included_profile_id), - "included_profile_name": included_profile.name if included_profile else None, - "order_index": include.order_index, - "created_at": include.created_at.isoformat() if include.created_at else None, - "updated_at": include.updated_at.isoformat() if include.updated_at else None, - } + return include_to_dict(include, included_profile.name if included_profile else None) -@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile include") +@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_profile_include( profile_id: uuid.UUID, include_id: uuid.UUID, @@ -380,42 +227,18 @@ async def remove_profile_include( await session.commit() -# --------------------------------------------------------------------------- -# Mount management -# --------------------------------------------------------------------------- -@router.get("/{profile_id}/mounts", summary="List profile mounts") +@router.get("/{profile_id}/mounts") async def list_profile_mounts( profile_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: await get_owned_profile(profile_id, user_id, session) - result = await session.execute( - select(ConfigMount) - .where(ConfigMount.profile_id == profile_id) - .order_by(ConfigMount.order_index) - ) - mounts = result.scalars().all() - - return { - "mounts": [ - { - "id": str(m.id), - "profile_id": str(m.profile_id), - "target_path": m.target_path, - "files": m.files, - "mode": m.mode, - "order_index": m.order_index, - "created_at": m.created_at.isoformat() if m.created_at else None, - "updated_at": m.updated_at.isoformat() if m.updated_at else None, - } - for m in mounts - ] - } + return await list_mounts_for_profile(session, profile_id) -@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED, summary="Add profile mount") +@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED) async def add_profile_mount( profile_id: uuid.UUID, data: ConfigMountCreate, @@ -423,18 +246,7 @@ async def add_profile_mount( session: AsyncSession = Depends(get_db_session), ) -> dict: profile = await get_owned_profile(profile_id, user_id, session) - existing = await session.scalar( - select(ConfigMount).where( - ConfigMount.profile_id == profile_id, - ConfigMount.target_path == data.target_path, - ) - ) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"mount with path '{data.target_path}' already exists", - ) - + await check_duplicate_mount_path(session, profile_id, data.target_path) mount = ConfigMount( profile_id=profile_id, target_path=data.target_path, @@ -445,20 +257,10 @@ async def add_profile_mount( session.add(mount) await session.commit() await session.refresh(mount) - - return { - "id": str(mount.id), - "profile_id": str(mount.profile_id), - "target_path": mount.target_path, - "files": mount.files, - "mode": mount.mode, - "order_index": mount.order_index, - "created_at": mount.created_at.isoformat() if mount.created_at else None, - "updated_at": mount.updated_at.isoformat() if mount.updated_at else None, - } + return mount_to_dict(mount) -@router.put("/{profile_id}/mounts/{mount_id}", summary="Update profile mount") +@router.put("/{profile_id}/mounts/{mount_id}") async def update_profile_mount( profile_id: uuid.UUID, mount_id: uuid.UUID, @@ -470,43 +272,19 @@ async def update_profile_mount( mount = await session.get(ConfigMount, mount_id) if mount is None or mount.profile_id != profile_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found") - if data.target_path is not None: - existing = await session.scalar( - select(ConfigMount).where( - ConfigMount.profile_id == profile_id, - ConfigMount.target_path == data.target_path, - ConfigMount.id != mount_id, - ) - ) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"mount with path '{data.target_path}' already exists", - ) + await check_duplicate_mount_path(session, profile_id, data.target_path, exclude_id=mount_id) mount.target_path = data.target_path - if data.files is not None: mount.files = data.files if data.order_index is not None: mount.order_index = data.order_index - await session.commit() await session.refresh(mount) - - return { - "id": str(mount.id), - "profile_id": str(mount.profile_id), - "target_path": mount.target_path, - "files": mount.files, - "mode": mount.mode, - "order_index": mount.order_index, - "created_at": mount.created_at.isoformat() if mount.created_at else None, - "updated_at": mount.updated_at.isoformat() if mount.updated_at else None, - } + return mount_to_dict(mount) -@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile mount") +@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_profile_mount( profile_id: uuid.UUID, mount_id: uuid.UUID, diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index d221a01..8b88dd4 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -35,15 +35,10 @@ from src.services.git.repository import create_repository, delete_repository, li from src.utils.git_url_parser import parse_git_url router = APIRouter(prefix="/projects", tags=["git-repositories"]) - logger = logging.getLogger(__name__) -@router.get( - "/{project_id}/repositories", - response_model=list[GitRepositoryResponse], - summary="List repositories", -) +@router.get("/{project_id}/repositories", response_model=list[GitRepositoryResponse]) async def list_repositories_endpoint( project_id: uuid.UUID, user: User = Depends(get_current_user), @@ -53,12 +48,7 @@ async def list_repositories_endpoint( return await list_repositories(session, project_id) -@router.post( - "/{project_id}/repositories", - response_model=GitRepositoryResponse, - status_code=status.HTTP_201_CREATED, - summary="Create a repository", -) +@router.post("/{project_id}/repositories", response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED) async def create_repository_endpoint( project_id: uuid.UUID, data: GitRepositoryCreate, @@ -69,11 +59,7 @@ async def create_repository_endpoint( return await create_repository(session, project_id, data, user) -@router.delete( - "/{project_id}/repositories/{repo_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete a repository", -) +@router.delete("/{project_id}/repositories/{repo_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_repository_endpoint( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -85,23 +71,14 @@ async def delete_repository_endpoint( return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.post( - "/repositories/parse-url", - response_model=URLParseResponse, - summary="Parse a git URL", -) +@router.post("/repositories/parse-url", response_model=URLParseResponse) async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: - result = parse_git_url(data.url) - return URLParseResponse(**result) + return URLParseResponse(**parse_git_url(data.url)) # History - -@router.get( - "/{project_id}/repositories/{repo_id}/history", - summary="Get repository history", -) +@router.get("/{project_id}/repositories/{repo_id}/history") async def get_repository_history( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -115,16 +92,12 @@ async def get_repository_history( ) -> dict: from src.utils.git_history import get_commit_history from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk - repo = await get_repo_and_validate(session, repo_id, project_id) ensure_repo_on_disk(repo) return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) -@router.get( - "/{project_id}/repositories/{repo_id}/commits/{commit_hash}", - summary="Get commit details", -) +@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}") async def get_repository_commit( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -135,7 +108,6 @@ async def get_repository_commit( ) -> dict: from src.utils.git_history import get_commit_detail from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk - repo = await get_repo_and_validate(session, repo_id, project_id) ensure_repo_on_disk(repo) return get_commit_detail(repo.path, commit_hash) @@ -143,12 +115,7 @@ async def get_repository_commit( # File browsing - -@router.get( - "/{project_id}/repositories/{repo_id}/files", - response_model=FileListResponse, - summary="List repository files", -) +@router.get("/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse) async def list_repository_files( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -161,11 +128,7 @@ async def list_repository_files( return await git_files.list_files(session, project_id, repo_id, branch, path) -@router.get( - "/{project_id}/repositories/{repo_id}/files/content", - response_model=FileContentResponse, - summary="Get file content", -) +@router.get("/{project_id}/repositories/{repo_id}/files/content", response_model=FileContentResponse) async def get_repository_file_content( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -178,11 +141,7 @@ async def get_repository_file_content( return await git_files.get_file(session, project_id, repo_id, branch, path) -@router.post( - "/{project_id}/repositories/{repo_id}/files/content", - response_model=FileUpdateResponse, - summary="Update file content", -) +@router.post("/{project_id}/repositories/{repo_id}/files/content", response_model=FileUpdateResponse) async def update_repository_file( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -196,11 +155,7 @@ async def update_repository_file( # Branches - -@router.get( - "/{project_id}/repositories/{repo_id}/branches", - summary="List branches", -) +@router.get("/{project_id}/repositories/{repo_id}/branches") async def get_repository_branches( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -211,10 +166,7 @@ async def get_repository_branches( return await git_files.list_branches_with_validation(session, project_id, repo_id) -@router.post( - "/{project_id}/repositories/{repo_id}/branches", - summary="Create a branch", -) +@router.post("/{project_id}/repositories/{repo_id}/branches") async def create_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -226,10 +178,7 @@ async def create_repository_branch( return await git_control.create_branch_with_validation(session, project_id, repo_id, data) -@router.delete( - "/{project_id}/repositories/{repo_id}/branches/{branch_name}", - summary="Delete a branch", -) +@router.delete("/{project_id}/repositories/{repo_id}/branches/{branch_name}") async def delete_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -242,10 +191,7 @@ async def delete_repository_branch( return await git_control.delete_branch_with_validation(session, project_id, repo_id, branch_name, force) -@router.post( - "/{project_id}/repositories/{repo_id}/checkout", - summary="Checkout a branch", -) +@router.post("/{project_id}/repositories/{repo_id}/checkout") async def checkout_repository_branch( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -259,12 +205,7 @@ async def checkout_repository_branch( # Git control - -@router.get( - "/{project_id}/repositories/{repo_id}/status", - response_model=StatusResponse, - summary="Get repository status", -) +@router.get("/{project_id}/repositories/{repo_id}/status", response_model=StatusResponse) async def get_repository_status( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -275,11 +216,7 @@ async def get_repository_status( return await git_control.get_status_with_validation(session, project_id, repo_id) -@router.post( - "/{project_id}/repositories/{repo_id}/commit", - response_model=CommitResponse, - summary="Commit changes", -) +@router.post("/{project_id}/repositories/{repo_id}/commit", response_model=CommitResponse) async def commit_repository_changes( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -292,11 +229,7 @@ async def commit_repository_changes( return CommitResponse(commit_hash=result["commit_hash"], message=result["message"]) -@router.post( - "/{project_id}/repositories/{repo_id}/fetch", - response_model=FetchResponse, - summary="Fetch from remote", -) +@router.post("/{project_id}/repositories/{repo_id}/fetch", response_model=FetchResponse) async def fetch_repository( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -307,11 +240,7 @@ async def fetch_repository( return await git_control.fetch_with_validation(session, project_id, repo_id) -@router.post( - "/{project_id}/repositories/{repo_id}/pull", - response_model=PullResponse, - summary="Pull from remote", -) +@router.post("/{project_id}/repositories/{repo_id}/pull", response_model=PullResponse) async def pull_repository( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -323,11 +252,7 @@ async def pull_repository( return await git_control.pull_with_validation(session, project_id, repo_id, branch) -@router.post( - "/{project_id}/repositories/{repo_id}/push", - response_model=PushResponse, - summary="Push to remote", -) +@router.post("/{project_id}/repositories/{repo_id}/push", response_model=PushResponse) async def push_repository( project_id: uuid.UUID, repo_id: uuid.UUID, @@ -339,11 +264,7 @@ async def push_repository( return await git_control.push_with_validation(session, project_id, repo_id, branch) -@router.post( - "/{project_id}/repositories/{repo_id}/merge", - response_model=MergeResponse, - summary="Merge branches", -) +@router.post("/{project_id}/repositories/{repo_id}/merge", response_model=MergeResponse) async def merge_repository_branches( project_id: uuid.UUID, repo_id: uuid.UUID, diff --git a/apps/api/src/services/config_profiles.py b/apps/api/src/services/config_profiles.py index 295455b..daba56c 100644 --- a/apps/api/src/services/config_profiles.py +++ b/apps/api/src/services/config_profiles.py @@ -6,6 +6,7 @@ import uuid from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from src.models.config_include import ConfigInclude from src.models.config_mount import ConfigMount @@ -79,3 +80,220 @@ async def validate_includes_no_cycle( status_code=status.HTTP_400_BAD_REQUEST, detail="adding this include would create a circular reference", ) + + +# --------------------------------------------------------------------------- +# Profile CRUD helpers +# --------------------------------------------------------------------------- + +async def check_duplicate_name( + session: AsyncSession, + user_id: uuid.UUID, + name: str, + exclude_id: uuid.UUID | None = None, +) -> None: + """Raise 409 if a profile with the given name already exists.""" + query = select(ConfigProfile).where( + ConfigProfile.user_id == user_id, + ConfigProfile.name == name, + ) + if exclude_id: + query = query.where(ConfigProfile.id != exclude_id) + existing = await session.scalar(query) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"config profile with name '{name}' already exists", + ) + + +def profile_to_dict(profile: ConfigProfile) -> dict: + """Serialize a ConfigProfile to a dict.""" + return { + "id": str(profile.id), + "user_id": str(profile.user_id), + "name": profile.name, + "description": profile.description, + "created_at": profile.created_at.isoformat() if profile.created_at else None, + "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Include helpers +# --------------------------------------------------------------------------- + +async def check_duplicate_include( + session: AsyncSession, + profile_id: uuid.UUID, + included_profile_id: uuid.UUID, +) -> None: + """Raise 409 if the include already exists.""" + existing = await session.scalar( + select(ConfigInclude).where( + ConfigInclude.profile_id == profile_id, + ConfigInclude.included_profile_id == included_profile_id, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="this include already exists", + ) + + +def include_to_dict(inc: ConfigInclude, included_name: str | None) -> dict: + """Serialize a ConfigInclude to a dict.""" + return { + "id": str(inc.id), + "profile_id": str(inc.profile_id), + "included_profile_id": str(inc.included_profile_id), + "included_profile_name": included_name, + "order_index": inc.order_index, + "created_at": inc.created_at.isoformat() if inc.created_at else None, + "updated_at": inc.updated_at.isoformat() if inc.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Mount helpers +# --------------------------------------------------------------------------- + +async def check_duplicate_mount_path( + session: AsyncSession, + profile_id: uuid.UUID, + target_path: str, + exclude_id: uuid.UUID | None = None, +) -> None: + """Raise 409 if a mount with the given path already exists.""" + query = select(ConfigMount).where( + ConfigMount.profile_id == profile_id, + ConfigMount.target_path == target_path, + ) + if exclude_id: + query = query.where(ConfigMount.id != exclude_id) + existing = await session.scalar(query) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"mount with path '{target_path}' already exists", + ) + + +def mount_to_dict(mount: ConfigMount) -> dict: + """Serialize a ConfigMount to a dict.""" + return { + "id": str(mount.id), + "profile_id": str(mount.profile_id), + "target_path": mount.target_path, + "files": mount.files, + "mode": mount.mode, + "order_index": mount.order_index, + "created_at": mount.created_at.isoformat() if mount.created_at else None, + "updated_at": mount.updated_at.isoformat() if mount.updated_at else None, + } + + +# --------------------------------------------------------------------------- +# Default profile helpers +# --------------------------------------------------------------------------- + +async def get_or_create_user_config( + session: AsyncSession, + user_id: uuid.UUID, +) -> UserConfig: + """Get existing user config or create a new one.""" + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) + user_config = result.scalar_one_or_none() + if user_config is None: + user_config = UserConfig(user_id=user_id, config={}) + session.add(user_config) + return user_config + + +async def validate_default_profiles( + session: AsyncSession, + user_id: uuid.UUID, + default_profiles: dict[str, str], +) -> None: + """Validate that all profile IDs in default_profiles belong to the user.""" + for tool_type_id, profile_id_str in default_profiles.items(): + profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str)) + if profile is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"profile {profile_id_str} not found") + if profile.user_id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user") + + +async def get_default_profiles( + session: AsyncSession, + user_id: uuid.UUID, +) -> dict: + """Get default profiles for a user.""" + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) + user_config = result.scalar_one_or_none() + return {"default_profiles": user_config.default_profiles if user_config else {}} + + +async def set_default_profiles( + session: AsyncSession, + user_id: uuid.UUID, + default_profiles: dict[str, str], +) -> dict: + """Set default profiles for a user.""" + user_config = await get_or_create_user_config(session, user_id) + await validate_default_profiles(session, user_id, default_profiles) + user_config.config = {**user_config.config, "default_profiles": default_profiles} + await session.commit() + await session.refresh(user_config) + return {"default_profiles": user_config.default_profiles} + + +async def get_default_profile_for_tool_type( + session: AsyncSession, + user_id: uuid.UUID, + tool_type_id: str, +) -> dict: + """Get default profile for a specific tool type.""" + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) + user_config = result.scalar_one_or_none() + profile_id = user_config.default_profiles.get(tool_type_id) if user_config else None + return {"tool_type_id": tool_type_id, "profile_id": profile_id} + + +# --------------------------------------------------------------------------- +# Include list helper +# --------------------------------------------------------------------------- + +async def list_includes_for_profile( + session: AsyncSession, + profile_id: uuid.UUID, +) -> dict: + """List all includes for a profile.""" + result = await session.execute( + select(ConfigInclude) + .where(ConfigInclude.profile_id == profile_id) + .order_by(ConfigInclude.order_index) + ) + includes_data = [] + for inc in result.scalars().all(): + included_profile = await session.get(ConfigProfile, inc.included_profile_id) + includes_data.append(include_to_dict(inc, included_profile.name if included_profile else None)) + return {"includes": includes_data} + + +# --------------------------------------------------------------------------- +# Mount list helper +# --------------------------------------------------------------------------- + +async def list_mounts_for_profile( + session: AsyncSession, + profile_id: uuid.UUID, +) -> dict: + """List all mounts for a profile.""" + result = await session.execute( + select(ConfigMount) + .where(ConfigMount.profile_id == profile_id) + .order_by(ConfigMount.order_index) + ) + return {"mounts": [mount_to_dict(m) for m in result.scalars().all()]} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index b596412..bb5164c 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -32,7 +32,9 @@ const SessionItem = ({ session }: { session: Session }) => { className={`${styles.navItem} ${styles.sessionItem}`} title={`${displayName} (${session.status})`} > - + {displayName} @@ -97,7 +99,9 @@ export const AppShell = () => { key={item.to} to={item.to} className={({ isActive }) => - isActive ? `${styles.navItem} ${styles.navItemActive}` : styles.navItem + isActive + ? `${styles.navItem} ${styles.navItemActive}` + : styles.navItem } end={item.to === "/"} > diff --git a/apps/web/src/components/commit-panel.tsx b/apps/web/src/components/commit-panel.tsx index 996a681..5712702 100644 --- a/apps/web/src/components/commit-panel.tsx +++ b/apps/web/src/components/commit-panel.tsx @@ -4,100 +4,100 @@ import { commitChanges } from "../api/git_repositories"; import styles from "./features/git/CommitPanel.module.css"; interface CommitPanelProps { - projectId: string; - repoId: string; - modified: string[]; - added: string[]; - deleted: string[]; - untracked: string[]; - onCommit: () => void; + projectId: string; + repoId: string; + modified: string[]; + added: string[]; + deleted: string[]; + untracked: string[]; + onCommit: () => void; } export const CommitPanel = ({ - projectId, - repoId, - modified, - added, - deleted, - untracked, - onCommit, + projectId, + repoId, + modified, + added, + deleted, + untracked, + onCommit, }: CommitPanelProps) => { - const [message, setMessage] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - const allFiles = [...modified, ...added, ...deleted, ...untracked]; - const hasChanges = allFiles.length > 0; + const allFiles = [...modified, ...added, ...deleted, ...untracked]; + const hasChanges = allFiles.length > 0; - const handleCommit = async () => { - if (!message.trim()) { - setError("Please enter a commit message"); - return; - } - setLoading(true); - setError(null); - try { - await commitChanges(projectId, repoId, message); - setMessage(""); - onCommit(); - } catch { - setError("Commit failed. Please try again."); - } finally { - setLoading(false); - } - }; + const handleCommit = async () => { + if (!message.trim()) { + setError("Please enter a commit message"); + return; + } + setLoading(true); + setError(null); + try { + await commitChanges(projectId, repoId, message); + setMessage(""); + onCommit(); + } catch { + setError("Commit failed. Please try again."); + } finally { + setLoading(false); + } + }; - if (!hasChanges) return null; + if (!hasChanges) return null; - return ( -
-

Changes

- -
- {modified.map((file) => ( -
- M - {file} -
- ))} - {added.map((file) => ( -
- A - {file} -
- ))} - {deleted.map((file) => ( -
- D - {file} -
- ))} - {untracked.map((file) => ( -
- ? - {file} -
- ))} -
+ return ( +
+

Changes

-
-