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,38 @@
"""
Pydantic schemas for API request/response validation.
This module exports all schemas used throughout the application.
"""
# Common schemas
from app.schemas.common import SuccessResponse, ErrorResponse
# SSH Key schemas
from app.schemas.ssh_key import SshKeyCreate, SshKeyResponse
# Server schemas
from app.schemas.server import ServerCreate, ServerUpdate, ServerResponse
# Repository schemas
from app.schemas.repo import RepoResponse, CommitInfo
# Sync Log schemas
from app.schemas.sync_log import SyncLogResponse
__all__ = [
# Common
"SuccessResponse",
"ErrorResponse",
# SSH Key
"SshKeyCreate",
"SshKeyResponse",
# Server
"ServerCreate",
"ServerUpdate",
"ServerResponse",
# Repository
"RepoResponse",
"CommitInfo",
# Sync Log
"SyncLogResponse",
]

View File

@@ -0,0 +1,55 @@
"""
Common Pydantic schemas for API responses.
"""
from typing import Generic, TypeVar, Optional
from pydantic import BaseModel, Field
T = TypeVar("T")
class SuccessResponse(BaseModel, Generic[T]):
"""
Standard success response wrapper.
"""
code: int = Field(default=0, description="Response code, 0 for success")
data: T = Field(description="Response data")
message: str = Field(default="success", description="Response message")
model_config = {
"json_schema_extra": {
"examples": [
{
"code": 0,
"data": {},
"message": "success"
}
]
}
}
class ErrorResponse(BaseModel):
"""
Standard error response wrapper.
"""
code: int = Field(description="Error code, non-zero for errors")
message: str = Field(description="Error message")
data: Optional[dict] = Field(default=None, description="Additional error data")
model_config = {
"json_schema_extra": {
"examples": [
{
"code": 400,
"message": "Bad request",
"data": None
},
{
"code": 404,
"message": "Resource not found",
"data": {"detail": "Item with id 123 not found"}
}
]
}
}

View File

@@ -0,0 +1,64 @@
"""
Repository Pydantic schemas.
"""
from typing import Optional
from pydantic import BaseModel, Field
class CommitInfo(BaseModel):
"""
Schema for commit information.
"""
hash: str = Field(description="Commit hash")
author: str = Field(description="Commit author")
message: str = Field(description="Commit message")
timestamp: int = Field(description="Commit timestamp (Unix timestamp)")
model_config = {
"json_schema_extra": {
"examples": [
{
"hash": "a1b2c3d4e5f6...",
"author": "John Doe <john@example.com>",
"message": "Add new feature",
"timestamp": 1711891200
}
]
}
}
class RepoResponse(BaseModel):
"""
Schema for repository response.
"""
id: int = Field(description="Repository ID")
server_id: int = Field(description="Server ID")
name: str = Field(description="Repository name")
full_name: str = Field(description="Repository full name (e.g., 'owner/repo')")
clone_url: str = Field(description="Git clone URL")
local_path: str = Field(description="Local storage path")
last_sync_at: Optional[int] = Field(
default=None,
description="Last sync timestamp (Unix timestamp)"
)
status: str = Field(description="Repository status")
created_at: int = Field(description="Creation timestamp (Unix timestamp)")
model_config = {
"json_schema_extra": {
"examples": [
{
"id": 1,
"server_id": 1,
"name": "my-repo",
"full_name": "myorg/my-repo",
"clone_url": "https://gitea.example.com/myorg/my-repo.git",
"local_path": "/data/gitea-mirror/myorg/my-repo",
"last_sync_at": 1711891200,
"status": "success",
"created_at": 1711804800
}
]
}
}

View File

