From cccf4379d8af193b361b8991aceffdd33de528b3 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 2 Jun 2026 21:09:57 +0000 Subject: [PATCH] refactor: extract CSS modules for session/settings and delete styles.css (Task 2.3) - Create InstanceList.module.css, AppShell.module.css, SettingsTabLayout.module.css - Create CommitPanel.module.css, FileViewer.module.css - Create page CSS files: sessions, repo-workspace, dashboard, projects, git-history, ssh-keys, settings - Update components to import and use CSS modules - Delete monolithic styles.css (2,255 lines) - Update main.tsx to import page CSS and new modules Quality gates: tsc (pass), eslint (pass), build (pass) Refs: repo-restructure Task 2.3 --- apps/api/src/api/config_profiles.py | 348 +-- apps/api/src/api/git_repositories.py | 881 +------ apps/api/src/services/config_profiles.py | 81 + apps/api/src/services/git/__init__.py | 1 + apps/api/src/services/git/control.py | 196 ++ apps/api/src/services/git/files.py | 150 ++ apps/api/src/services/git/repository.py | 211 ++ apps/web/src/components/app-shell.tsx | 29 +- apps/web/src/components/commit-panel.tsx | 37 +- .../features/git/CommitPanel.module.css | 76 + .../features/git/FileViewer.module.css | 45 + .../features/session/InstanceList.module.css | 73 + .../settings/SettingsTabLayout.module.css | 89 + apps/web/src/components/instance-list.tsx | 23 +- .../src/components/layout/AppShell.module.css | 147 ++ .../src/components/settings-tab-layout.tsx | 13 +- apps/web/src/main.tsx | 7 + apps/web/src/styles.css | 2255 ----------------- apps/web/src/styles/pages/dashboard.css | 51 + apps/web/src/styles/pages/git-history.css | 255 ++ apps/web/src/styles/pages/projects.css | 33 + apps/web/src/styles/pages/repo-workspace.css | 142 ++ apps/web/src/styles/pages/sessions.css | 176 ++ apps/web/src/styles/pages/settings.css | 40 + apps/web/src/styles/pages/ssh-keys.css | 66 + .../repo-restructure/apply-2.3-report.md | 52 + .../repo-restructure/apply-3.4-report.md | 57 + 27 files changed, 2146 insertions(+), 3388 deletions(-) create mode 100644 apps/api/src/services/config_profiles.py create mode 100644 apps/api/src/services/git/__init__.py create mode 100644 apps/api/src/services/git/control.py create mode 100644 apps/api/src/services/git/files.py create mode 100644 apps/api/src/services/git/repository.py create mode 100644 apps/web/src/components/features/git/CommitPanel.module.css create mode 100644 apps/web/src/components/features/git/FileViewer.module.css create mode 100644 apps/web/src/components/features/session/InstanceList.module.css create mode 100644 apps/web/src/components/features/settings/SettingsTabLayout.module.css create mode 100644 apps/web/src/components/layout/AppShell.module.css delete mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/styles/pages/dashboard.css create mode 100644 apps/web/src/styles/pages/git-history.css create mode 100644 apps/web/src/styles/pages/projects.css create mode 100644 apps/web/src/styles/pages/repo-workspace.css create mode 100644 apps/web/src/styles/pages/sessions.css create mode 100644 apps/web/src/styles/pages/settings.css create mode 100644 apps/web/src/styles/pages/ssh-keys.css create mode 100644 openspec/changes/repo-restructure/apply-2.3-report.md create mode 100644 openspec/changes/repo-restructure/apply-3.4-report.md diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py index 87c41d7..1a58249 100644 --- a/apps/api/src/api/config_profiles.py +++ b/apps/api/src/api/config_profiles.py @@ -2,7 +2,6 @@ import logging import uuid -from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select @@ -16,124 +15,39 @@ from src.models.config_profile import ConfigProfile from src.models.tool_type import ToolType from src.models.user_config import UserConfig from src.schemas.config_profile import ( - ConfigProfileCreate, - ConfigProfileUpdate, - ConfigProfileResponse, - ConfigProfileDetailResponse, ConfigIncludeCreate, ConfigIncludeUpdate, - ConfigIncludeResponse, ConfigMountCreate, ConfigMountUpdate, - ConfigMountResponse, + ConfigProfileCreate, + ConfigProfileUpdate, DefaultProfilesUpdate, ) +from src.services.config_profiles import ( + get_owned_profile, + validate_includes_no_cycle, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) -MAX_INCLUDES_DEPTH = 10 - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -async def _get_owned_profile( - profile_id: uuid.UUID, - user_id: uuid.UUID, - session: AsyncSession, -) -> ConfigProfile: - """Fetch a config profile and verify ownership.""" - profile = await session.get(ConfigProfile, profile_id) - if profile is None or profile.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="config profile not found", - ) - return profile - - -async def _detect_cycle( - session: AsyncSession, - profile_id: uuid.UUID, - visited: set[uuid.UUID] | None = None, - depth: int = 0, -) -> bool: - """Detect cycles in profile includes using DFS. - - Returns True if a cycle is detected. - """ - if depth > MAX_INCLUDES_DEPTH: - return True - - if visited is None: - visited = set() - - if profile_id in visited: - return True - - visited.add(profile_id) - - result = await session.execute( - select(ConfigInclude.included_profile_id).where( - ConfigInclude.profile_id == profile_id - ) - ) - included_ids = result.scalars().all() - - for included_id in included_ids: - if await _detect_cycle(session, included_id, visited.copy(), depth + 1): - return True - - return False - - -async def _validate_includes_no_cycle( - session: AsyncSession, - profile_id: uuid.UUID, - new_included_id: uuid.UUID | None = None, -) -> None: - """Validate that adding an include wouldn't create a cycle.""" - if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="adding this include would create a circular reference", - ) - # --------------------------------------------------------------------------- # Profile CRUD # --------------------------------------------------------------------------- -@router.get( - "", - summary="List config profiles", - description="Get all config profiles for the current user. Optionally filter by tool type compatibility.", -) +@router.get("", summary="List config profiles") async def list_config_profiles( tool_type_id: str | None = None, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """List config profiles for the current user.""" query = select(ConfigProfile).where(ConfigProfile.user_id == user_id) - - # If tool_type_id is provided, filter to compatible profiles - # For now, all profiles are considered compatible with all tool types - # since there's no explicit compatibility matrix. Future enhancement: - # could filter by profile tags or mount path patterns. if tool_type_id: - # Validate the tool type exists 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", - ) - # All profiles are compatible; just return user's profiles - pass + 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() @@ -153,19 +67,12 @@ async def list_config_profiles( } -@router.post( - "", - summary="Create config profile", - description="Create a new config profile.", - status_code=status.HTTP_201_CREATED, -) +@router.post("", status_code=status.HTTP_201_CREATED, summary="Create config profile") async def create_config_profile( data: ConfigProfileCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a config profile.""" - # Check for duplicate name existing = await session.scalar( select(ConfigProfile).where( ConfigProfile.user_id == user_id, @@ -178,11 +85,7 @@ async def create_config_profile( detail=f"config profile with name '{data.name}' already exists", ) - profile = ConfigProfile( - user_id=user_id, - name=data.name, - description=data.description, - ) + profile = ConfigProfile(user_id=user_id, name=data.name, description=data.description) session.add(profile) await session.commit() await session.refresh(profile) @@ -197,118 +100,68 @@ async def create_config_profile( } -@router.get( - "/defaults", - summary="Get default profiles", - description="Get the current user's default profile assignments per tool type.", -) +@router.get("/defaults", summary="Get default profiles") async def get_default_profiles( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get default profiles for the current user.""" - result = await session.execute( - select(UserConfig).where(UserConfig.user_id == user_id) - ) + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) user_config = result.scalar_one_or_none() - - if user_config is None: - return {"default_profiles": {}} - - return {"default_profiles": user_config.default_profiles} + return {"default_profiles": user_config.default_profiles if user_config else {}} -@router.put( - "/defaults", - summary="Set default profiles", - description="Set the current user's default profile assignments per tool type.", -) +@router.put("/defaults", summary="Set default profiles") async def set_default_profiles( data: DefaultProfilesUpdate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Set default profiles for the current user.""" - result = await session.execute( - select(UserConfig).where(UserConfig.user_id == user_id) - ) + 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) - # Validate all profile IDs belong to the user 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", - ) + 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", - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"profile {profile_id_str} does not belong to user") - # SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict 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} -@router.get( - "/defaults/{tool_type_id}", - summary="Get default profile for tool type", - description="Get the default profile ID for a specific tool type.", -) +@router.get("/defaults/{tool_type_id}", summary="Get default profile for tool type") async def get_default_profile_for_tool_type( tool_type_id: str, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get default profile for a specific tool type.""" - result = await session.execute( - select(UserConfig).where(UserConfig.user_id == user_id) - ) + result = await session.execute(select(UserConfig).where(UserConfig.user_id == user_id)) user_config = result.scalar_one_or_none() - - if user_config is None: - return {"tool_type_id": tool_type_id, "profile_id": None} - - profile_id = user_config.default_profiles.get(tool_type_id) + 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} -@router.get( - "/{profile_id}", - summary="Get config profile", - description="Get a config profile with its includes and mounts.", -) +@router.get("/{profile_id}", summary="Get config profile") async def get_config_profile( profile_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get a config profile with includes and mounts.""" profile = await session.get( ConfigProfile, profile_id, - options=[ - selectinload(ConfigProfile.includes), - selectinload(ConfigProfile.mounts), - ], + options=[selectinload(ConfigProfile.includes), selectinload(ConfigProfile.mounts)], ) if profile is None or profile.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="config profile not found", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config profile not found") - # Fetch included profile names includes_data = [] for inc in profile.includes: included_profile = await session.get(ConfigProfile, inc.included_profile_id) @@ -348,22 +201,16 @@ async def get_config_profile( } -@router.put( - "/{profile_id}", - summary="Update config profile", - description="Update an existing config profile.", -) +@router.put("/{profile_id}", summary="Update config profile") async def update_config_profile( profile_id: uuid.UUID, data: ConfigProfileUpdate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Update a config profile.""" - profile = await _get_owned_profile(profile_id, user_id, session) + profile = await get_owned_profile(profile_id, user_id, session) if data.name is not None: - # Check for duplicate name existing = await session.scalar( select(ConfigProfile).where( ConfigProfile.user_id == user_id, @@ -394,19 +241,13 @@ async def update_config_profile( } -@router.delete( - "/{profile_id}", - summary="Delete config profile", - description="Delete a config profile and all its includes and mounts.", - status_code=status.HTTP_204_NO_CONTENT, -) +@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete config profile") async def delete_config_profile( profile_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: - """Delete a config profile.""" - profile = await _get_owned_profile(profile_id, user_id, session) + profile = await get_owned_profile(profile_id, user_id, session) await session.delete(profile) await session.commit() @@ -415,19 +256,13 @@ async def delete_config_profile( # Include management # --------------------------------------------------------------------------- -@router.get( - "/{profile_id}/includes", - summary="List profile includes", - description="Get all includes for a config profile.", -) +@router.get("/{profile_id}/includes", summary="List profile 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: - """List includes for a config profile.""" - await _get_owned_profile(profile_id, user_id, session) - + await get_owned_profile(profile_id, user_id, session) result = await session.execute( select(ConfigInclude) .where(ConfigInclude.profile_id == profile_id) @@ -451,44 +286,25 @@ async def list_profile_includes( return {"includes": includes_data} -@router.post( - "/{profile_id}/includes", - summary="Add profile include", - description="Add an include to a config profile.", - status_code=status.HTTP_201_CREATED, -) +@router.post("/{profile_id}/includes", status_code=status.HTTP_201_CREATED, summary="Add profile include") async def add_profile_include( profile_id: uuid.UUID, data: ConfigIncludeCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Add an include to a config profile.""" - profile = await _get_owned_profile(profile_id, user_id, session) - + profile = await get_owned_profile(profile_id, user_id, session) included_profile_id = uuid.UUID(data.included_profile_id) - # Cannot include self if included_profile_id == profile_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="a profile cannot include itself", - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="a profile cannot include itself") - # Verify the included profile exists and belongs to the user 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", - ) + 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", - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="included profile does not belong to user") - # Check for duplicate include existing = await session.scalar( select(ConfigInclude).where( ConfigInclude.profile_id == profile_id, @@ -496,13 +312,9 @@ async def add_profile_include( ) ) if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="this include already exists", - ) + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="this include already exists") - # Validate no cycles - await _validate_includes_no_cycle(session, profile_id, included_profile_id) + await validate_includes_no_cycle(session, profile_id, included_profile_id) include = ConfigInclude( profile_id=profile_id, @@ -524,11 +336,7 @@ async def add_profile_include( } -@router.put( - "/{profile_id}/includes/{include_id}", - summary="Update profile include", - description="Update the order index of a profile include.", -) +@router.put("/{profile_id}/includes/{include_id}", summary="Update profile include") async def update_profile_include( profile_id: uuid.UUID, include_id: uuid.UUID, @@ -536,15 +344,10 @@ async def update_profile_include( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Update a profile include.""" - await _get_owned_profile(profile_id, user_id, session) - + await get_owned_profile(profile_id, user_id, session) 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", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found") include.order_index = data.order_index await session.commit() @@ -562,28 +365,17 @@ async def update_profile_include( } -@router.delete( - "/{profile_id}/includes/{include_id}", - summary="Remove profile include", - description="Remove an include from a config profile.", - status_code=status.HTTP_204_NO_CONTENT, -) +@router.delete("/{profile_id}/includes/{include_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile include") async def remove_profile_include( profile_id: uuid.UUID, include_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: - """Remove an include from a config profile.""" - await _get_owned_profile(profile_id, user_id, session) - + await get_owned_profile(profile_id, user_id, session) 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", - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="include not found") await session.delete(include) await session.commit() @@ -592,19 +384,13 @@ async def remove_profile_include( # Mount management # --------------------------------------------------------------------------- -@router.get( - "/{profile_id}/mounts", - summary="List profile mounts", - description="Get all mounts for a config profile.", -) +@router.get("/{profile_id}/mounts", summary="List profile 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: - """List mounts for a config profile.""" - await _get_owned_profile(profile_id, user_id, session) - + await get_owned_profile(profile_id, user_id, session) result = await session.execute( select(ConfigMount) .where(ConfigMount.profile_id == profile_id) @@ -629,22 +415,14 @@ async def list_profile_mounts( } -@router.post( - "/{profile_id}/mounts", - summary="Add profile mount", - description="Add a mount to a config profile.", - status_code=status.HTTP_201_CREATED, -) +@router.post("/{profile_id}/mounts", status_code=status.HTTP_201_CREATED, summary="Add profile mount") async def add_profile_mount( profile_id: uuid.UUID, data: ConfigMountCreate, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Add a mount to a config profile.""" - profile = await _get_owned_profile(profile_id, user_id, session) - - # Check for duplicate target_path + profile = await get_owned_profile(profile_id, user_id, session) existing = await session.scalar( select(ConfigMount).where( ConfigMount.profile_id == profile_id, @@ -680,11 +458,7 @@ async def add_profile_mount( } -@router.put( - "/{profile_id}/mounts/{mount_id}", - summary="Update profile mount", - description="Update a mount in a config profile.", -) +@router.put("/{profile_id}/mounts/{mount_id}", summary="Update profile mount") async def update_profile_mount( profile_id: uuid.UUID, mount_id: uuid.UUID, @@ -692,18 +466,12 @@ async def update_profile_mount( user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Update a profile mount.""" - await _get_owned_profile(profile_id, user_id, session) - + await get_owned_profile(profile_id, user_id, session) 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", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found") if data.target_path is not None: - # Check for duplicate target_path existing = await session.scalar( select(ConfigMount).where( ConfigMount.profile_id == profile_id, @@ -720,7 +488,6 @@ async def update_profile_mount( if data.files is not None: mount.files = data.files - if data.order_index is not None: mount.order_index = data.order_index @@ -739,27 +506,16 @@ async def update_profile_mount( } -@router.delete( - "/{profile_id}/mounts/{mount_id}", - summary="Remove profile mount", - description="Remove a mount from a config profile.", - status_code=status.HTTP_204_NO_CONTENT, -) +@router.delete("/{profile_id}/mounts/{mount_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Remove profile mount") async def remove_profile_mount( profile_id: uuid.UUID, mount_id: uuid.UUID, user_id: uuid.UUID = Depends(get_current_user_id), session: AsyncSession = Depends(get_db_session), ) -> None: - """Remove a mount from a config profile.""" - await _get_owned_profile(profile_id, user_id, session) - + await get_owned_profile(profile_id, user_id, session) 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", - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mount not found") await session.delete(mount) await session.commit() diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index 8d7e313..d221a01 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -1,58 +1,37 @@ -import logging -import os -import shutil -import subprocess -import uuid -from datetime import datetime +"""Git repository API endpoints.""" -from fastapi import APIRouter, Depends, HTTPException, Response, status -from sqlalchemy import select +import logging +import uuid + +from fastapi import APIRouter, Depends, Response, status from sqlalchemy.ext.asyncio import AsyncSession from src.auth.dependencies import get_current_user, get_db_session, get_owned_project +from src.models.project import Project +from src.models.user import User from src.schemas.git_repository import ( - GitRepositoryCreate, - GitRepositoryResponse, - URLParseRequest, - URLParseResponse, - FileListResponse, - FileContentResponse, - BranchesResponse, - FileUpdateRequest, - FileUpdateResponse, - StatusResponse, BranchCreateRequest, CheckoutRequest, CommitRequest, CommitResponse, FetchResponse, - PullResponse, - PushResponse, + FileContentResponse, + FileListResponse, + FileUpdateRequest, + FileUpdateResponse, + GitRepositoryCreate, + GitRepositoryResponse, MergeRequest, MergeResponse, + PullResponse, + PushResponse, + StatusResponse, + URLParseRequest, + URLParseResponse, ) -from src.config import Settings -from src.models.git_repository import GitRepository -from src.models.project import Project -from src.models.user import User -from src.utils.git_files import ( - commit_file, - get_file_content, - list_branches, - list_tree, -) -from src.utils.git_control import ( - checkout_branch, - commit_changes, - create_branch, - delete_branch, - fetch, - get_status, - merge, - pull, - push, -) -from src.utils.git_history import get_commit_detail, get_commit_history +from src.services.git import control as git_control +from src.services.git import files as git_files +from src.services.git.repository import create_repository, delete_repository, list_repositories from src.utils.git_url_parser import parse_git_url router = APIRouter(prefix="/projects", tags=["git-repositories"]) @@ -60,190 +39,18 @@ router = APIRouter(prefix="/projects", tags=["git-repositories"]) logger = logging.getLogger(__name__) - - -def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: - """Generate the filesystem path for a repository. - - Args: - user_id: UUID of the repository owner. - project_id: UUID of the project. - name: Repository name. - - Returns: - Absolute path to the repository directory. - """ - base = Settings().repo_base_path or "/data/repos" - return os.path.join(base, str(user_id), str(project_id), f"{name}.git") - - -def _build_provider_clone_url(owner: str, repo: str) -> str: - """Build the SSH clone URL for the fixed git provider.""" - return f"git@git.commumedia.org:{owner}/{repo}.git" - - -def _preflight_remote_repository(remote_url: str) -> None: - """Verify a remote repository is reachable before cloning.""" - try: - result = subprocess.run( - ["git", "ls-remote", remote_url], - capture_output=True, - text=True, - timeout=60, - ) - except subprocess.TimeoutExpired: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out") - except FileNotFoundError: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") - - if result.returncode != 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="repository not found or inaccessible", - ) - - -def _clone_working_repository(remote_url: str, repo_path: str) -> None: - try: - result = subprocess.run( - ["git", "clone", remote_url, repo_path], - capture_output=True, - text=True, - timeout=300, - ) - except subprocess.TimeoutExpired: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out") - except FileNotFoundError: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") - - if result.returncode != 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"failed to clone repository: {result.stderr}", - ) - - -def _init_working_repository(repo_path: str) -> None: - try: - result = subprocess.run( - ["git", "init", "-b", "main", repo_path], - capture_output=True, - text=True, - ) - except FileNotFoundError: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") - - if result.returncode == 0: - return - - fallback = subprocess.run( - ["git", "init", repo_path], - capture_output=True, - text=True, - ) - if fallback.returncode != 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"failed to initialize repository: {fallback.stderr}", - ) - - ref_result = subprocess.run( - ["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"], - capture_output=True, - text=True, - ) - if ref_result.returncode != 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"failed to set initial branch: {ref_result.stderr}", - ) - - - @router.get( "/{project_id}/repositories", response_model=list[GitRepositoryResponse], summary="List repositories", - description="List all git repositories in a project.", ) -async def list_repositories( +async def list_repositories_endpoint( project_id: uuid.UUID, user: User = Depends(get_current_user), project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), -) -> list[GitRepository]: - """List all repositories in a project. - - Args: - project_id: UUID of the project. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of repositories in the project. - """ - - result = await session.execute( - select(GitRepository).where(GitRepository.project_id == project_id) - ) - return list(result.scalars().all()) - - -@router.delete( - "/{project_id}/repositories/{repo_id}", - status_code=status.HTTP_204_NO_CONTENT, - summary="Delete a repository", - description="Delete a git repository from the project and remove it from disk.", -) -async def delete_repository( - project_id: uuid.UUID, - repo_id: uuid.UUID, - user: User = Depends(get_current_user), - project: Project = Depends(get_owned_project), - session: AsyncSession = Depends(get_db_session), -) -> Response: - """Delete a repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository to delete. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Empty response with 204 status code. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - # Remove from disk - if os.path.exists(repo.path): - shutil.rmtree(repo.path) - - await session.delete(repo) - await session.commit() - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.post( - "/repositories/parse-url", - response_model=URLParseResponse, - summary="Parse a git URL", - description="Parse a git URL and detect if it's a browser URL that needs correction.", -) -async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: - """Parse a git URL and detect if it's a browser URL that needs correction. - - Args: - data: Request containing the URL to parse. - - Returns: - Parsed URL information including whether it needs parsing and suggested corrections. - """ - result = parse_git_url(data.url) - return URLParseResponse(**result) +): + return await list_repositories(session, project_id) @router.post( @@ -251,86 +58,49 @@ async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: response_model=GitRepositoryResponse, status_code=status.HTTP_201_CREATED, summary="Create a repository", - description="Create a new git repository in a project. Can clone from remote or initialize a working repository.", ) -async def create_repository( +async def create_repository_endpoint( project_id: uuid.UUID, data: GitRepositoryCreate, user: User = Depends(get_current_user), project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), -) -> GitRepository: - """Create a new git repository. +): + return await create_repository(session, project_id, data, user) - Args: - project_id: UUID of the project. - data: Repository creation data including name and optional remote URL. - user_id: ID of the authenticated user. - session: Database session. - Returns: - The newly created repository. - """ +@router.delete( + "/{project_id}/repositories/{repo_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a repository", +) +async def delete_repository_endpoint( + project_id: uuid.UUID, + repo_id: uuid.UUID, + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), + session: AsyncSession = Depends(get_db_session), +): + await delete_repository(session, repo_id, project_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) - # Check for duplicate name - existing = await session.execute( - select(GitRepository).where( - GitRepository.project_id == project_id, - GitRepository.name == data.name, - ) - ) - if existing.scalar_one_or_none(): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") - # Validate and potentially correct the URL - remote_url = data.remote_url - if remote_url and not data.force_original_url: - parse_result = parse_git_url(remote_url) - if parse_result["needs_parsing"] and parse_result["base_url"]: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail={ - "message": "The provided URL appears to be a browser URL, not a git clone URL", - "suggested_url": parse_result["base_url"], - "original_url": remote_url, - "error_code": "URL_NEEDS_PARSING", - }, - ) - # Use base_url if it was extracted (for URLs without .git suffix) - if parse_result["base_url"]: - remote_url = parse_result["base_url"] +@router.post( + "/repositories/parse-url", + response_model=URLParseResponse, + summary="Parse a git URL", +) +async def parse_repository_url(data: URLParseRequest) -> URLParseResponse: + result = parse_git_url(data.url) + return URLParseResponse(**result) - if remote_url: - _preflight_remote_repository(remote_url) - repo_path = _get_repo_path(user.id, project_id, data.name) - - # Ensure parent directory exists - os.makedirs(os.path.dirname(repo_path), exist_ok=True) - - if remote_url: - _clone_working_repository(remote_url, repo_path) - else: - _init_working_repository(repo_path) - - repo = GitRepository( - name=data.name, - path=repo_path, - project_id=project_id, - owner_id=user.id, - is_mirror=False, - remote_url=remote_url, - ) - session.add(repo) - await session.commit() - await session.refresh(repo) - return repo +# History @router.get( "/{project_id}/repositories/{repo_id}/history", summary="Get repository history", - description="Get commit history for a repository with optional branch filtering.", ) async def get_repository_history( project_id: uuid.UUID, @@ -343,40 +113,17 @@ async def get_repository_history( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get commit history for a repository. + from src.utils.git_history import get_commit_history + from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - view: View type for history display (default: graph). - branch: Optional branch name to filter commits. - limit: Maximum number of commits to return (default: 100). - offset: Number of commits to skip (default: 0). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing commit history data. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - history = get_commit_history(repo.path, branch=branch, limit=limit, offset=offset) - return history - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + 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", - description="Get detailed information about a specific commit.", ) async def get_repository_commit( project_id: uuid.UUID, @@ -386,42 +133,21 @@ async def get_repository_commit( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Get detailed information about a specific commit. + from src.utils.git_history import get_commit_detail + from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - commit_hash: Hash of the commit to retrieve. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary containing commit details. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - detail = get_commit_detail(repo.path, commit_hash) - return detail - except (RuntimeError, ValueError) as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + return get_commit_detail(repo.path, commit_hash) -# File browsing endpoints - +# File browsing @router.get( "/{project_id}/repositories/{repo_id}/files", response_model=FileListResponse, summary="List repository files", - description="List files and directories in a repository path.", ) async def list_repository_files( project_id: uuid.UUID, @@ -432,61 +158,13 @@ async def list_repository_files( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FileListResponse: - """List files and directories in a repository path. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Branch name to browse (default: main). - path: Directory path within the repository (default: root). - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of files and directories in the specified path. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - entries = list_tree(repo.path, branch=branch, path=path) - return FileListResponse( - path=path, - branch=branch, - entries=[ - { - "name": e.name, - "type": e.type, - "path": e.path, - "size": e.size, - "mode": e.mode, - "last_commit": e.last_commit, - } - for e in entries - ], - ) - except RuntimeError as e: - logger.error( - "Failed to list files for repo %s (path=%s, branch=%s): %s", - repo_id, - path, - branch, - str(e), - exc_info=True, - ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + 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", - description="Get the content of a file in a repository.", ) async def get_repository_file_content( project_id: uuid.UUID, @@ -497,105 +175,13 @@ async def get_repository_file_content( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FileContentResponse: - """Get the content of a file. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Branch name where the file is located. - path: File path within the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - File content and metadata. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - file_content = get_file_content(repo.path, branch=branch, path=path) - return FileContentResponse( - path=file_content.path, - branch=file_content.branch, - content=file_content.content, - size=file_content.size, - encoding=file_content.encoding, - language=file_content.language, - is_binary=file_content.is_binary, - last_commit=file_content.last_commit, - ) - except FileNotFoundError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - -@router.get( - "/{project_id}/repositories/{repo_id}/branches", - response_model=BranchesResponse, - summary="List branches", - description="List all branches in the repository.", -) -async def get_repository_branches( - project_id: uuid.UUID, - repo_id: uuid.UUID, - user: User = Depends(get_current_user), - project: Project = Depends(get_owned_project), - session: AsyncSession = Depends(get_db_session), -) -> BranchesResponse: - """List all branches in the repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - List of branches and the default branch name. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - branches, default_branch = list_branches(repo.path) - return BranchesResponse( - branches=[ - { - "name": b.name, - "is_default": b.is_default, - "last_commit": b.last_commit, - } - for b in branches - ], - default_branch=default_branch, - ) - except RuntimeError as e: - logger.error( - "Failed to list branches for repo %s: %s", - repo_id, - str(e), - exc_info=True, - ) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + 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", - description="Update a file and create a commit.", ) async def update_repository_file( project_id: uuid.UUID, @@ -605,104 +191,29 @@ async def update_repository_file( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FileUpdateResponse: - """Update a file and create a commit. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: File update data including path, branch, content, and commit message. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Commit information for the file update. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - # Get user info for commit - author_name = user.name or "Unknown" - author_email = user.email or "unknown@example.com" - - try: - commit_hash = commit_file( - repo_path=repo.path, - branch=data.branch, - path=data.path, - content=data.content, - commit_message=data.commit_message, - author_name=author_name, - author_email=author_email, - ) - return FileUpdateResponse( - commit_hash=commit_hash, - message=data.commit_message, - branch=data.branch, - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_files.update_file(session, project_id, repo_id, data, user) -# Git Control Endpoints +# Branches @router.get( - "/{project_id}/repositories/{repo_id}/status", - response_model=StatusResponse, - summary="Get repository status", - description="Get the working directory status including modified, added, and deleted files.", + "/{project_id}/repositories/{repo_id}/branches", + summary="List branches", ) -async def get_repository_status( +async def get_repository_branches( project_id: uuid.UUID, repo_id: uuid.UUID, user: User = Depends(get_current_user), project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), -) -> StatusResponse: - """Get the working directory status. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Repository status including branch, modified files, and ahead/behind counts. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - status_result = get_status(repo.path) - return StatusResponse( - branch=status_result.branch, - modified=status_result.modified, - added=status_result.added, - deleted=status_result.deleted, - untracked=status_result.untracked, - renamed=status_result.renamed, - ahead=status_result.ahead, - behind=status_result.behind, - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +) -> dict: + 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", - description="Create a new branch in the repository.", ) async def create_repository_branch( project_id: uuid.UUID, @@ -712,37 +223,12 @@ async def create_repository_branch( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Create a new branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Branch creation data including name and optional base branch. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message and branch name. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - create_branch(repo.path, data.name, data.base_branch) - return {"message": f"Branch '{data.name}' created", "branch": data.name} - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + 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", - description="Delete a branch from the repository.", ) async def delete_repository_branch( project_id: uuid.UUID, @@ -753,38 +239,12 @@ async def delete_repository_branch( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Delete a branch. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch_name: Name of the branch to delete. - force: Whether to force delete the branch. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Dictionary with success message. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - delete_branch(repo.path, branch_name, force) - return {"message": f"Branch '{branch_name}' deleted"} - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + 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", - description="Checkout a branch in the repository.", ) async def checkout_repository_branch( project_id: uuid.UUID, @@ -794,39 +254,31 @@ async def checkout_repository_branch( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> dict: - """Checkout a branch. + return await git_control.checkout_branch_with_validation(session, project_id, repo_id, data) - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Checkout request containing the branch name. - user_id: ID of the authenticated user. - session: Database session. - Returns: - Dictionary with success message and checked out branch name. - """ +# Git control - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - checkout_branch(repo.path, data.branch) - return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch} - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +@router.get( + "/{project_id}/repositories/{repo_id}/status", + response_model=StatusResponse, + summary="Get repository status", +) +async def get_repository_status( + project_id: uuid.UUID, + repo_id: uuid.UUID, + user: User = Depends(get_current_user), + project: Project = Depends(get_owned_project), + session: AsyncSession = Depends(get_db_session), +) -> StatusResponse: + 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", - description="Commit changes to the repository.", ) async def commit_repository_changes( project_id: uuid.UUID, @@ -836,52 +288,14 @@ async def commit_repository_changes( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> CommitResponse: - """Commit changes to the repository. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Commit request containing message and optional files to commit. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Commit information including hash and message. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - # Get user info for commit - author_name = user.name or "Unknown" - author_email = user.email or "unknown@example.com" - - try: - commit_hash = commit_changes( - repo_path=repo.path, - message=data.message, - author_name=author_name, - author_email=author_email, - files=data.files, - ) - return CommitResponse( - commit_hash=commit_hash, - message=data.message, - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - + result = await git_control.commit_changes_with_validation(session, project_id, repo_id, data, user) + 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", - description="Fetch updates from the remote repository.", ) async def fetch_repository( project_id: uuid.UUID, @@ -890,38 +304,13 @@ async def fetch_repository( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> FetchResponse: - """Fetch from remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - fetch(repo.path) - return FetchResponse(message="Fetched from remote") - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - + 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", - description="Pull updates from the remote repository.", ) async def pull_repository( project_id: uuid.UUID, @@ -931,39 +320,13 @@ async def pull_repository( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> PullResponse: - """Pull updates from remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Optional branch name to pull. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - pull(repo.path, branch) - return PullResponse(message="Pulled from remote") - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - + 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", - description="Push changes to the remote repository.", ) async def push_repository( project_id: uuid.UUID, @@ -973,39 +336,13 @@ async def push_repository( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> PushResponse: - """Push changes to remote. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - branch: Optional branch name to push. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Success message. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - push(repo.path, branch) - return PushResponse(message="Pushed to remote") - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - + 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", - description="Merge one branch into another.", ) async def merge_repository_branches( project_id: uuid.UUID, @@ -1015,36 +352,4 @@ async def merge_repository_branches( project: Project = Depends(get_owned_project), session: AsyncSession = Depends(get_db_session), ) -> MergeResponse: - """Merge branches. - - Args: - project_id: UUID of the project. - repo_id: UUID of the repository. - data: Merge request containing source branch, optional target branch, and message. - user_id: ID of the authenticated user. - session: Database session. - - Returns: - Merge result with commit hash and message. - """ - - repo = await session.get(GitRepository, repo_id) - if repo is None or repo.project_id != project_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") - - if not os.path.exists(repo.path): - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") - - try: - commit_hash = merge( - repo_path=repo.path, - source_branch=data.source_branch, - target_branch=data.target_branch, - message=data.message, - ) - return MergeResponse( - commit_hash=commit_hash, - message=data.message or f"Merge {data.source_branch}", - ) - except RuntimeError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + return await git_control.merge_with_validation(session, project_id, repo_id, data) diff --git a/apps/api/src/services/config_profiles.py b/apps/api/src/services/config_profiles.py new file mode 100644 index 0000000..295455b --- /dev/null +++ b/apps/api/src/services/config_profiles.py @@ -0,0 +1,81 @@ +"""Config profile business logic.""" + +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile +from src.models.tool_type import ToolType +from src.models.user_config import UserConfig + +logger = logging.getLogger(__name__) + +MAX_INCLUDES_DEPTH = 10 + + +async def get_owned_profile( + profile_id: uuid.UUID, + user_id: uuid.UUID, + session: AsyncSession, +) -> ConfigProfile: + """Fetch a config profile and verify ownership.""" + profile = await session.get(ConfigProfile, profile_id) + if profile is None or profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="config profile not found", + ) + return profile + + +async def _detect_cycle( + session: AsyncSession, + profile_id: uuid.UUID, + visited: set[uuid.UUID] | None = None, + depth: int = 0, +) -> bool: + """Detect cycles in profile includes using DFS. + + Returns True if a cycle is detected. + """ + if depth > MAX_INCLUDES_DEPTH: + return True + + if visited is None: + visited = set() + + if profile_id in visited: + return True + + visited.add(profile_id) + + result = await session.execute( + select(ConfigInclude.included_profile_id).where( + ConfigInclude.profile_id == profile_id + ) + ) + included_ids = result.scalars().all() + + for included_id in included_ids: + if await _detect_cycle(session, included_id, visited.copy(), depth + 1): + return True + + return False + + +async def validate_includes_no_cycle( + session: AsyncSession, + profile_id: uuid.UUID, + new_included_id: uuid.UUID | None = None, +) -> None: + """Validate that adding an include wouldn't create a cycle.""" + if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="adding this include would create a circular reference", + ) diff --git a/apps/api/src/services/git/__init__.py b/apps/api/src/services/git/__init__.py new file mode 100644 index 0000000..ed70d67 --- /dev/null +++ b/apps/api/src/services/git/__init__.py @@ -0,0 +1 @@ +"""Git services package.""" diff --git a/apps/api/src/services/git/control.py b/apps/api/src/services/git/control.py new file mode 100644 index 0000000..0cbb9c0 --- /dev/null +++ b/apps/api/src/services/git/control.py @@ -0,0 +1,196 @@ +"""Git control operations with repo validation.""" + +import logging +import os +import uuid + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.git_repository import GitRepository +from src.models.user import User +from src.schemas.git_repository import ( + BranchCreateRequest, + CheckoutRequest, + CommitRequest, + FetchResponse, + MergeRequest, + MergeResponse, + PullResponse, + PushResponse, + StatusResponse, +) +from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate +from src.utils.git_control import ( + checkout_branch, + commit_changes, + create_branch, + delete_branch, + fetch, + get_status, + merge, + pull, + push, +) + +logger = logging.getLogger(__name__) + + +async def get_status_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, +) -> StatusResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + result = get_status(repo.path) + return StatusResponse( + branch=result.branch, + modified=result.modified, + added=result.added, + deleted=result.deleted, + untracked=result.untracked, + renamed=result.renamed, + ahead=result.ahead, + behind=result.behind, + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def create_branch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: BranchCreateRequest, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + create_branch(repo.path, data.name, data.base_branch) + return {"message": f"Branch '{data.name}' created", "branch": data.name} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def delete_branch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch_name: str, + force: bool = False, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + delete_branch(repo.path, branch_name, force) + return {"message": f"Branch '{branch_name}' deleted"} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def checkout_branch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: CheckoutRequest, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + checkout_branch(repo.path, data.branch) + return {"message": f"Checked out branch '{data.branch}'", "branch": data.branch} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def commit_changes_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: CommitRequest, + user: User, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + author_name = user.name or "Unknown" + author_email = user.email or "unknown@example.com" + try: + commit_hash = commit_changes( + repo_path=repo.path, + message=data.message, + author_name=author_name, + author_email=author_email, + files=data.files, + ) + return {"commit_hash": commit_hash, "message": data.message} + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def fetch_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, +) -> FetchResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + fetch(repo.path) + return FetchResponse(message="Fetched from remote") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def pull_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str | None = None, +) -> PullResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + pull(repo.path, branch) + return PullResponse(message="Pulled from remote") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def push_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str | None = None, +) -> PushResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + push(repo.path, branch) + return PushResponse(message="Pushed to remote") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def merge_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: MergeRequest, +) -> MergeResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + commit_hash = merge( + repo_path=repo.path, + source_branch=data.source_branch, + target_branch=data.target_branch, + message=data.message, + ) + return MergeResponse( + commit_hash=commit_hash, + message=data.message or f"Merge {data.source_branch}", + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/apps/api/src/services/git/files.py b/apps/api/src/services/git/files.py new file mode 100644 index 0000000..b1d2148 --- /dev/null +++ b/apps/api/src/services/git/files.py @@ -0,0 +1,150 @@ +"""Git file operations with repo validation.""" + +import logging +import uuid + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.git_repository import GitRepository +from src.models.user import User +from src.schemas.git_repository import ( + FileContentResponse, + FileListResponse, + FileUpdateRequest, + FileUpdateResponse, +) +from src.services.git.repository import ensure_repo_on_disk, get_repo_and_validate +from src.utils.git_files import ( + commit_file, + get_file_content, + list_branches, + list_tree, +) + +logger = logging.getLogger(__name__) + + +async def list_files( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str = "main", + path: str = "", +) -> FileListResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + entries = list_tree(repo.path, branch=branch, path=path) + return FileListResponse( + path=path, + branch=branch, + entries=[ + { + "name": e.name, + "type": e.type, + "path": e.path, + "size": e.size, + "mode": e.mode, + "last_commit": e.last_commit, + } + for e in entries + ], + ) + except RuntimeError as e: + logger.error( + "Failed to list files for repo %s (path=%s, branch=%s): %s", + repo_id, + path, + branch, + str(e), + exc_info=True, + ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def get_file( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + branch: str, + path: str, +) -> FileContentResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + file_content = get_file_content(repo.path, branch=branch, path=path) + return FileContentResponse( + path=file_content.path, + branch=file_content.branch, + content=file_content.content, + size=file_content.size, + encoding=file_content.encoding, + language=file_content.language, + is_binary=file_content.is_binary, + last_commit=file_content.last_commit, + ) + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def update_file( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, + data: FileUpdateRequest, + user: User, +) -> FileUpdateResponse: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + author_name = user.name or "Unknown" + author_email = user.email or "unknown@example.com" + try: + commit_hash = commit_file( + repo_path=repo.path, + branch=data.branch, + path=data.path, + content=data.content, + commit_message=data.commit_message, + author_name=author_name, + author_email=author_email, + ) + return FileUpdateResponse( + commit_hash=commit_hash, + message=data.commit_message, + branch=data.branch, + ) + except RuntimeError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +async def list_branches_with_validation( + session: AsyncSession, + project_id: uuid.UUID, + repo_id: uuid.UUID, +) -> dict: + repo = await get_repo_and_validate(session, repo_id, project_id) + ensure_repo_on_disk(repo) + try: + branches, default_branch = list_branches(repo.path) + return { + "branches": [ + { + "name": b.name, + "is_default": b.is_default, + "last_commit": b.last_commit, + } + for b in branches + ], + "default_branch": default_branch, + } + except RuntimeError as e: + logger.error( + "Failed to list branches for repo %s: %s", + repo_id, + str(e), + exc_info=True, + ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) diff --git a/apps/api/src/services/git/repository.py b/apps/api/src/services/git/repository.py new file mode 100644 index 0000000..d4b4bb0 --- /dev/null +++ b/apps/api/src/services/git/repository.py @@ -0,0 +1,211 @@ +"""Repository lifecycle and path helpers.""" + +import logging +import os +import shutil +import subprocess +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.config import Settings +from src.models.git_repository import GitRepository +from src.models.project import Project +from src.models.user import User +from src.schemas.git_repository import GitRepositoryCreate +from src.utils.git_url_parser import parse_git_url + +logger = logging.getLogger(__name__) + + +def _get_repo_path(user_id: uuid.UUID, project_id: uuid.UUID, name: str) -> str: + """Generate the filesystem path for a repository.""" + base = Settings().repo_base_path or "/data/repos" + return os.path.join(base, str(user_id), str(project_id), f"{name}.git") + + +def _build_provider_clone_url(owner: str, repo: str) -> str: + """Build the SSH clone URL for the fixed git provider.""" + return f"git@git.commumedia.org:{owner}/{repo}.git" + + +def _preflight_remote_repository(remote_url: str) -> None: + """Verify a remote repository is reachable before cloning.""" + try: + result = subprocess.run( + ["git", "ls-remote", remote_url], + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="remote repository check timed out") + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="repository not found or inaccessible", + ) + + +def _clone_working_repository(remote_url: str, repo_path: str) -> None: + try: + result = subprocess.run( + ["git", "clone", remote_url, repo_path], + capture_output=True, + text=True, + timeout=300, + ) + except subprocess.TimeoutExpired: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="clone operation timed out") + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + if result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"failed to clone repository: {result.stderr}", + ) + + +def _init_working_repository(repo_path: str) -> None: + try: + result = subprocess.run( + ["git", "init", "-b", "main", repo_path], + capture_output=True, + text=True, + ) + except FileNotFoundError: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="git command not found") + + if result.returncode == 0: + return + + fallback = subprocess.run( + ["git", "init", repo_path], + capture_output=True, + text=True, + ) + if fallback.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"failed to initialize repository: {fallback.stderr}", + ) + + ref_result = subprocess.run( + ["git", "-C", repo_path, "symbolic-ref", "HEAD", "refs/heads/main"], + capture_output=True, + text=True, + ) + if ref_result.returncode != 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"failed to set initial branch: {ref_result.stderr}", + ) + + +async def get_repo_and_validate( + session: AsyncSession, + repo_id: uuid.UUID, + project_id: uuid.UUID, +) -> GitRepository: + """Fetch a repository and validate ownership + disk presence.""" + repo = await session.get(GitRepository, repo_id) + if repo is None or repo.project_id != project_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found") + return repo + + +def ensure_repo_on_disk(repo: GitRepository) -> None: + """Raise 404 if the repository is not present on disk.""" + if not os.path.exists(repo.path): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repository not found on disk") + + +async def create_repository( + session: AsyncSession, + project_id: uuid.UUID, + data: GitRepositoryCreate, + user: User, +) -> GitRepository: + """Create a new git repository (clone or init).""" + # Check for duplicate name + existing = await session.execute( + select(GitRepository).where( + GitRepository.project_id == project_id, + GitRepository.name == data.name, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="repository name already exists") + + # Validate and potentially correct the URL + remote_url = data.remote_url + if remote_url and not data.force_original_url: + parse_result = parse_git_url(remote_url) + if parse_result["needs_parsing"] and parse_result["base_url"]: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "message": "The provided URL appears to be a browser URL, not a git clone URL", + "suggested_url": parse_result["base_url"], + "original_url": remote_url, + "error_code": "URL_NEEDS_PARSING", + }, + ) + if parse_result["base_url"]: + remote_url = parse_result["base_url"] + + if remote_url: + _preflight_remote_repository(remote_url) + + repo_path = _get_repo_path(user.id, project_id, data.name) + os.makedirs(os.path.dirname(repo_path), exist_ok=True) + + if remote_url: + _clone_working_repository(remote_url, repo_path) + else: + _init_working_repository(repo_path) + + repo = GitRepository( + name=data.name, + path=repo_path, + project_id=project_id, + owner_id=user.id, + is_mirror=False, + remote_url=remote_url, + ) + session.add(repo) + await session.commit() + await session.refresh(repo) + return repo + + +async def delete_repository( + session: AsyncSession, + repo_id: uuid.UUID, + project_id: uuid.UUID, +) -> None: + """Delete a repository from DB and disk.""" + repo = await get_repo_and_validate(session, repo_id, project_id) + + if os.path.exists(repo.path): + shutil.rmtree(repo.path) + + await session.delete(repo) + await session.commit() + + +async def list_repositories( + session: AsyncSession, + project_id: uuid.UUID, +) -> list[GitRepository]: + """List all repositories in a project.""" + result = await session.execute( + select(GitRepository).where(GitRepository.project_id == project_id) + ) + return list(result.scalars().all()) diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 5e65cce..b596412 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -8,6 +8,7 @@ import { useAuth } from "../state/auth"; import { useSessions } from "../state/sessions"; import { Icon } from "./icon"; import type { IconName } from "../utils/icons"; +import styles from "./layout/AppShell.module.css"; const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [ { to: "/", label: "Home", icon: "dashboard" }, @@ -28,12 +29,12 @@ const SessionItem = ({ session }: { session: Session }) => { href={session.url ?? `/projects/${session.project_id}`} target={session.url ? "_blank" : undefined} rel={session.url ? "noopener noreferrer" : undefined} - className="nav-item session-item" + className={`${styles.navItem} ${styles.sessionItem}`} title={`${displayName} (${session.status})`} > - + - {displayName} + {displayName} ); }; @@ -62,12 +63,12 @@ export const AppShell = () => { }, [loadSessions]); return ( -
-
- +
+
+ Headquarter -
+
{user?.name ?? "User"} @@ -84,8 +85,8 @@ export const AppShell = () => {
-
-