feat: complete Git Repo Manager MVP implementation

Backend (Phase 1-6):
- Pydantic schemas for request/response validation
- Service layer (SSH Key, Server, Repo, Sync)
- API routes with authentication
- FastAPI main application with lifespan management
- ORM models (SshKey, Server, Repo, SyncLog)

Frontend (Phase 7):
- Vue 3 + Element Plus + Pinia + Vue Router
- API client with Axios and interceptors
- State management stores
- All page components (Dashboard, Servers, Repos, SyncLogs, SshKeys, Settings)

Deployment (Phase 8):
- README with quick start guide
- Startup script (start.sh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
panw
2026-03-30 16:30:13 +08:00
parent 960056c88c
commit 44921c5646
46 changed files with 6533 additions and 2 deletions

View File

@@ -0,0 +1,17 @@
"""
API routes module.
This package contains all FastAPI route handlers.
"""
from app.api.deps import get_db_session, require_auth
from app.api.ssh_keys import router as ssh_keys_router
from app.api.servers import router as servers_router
from app.api.status import router as status_router
__all__ = [
"get_db_session",
"require_auth",
"ssh_keys_router",
"servers_router",
"status_router",
]

111
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,111 @@
"""
FastAPI dependencies for API routes.
Provides reusable dependencies for:
- Database session management
- Authentication/authorization
"""
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_session_factory
from app.security import verify_api_token
# HTTP Bearer token security scheme
security = HTTPBearer(auto_error=False)
def get_db_session() -> Generator[Session, None, None]:
"""
Dependency to get a database session.
Yields:
SQLAlchemy database session
Example:
@app.get("/items")
def read_items(db: Session = Depends(get_db_session)):
items = db.query(Item).all()
return items
"""
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database not initialized"
)
session = session_factory()
try:
yield session
finally:
session.close()
def require_auth(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> None:
"""
Dependency to require authentication for protected endpoints.
Args:
credentials: HTTP Bearer token credentials
Raises:
HTTPException: If authentication fails (401 Unauthorized)
Example:
@app.get("/protected")
def protected_route(auth: None = Depends(require_auth)):
return {"message": "authenticated"}
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
authorization = f"Bearer {credentials.credentials}"
if not verify_api_token(authorization):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
# Return None to indicate successful authentication
return None
async def require_auth_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
) -> bool:
"""
Optional authentication dependency.
Returns True if authenticated, False otherwise.
This is useful for endpoints that have different behavior
based on authentication status but don't require it.
Args:
credentials: HTTP Bearer token credentials
Returns:
bool: True if authenticated, False otherwise
Example:
@app.get("/public")
def public_route(authenticated: bool = Depends(require_auth_optional)):
if authenticated:
return {"message": "authenticated user"}
return {"message": "anonymous user"}
"""
if credentials is None:
return False
authorization = f"Bearer {credentials.credentials}"
return verify_api_token(authorization)

292
backend/app/api/servers.py Normal file
View File

@@ -0,0 +1,292 @@
"""
Servers API routes.
Provides CRUD endpoints for Gitea server management:
- POST /api/servers - Create a new server
- GET /api/servers - List all servers
- GET /api/servers/{id} - Get a specific server
- PUT /api/servers/{id} - Update a server
- DELETE /api/servers/{id} - Delete a server
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db_session, require_auth
from app.schemas.server import ServerCreate, ServerUpdate, ServerResponse
from app.schemas.common import SuccessResponse
from app.services.server_service import ServerService
router = APIRouter(prefix="/api/servers", tags=["Servers"])
@router.post("", response_model=SuccessResponse[ServerResponse], status_code=status.HTTP_201_CREATED)
def create_server(
server_data: ServerCreate,
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
Create a new Gitea server.
The API token will be encrypted before storage.
The name must be unique across all servers.
A local storage path will be automatically generated based on the server name.
Args:
server_data: Server creation data
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse containing the created server
Raises:
HTTPException 400: If validation fails or name already exists
HTTPException 401: If authentication fails
"""
service = ServerService(db)
try:
server = service.create_server(
name=server_data.name,
url=server_data.url,
api_token=server_data.api_token,
ssh_key_id=server_data.ssh_key_id,
sync_enabled=server_data.sync_enabled,
schedule_cron=server_data.schedule_cron
)
return SuccessResponse(
code=0,
data=ServerResponse(
id=server.id,
name=server.name,
url=server.url,
ssh_key_id=server.ssh_key_id,
sync_enabled=server.sync_enabled,
schedule_cron=server.schedule_cron,
local_path=server.local_path,
status=server.status,
created_at=server.created_at,
updated_at=server.updated_at
),
message="Server created successfully"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("", response_model=SuccessResponse[List[ServerResponse]])
def list_servers(
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
List all servers.
Returns all servers ordered by creation time.
API tokens are not included in the response.
Args:
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse containing list of servers
Raises:
HTTPException 401: If authentication fails
"""
service = ServerService(db)
servers = service.list_servers()
return SuccessResponse(
code=0,
data=[
ServerResponse(
id=server.id,
name=server.name,
url=server.url,
ssh_key_id=server.ssh_key_id,
sync_enabled=server.sync_enabled,
schedule_cron=server.schedule_cron,
local_path=server.local_path,
status=server.status,
created_at=server.created_at,
updated_at=server.updated_at
)
for server in servers
],
message=f"Retrieved {len(servers)} server(s)"
)
@router.get("/{server_id}", response_model=SuccessResponse[ServerResponse])
def get_server(
server_id: int,
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
Get a specific server by ID.
Args:
server_id: ID of the server
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse containing the server
Raises:
HTTPException 401: If authentication fails
HTTPException 404: If server not found
"""
service = ServerService(db)
server = service.get_server(server_id)
if server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Server with ID {server_id} not found"
)
return SuccessResponse(
code=0,
data=ServerResponse(
id=server.id,
name=server.name,
url=server.url,
ssh_key_id=server.ssh_key_id,
sync_enabled=server.sync_enabled,
schedule_cron=server.schedule_cron,
local_path=server.local_path,
status=server.status,
created_at=server.created_at,
updated_at=server.updated_at
),
message="Server retrieved successfully"
)
@router.put("/{server_id}", response_model=SuccessResponse[ServerResponse])
def update_server(
server_id: int,
server_data: ServerUpdate,
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
Update a server.
Only the fields provided in the request body will be updated.
If api_token is provided, it will be encrypted before storage.
If name is changed, the local_path will be updated accordingly.
Args:
server_id: ID of the server to update
server_data: Server update data (all fields optional)
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse containing the updated server
Raises:
HTTPException 400: If validation fails
HTTPException 401: If authentication fails
HTTPException 404: If server not found
"""
service = ServerService(db)
# Build update dict from non-None fields
update_data = {}
if server_data.name is not None:
update_data['name'] = server_data.name
if server_data.url is not None:
update_data['url'] = server_data.url
if server_data.api_token is not None:
update_data['api_token'] = server_data.api_token
if server_data.ssh_key_id is not None:
update_data['ssh_key_id'] = server_data.ssh_key_id
if server_data.sync_enabled is not None:
update_data['sync_enabled'] = server_data.sync_enabled
if server_data.schedule_cron is not None:
update_data['schedule_cron'] = server_data.schedule_cron
if server_data.status is not None:
update_data['status'] = server_data.status
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No fields provided for update"
)
try:
server = service.update_server(server_id, **update_data)
return SuccessResponse(
code=0,
data=ServerResponse(
id=server.id,
name=server.name,
url=server.url,
ssh_key_id=server.ssh_key_id,
sync_enabled=server.sync_enabled,
schedule_cron=server.schedule_cron,
local_path=server.local_path,
status=server.status,
created_at=server.created_at,
updated_at=server.updated_at
),
message="Server updated successfully"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.delete("/{server_id}", response_model=SuccessResponse[dict])
def delete_server(
server_id: int,
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
Delete a server.
Args:
server_id: ID of the server to delete
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse with empty data
Raises:
HTTPException 401: If authentication fails
HTTPException 404: If server not found
"""
service = ServerService(db)
deleted = service.delete_server(server_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Server with ID {server_id} not found"
)
return SuccessResponse(
code=0,
data={},
message="Server deleted successfully"
)

201
backend/app/api/ssh_keys.py Normal file
View File

@@ -0,0 +1,201 @@
"""
SSH Keys API routes.
Provides CRUD endpoints for SSH key management:
- POST /api/ssh-keys - Create a new SSH key
- GET /api/ssh-keys - List all SSH keys
- GET /api/ssh-keys/{id} - Get a specific SSH key
- DELETE /api/ssh-keys/{id} - Delete an SSH key
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db_session, require_auth
from app.schemas.ssh_key import SshKeyCreate, SshKeyResponse
from app.schemas.common import SuccessResponse, ErrorResponse
from app.services.ssh_key_service import SshKeyService
router = APIRouter(prefix="/api/ssh-keys", tags=["SSH Keys"])
@router.post("", response_model=SuccessResponse[SshKeyResponse], status_code=status.HTTP_201_CREATED)
def create_ssh_key(
ssh_key_data: SshKeyCreate,
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
Create a new SSH key.
The private key will be encrypted before storage.
The name must be unique across all SSH keys.
Args:
ssh_key_data: SSH key creation data (name, private_key)
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse containing the created SSH key
Raises:
HTTPException 400: If validation fails or name already exists
HTTPException 401: If authentication fails
"""
service = SshKeyService(db)
try:
ssh_key = service.create_ssh_key(
name=ssh_key_data.name,
private_key=ssh_key_data.private_key
)
return SuccessResponse(
code=0,
data=SshKeyResponse(
id=ssh_key.id,
name=ssh_key.name,
fingerprint=ssh_key.fingerprint,
created_at=ssh_key.created_at
),
message="SSH key created successfully"
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.get("", response_model=SuccessResponse[List[SshKeyResponse]])
def list_ssh_keys(
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
List all SSH keys.
Returns all SSH keys ordered by creation time.
Private keys are not included in the response.
Args:
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse containing list of SSH keys
Raises:
HTTPException 401: If authentication fails
"""
service = SshKeyService(db)
ssh_keys = service.list_ssh_keys()
return SuccessResponse(
code=0,
data=[
SshKeyResponse(
id=key.id,
name=key.name,
fingerprint=key.fingerprint,
created_at=key.created_at
)
for key in ssh_keys
],
message=f"Retrieved {len(ssh_keys)} SSH key(s)"
)
@router.get("/{key_id}", response_model=SuccessResponse[SshKeyResponse])
def get_ssh_key(
key_id: int,
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
Get a specific SSH key by ID.
Args:
key_id: ID of the SSH key
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse containing the SSH key
Raises:
HTTPException 401: If authentication fails
HTTPException 404: If SSH key not found
"""
service = SshKeyService(db)
ssh_key = service.get_ssh_key(key_id)
if ssh_key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"SSH key with ID {key_id} not found"
)
return SuccessResponse(
code=0,
data=SshKeyResponse(
id=ssh_key.id,
name=ssh_key.name,
fingerprint=ssh_key.fingerprint,
created_at=ssh_key.created_at
),
message="SSH key retrieved successfully"
)
@router.delete("/{key_id}", response_model=SuccessResponse[dict])
def delete_ssh_key(
key_id: int,
db: Session = Depends(get_db_session),
_auth: None = Depends(require_auth)
):
"""
Delete an SSH key.
The SSH key can only be deleted if it is not in use by any server.
If servers are using this key, the deletion will fail.
Args:
key_id: ID of the SSH key to delete
db: Database session (injected)
_auth: Authentication requirement (injected)
Returns:
SuccessResponse with empty data
Raises:
HTTPException 400: If key is in use by servers
HTTPException 401: If authentication fails
HTTPException 404: If SSH key not found
"""
service = SshKeyService(db)
try:
deleted = service.delete_ssh_key(key_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"SSH key with ID {key_id} not found"
)
return SuccessResponse(
code=0,
data={},
message="SSH key deleted successfully"
)
except ValueError as e:
# Key is in use by servers
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)

138
backend/app/api/status.py Normal file
View File

@@ -0,0 +1,138 @@
"""
Status API routes.
Provides system status and health check endpoints:
- GET /api/status - Get system status and health information
"""
from typing import Dict, Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.api.deps import get_db_session, require_auth_optional
from app.schemas.common import SuccessResponse
from app.config import get_settings
from app.models.server import Server
from app.models.ssh_key import SshKey
from app.models.repo import Repo
router = APIRouter(prefix="/api/status", tags=["Status"])
@router.get("", response_model=SuccessResponse[Dict[str, Any]])
def get_status(
db: Session = Depends(get_db_session),
_authenticated: bool = Depends(require_auth_optional)
):
"""
Get system status and health information.
This endpoint provides information about:
- Application status and version
- Database connectivity and statistics
- Counts of servers, SSH keys, and repositories
- Storage paths
Authentication is optional for this endpoint.
Authenticated users may receive additional information.
Args:
db: Database session (injected)
_authenticated: Whether request is authenticated (injected)
Returns:
SuccessResponse containing system status information
Example response:
{
"code": 0,
"data": {
"status": "healthy",
"version": "1.0.0",
"database": {
"status": "connected",
"servers_count": 2,
"ssh_keys_count": 3,
"repos_count": 15
},
"storage": {
"data_dir": "/path/to/data",
"repos_dir": "/path/to/data/repos",
"ssh_keys_dir": "/path/to/data/ssh_keys"
},
"authenticated": true
},
"message": "System status retrieved successfully"
}
"""
settings = get_settings()
status_info: Dict[str, Any] = {
"status": "healthy",
"version": "1.0.0",
"authenticated": _authenticated
}
# Check database connectivity
try:
# Execute a simple query to verify database connection
db.execute(text("SELECT 1"))
# Get counts for each model
servers_count = db.query(Server).count()
ssh_keys_count = db.query(SshKey).count()
repos_count = db.query(Repo).count()
status_info["database"] = {
"status": "connected",
"servers_count": servers_count,
"ssh_keys_count": ssh_keys_count,
"repos_count": repos_count
}
except Exception as e:
status_info["database"] = {
"status": "error",
"error": str(e)
}
status_info["status"] = "degraded"
# Storage paths (only show to authenticated users)
if _authenticated:
status_info["storage"] = {
"data_dir": str(settings.data_dir),
"repos_dir": str(settings.repos_dir),
"ssh_keys_dir": str(settings.ssh_keys_dir),
"db_path": str(settings.db_path)
}
return SuccessResponse(
code=0,
data=status_info,
message="System status retrieved successfully"
)
@router.get("/health", response_model=SuccessResponse[Dict[str, str]])
def health_check():
"""
Simple health check endpoint.
This is a lightweight endpoint for load balancers and monitoring systems.
It always returns 200 OK when the service is running.
Returns:
SuccessResponse indicating healthy status
Example response:
{
"code": 0,
"data": {"status": "ok"},
"message": "Service is healthy"
}
"""
return SuccessResponse(
code=0,
data={"status": "ok"},
message="Service is healthy"
)