@@ -0,0 +1,172 @@
"""
Server Pydantic schemas.
"""
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class ServerCreate(BaseModel):
"""
Schema for creating a new server.
"""
name: str = Field(..., min_length=1, max_length=100, description="Server name")
url: str = Field(..., min_length=1, max_length=500, description="Gitea server URL")
api_token: str = Field(..., min_length=1, description="Gitea API token")
ssh_key_id: int = Field(..., gt=0, description="SSH key ID to use")
local_path: str = Field(..., min_length=1, max_length=500, description="Local storage path")
sync_enabled: bool = Field(default=False, description="Whether sync is enabled")
schedule_cron: Optional[str] = Field(
default=None,
max_length=50,
description="Cron expression for scheduled sync"
)
@field_validator("name")
@classmethod
def name_must_not_be_empty(cls, v: str) -> str:
"""Validate that name is not empty or whitespace only."""
if not v or not v.strip():
raise ValueError("name must not be empty")
return v.strip()
@field_validator("url")
@classmethod
def url_must_not_be_empty(cls, v: str) -> str:
"""Validate that url is not empty or whitespace only."""
if not v or not v.strip():
raise ValueError("url must not be empty")
return v.strip()
@field_validator("api_token")
@classmethod
def api_token_must_not_be_empty(cls, v: str) -> str:
"""Validate that api_token is not empty or whitespace only."""
if not v or not v.strip():
raise ValueError("api_token must not be empty")
return v.strip()
@field_validator("local_path")
@classmethod
def local_path_must_not_be_empty(cls, v: str) -> str:
"""Validate that local_path is not empty or whitespace only."""
if not v or not v.strip():
raise ValueError("local_path must not be empty")
return v.strip()
model_config = {
"json_schema_extra": {
"examples": [
{
"name": "my-gitea",
"url": "https://gitea.example.com",
"api_token": "your_api_token_here",
"ssh_key_id": 1,
"local_path": "/data/gitea-mirror",
"sync_enabled": False,
"schedule_cron": None
}
]
}
}
class ServerUpdate(BaseModel):
"""
Schema for updating a server.
All fields are optional.
"""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Server name")
url: Optional[str] = Field(None, min_length=1, max_length=500, description="Gitea server URL")
api_token: Optional[str] = Field(None, min_length=1, description="Gitea API token")
ssh_key_id: Optional[int] = Field(None, gt=0, description="SSH key ID to use")
local_path: Optional[str] = Field(None, min_length=1, max_length=500, description="Local storage path")
sync_enabled: Optional[bool] = Field(None, description="Whether sync is enabled")
schedule_cron: Optional[str] = Field(
None,
max_length=50,
description="Cron expression for scheduled sync"
)
status: Optional[str] = Field(
None,
pattern="^(untested|testing|success|error)$",
description="Server status"
)
@field_validator("name")
@classmethod
def name_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]:
"""Validate that name is not empty or whitespace only."""
if v is not None and (not v or not v.strip()):
raise ValueError("name must not be empty")
return v.strip() if v else None
@field_validator("url")
@classmethod
def url_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]:
"""Validate that url is not empty or whitespace only."""
if v is not None and (not v or not v.strip()):
raise ValueError("url must not be empty")
return v.strip() if v else None
@field_validator("api_token")
@classmethod
def api_token_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]:
"""Validate that api_token is not empty or whitespace only."""
if v is not None and (not v or not v.strip()):
raise ValueError("api_token must not be empty")
return v.strip() if v else None
@field_validator("local_path")
@classmethod
def local_path_must_not_be_empty(cls, v: Optional[str]) -> Optional[str]:
"""Validate that local_path is not empty or whitespace only."""
if v is not None and (not v or not v.strip()):
raise ValueError("local_path must not be empty")
return v.strip() if v else None
model_config = {
"json_schema_extra": {
"examples": [
{
"name": "updated-gitea",
"sync_enabled": True,
"schedule_cron": "0 */6 * * *"
}
]
}
}
class ServerResponse(BaseModel):
"""
Schema for server response.
"""
id: int = Field(description="Server ID")
name: str = Field(description="Server name")
url: str = Field(description="Gitea server URL")
ssh_key_id: int = Field(description="SSH key ID")
sync_enabled: bool = Field(description="Whether sync is enabled")
schedule_cron: Optional[str] = Field(default=None, description="Cron expression")
local_path: str = Field(description="Local storage path")
status: str = Field(description="Server status")
created_at: int = Field(description="Creation timestamp (Unix timestamp)")
updated_at: int = Field(description="Last update timestamp (Unix timestamp)")
model_config = {
"json_schema_extra": {
"examples": [
{
"id": 1,
"name": "my-gitea",
"url": "https://gitea.example.com",
"ssh_key_id": 1,
"sync_enabled": True,
"schedule_cron": "0 */6 * * *",
"local_path": "/data/gitea-mirror",
"status": "success",
"created_at": 1711804800,
"updated_at": 1711891200
}
]
}
}

View File

@@ -0,0 +1,63 @@
"""
SSH Key Pydantic schemas.
"""
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class SshKeyCreate(BaseModel):
"""
Schema for creating a new SSH key.
"""
name: str = Field(..., min_length=1, max_length=100, description="SSH key name")
private_key: str = Field(..., min_length=1, description="SSH private key content")
@field_validator("name")
@classmethod
def name_must_not_be_empty(cls, v: str) -> str:
"""Validate that name is not empty or whitespace only."""
if not v or not v.strip():
raise ValueError("name must not be empty")
return v.strip()
@field_validator("private_key")
@classmethod
def private_key_must_not_be_empty(cls, v: str) -> str:
"""Validate that private_key is not empty or whitespace only."""
if not v or not v.strip():
raise ValueError("private_key must not be empty")
return v.strip()
model_config = {
"json_schema_extra": {
"examples": [
{
"name": "my-git-key",
"private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----"
}
]
}
}
class SshKeyResponse(BaseModel):
"""
Schema for SSH key response.
"""
id: int = Field(description="SSH key ID")
name: str = Field(description="SSH key name")
fingerprint: Optional[str] = Field(default=None, description="SSH key fingerprint")
created_at: int = Field(description="Creation timestamp (Unix timestamp)")
model_config = {
"json_schema_extra": {
"examples": [
{
"id": 1,
"name": "my-git-key",
"fingerprint": "SHA256:abc123...",
"created_at": 1711804800
}
]
}
}

View File

@@ -0,0 +1,52 @@
"""
Sync Log Pydantic schemas.
"""
from typing import Optional
from pydantic import BaseModel, Field
class SyncLogResponse(BaseModel):
"""
Schema for sync log response.
"""
id: int = Field(description="Sync log ID")
repo_id: int = Field(description="Repository ID")
status: str = Field(description="Sync status")
started_at: int = Field(description="Sync start timestamp (Unix timestamp)")
finished_at: int = Field(description="Sync finish timestamp (Unix timestamp)")
commits_count: Optional[int] = Field(
default=None,
description="Number of commits synced"
)
error_msg: Optional[str] = Field(
default=None,
description="Error message if sync failed"
)
created_at: int = Field(description="Creation timestamp (Unix timestamp)")
model_config = {
"json_schema_extra": {
"examples": [
{
"id": 1,
"repo_id": 1,
"status": "success",
"started_at": 1711891200,
"finished_at": 1711891500,
"commits_count": 5,
"error_msg": None,
"created_at": 1711891200
},
{
"id": 2,
"repo_id": 1,
"status": "error",
"started_at": 1711891800,
"finished_at": 1711892000,
"commits_count": None,
"error_msg": "Connection timeout",
"created_at": 1711891800
}
]
}
}