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:
@@ -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",
|
||||
]
|
||||
|
||||
55
backend/app/schemas/common.py
Normal file
55
backend/app/schemas/common.py
Normal 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"}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
64
backend/app/schemas/repo.py
Normal file
64
backend/app/schemas/repo.py
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
172
backend/app/schemas/server.py
Normal file
172
backend/app/schemas/server.py
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
63
backend/app/schemas/ssh_key.py
Normal file
63
backend/app/schemas/ssh_key.py
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
52
backend/app/schemas/sync_log.py
Normal file
52
backend/app/schemas/sync_log.py
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user