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

@@ -5,5 +5,7 @@ Business logic layer for application services.
"""
from app.services.ssh_key_service import SshKeyService
from app.services.server_service import ServerService
from app.services.sync_service import SyncService
from app.services.repo_service import RepoService
__all__ = ['SshKeyService', 'ServerService']
__all__ = ['SshKeyService', 'ServerService', 'SyncService', 'RepoService']

View File

@@ -0,0 +1,227 @@
"""
Repo Service.
Business logic for repository management including:
- Creating repository records
- Listing repositories by server
- Retrieving repository details
- Updating repository status
- Deleting repository records
"""
import time
from typing import List, Optional
from pathlib import Path
from sqlalchemy.orm import Session
from app.models.repo import Repo
from app.models.server import Server
from app.config import get_settings
class RepoService:
"""
Service for managing repository records.
Handles CRUD operations for repositories that are being
mirrored from Gitea servers.
"""
def __init__(self, db: Session):
"""
Initialize the service with a database session.
Args:
db: SQLAlchemy database session
"""
self.db = db
self.settings = get_settings()
def create_repo(
self,
server_id: int,
name: str,
full_name: str,
clone_url: str,
local_path: Optional[str] = None
) -> Repo:
"""
Create a new repository record.
Args:
server_id: ID of the server this repo belongs to
name: Repository name (e.g., "my-repo")
full_name: Full repository name (e.g., "owner/my-repo")
clone_url: Git clone URL (typically SSH format)
local_path: Optional local path for the mirrored repo.
If not provided, will be generated based on server and repo name.
Returns:
Created Repo model instance
Raises:
ValueError: If server_id is invalid
"""
# Verify server exists
server = self.db.query(Server).filter_by(id=server_id).first()
if not server:
raise ValueError(f"Server not found with ID {server_id}")
# Generate local_path if not provided
if local_path is None:
local_path = str(Path(server.local_path) / name)
# Create the repo record
current_time = int(time.time())
repo = Repo(
server_id=server_id,
name=name,
full_name=full_name,
clone_url=clone_url,
local_path=local_path,
status="pending",
last_sync_at=None,
created_at=current_time
)
self.db.add(repo)
self.db.commit()
self.db.refresh(repo)
return repo
def list_repos(self, server_id: int) -> List[Repo]:
"""
List all repositories for a specific server.
Args:
server_id: ID of the server
Returns:
List of Repo model instances for the server, ordered by creation time
"""
return self.db.query(Repo).filter_by(
server_id=server_id
).order_by(Repo.created_at).all()
def get_repo(self, repo_id: int) -> Optional[Repo]:
"""
Get a repository by ID.
Args:
repo_id: ID of the repository
Returns:
Repo model instance or None if not found
"""
return self.db.query(Repo).filter_by(id=repo_id).first()
def update_repo_status(self, repo_id: int, status: str) -> Repo:
"""
Update the status of a repository.
Common status values:
- "pending": Initial state, not yet synced
- "syncing": Currently being synced
- "success": Last sync was successful
- "failed": Last sync failed
Args:
repo_id: ID of the repository
status: New status value
Returns:
Updated Repo model instance
Raises:
ValueError: If repo not found
"""
repo = self.get_repo(repo_id)
if not repo:
raise ValueError(f"Repo not found with ID {repo_id}")
repo.status = status
# If status is success, update last_sync_at
if status == "success":
repo.last_sync_at = int(time.time())
self.db.commit()
self.db.refresh(repo)
return repo
def delete_repo(self, repo_id: int) -> bool:
"""
Delete a repository.
Args:
repo_id: ID of the repository to delete
Returns:
True if deleted, False if not found
"""
repo = self.get_repo(repo_id)
if not repo:
return False
self.db.delete(repo)
self.db.commit()
return True
def get_repo_by_name(self, server_id: int, name: str) -> Optional[Repo]:
"""
Get a repository by server and name.
Args:
server_id: ID of the server
name: Repository name
Returns:
Repo model instance or None if not found
"""
return self.db.query(Repo).filter_by(
server_id=server_id,
name=name
).first()
def list_all_repos(self) -> List[Repo]:
"""
List all repositories across all servers.
Returns:
List of all Repo model instances, ordered by creation time
"""
return self.db.query(Repo).order_by(Repo.created_at).all()
def update_repo(
self,
repo_id: int,
**kwargs
) -> Repo:
"""
Update a repository's configuration.
Args:
repo_id: ID of the repository to update
**kwargs: Fields to update (name, full_name, clone_url, local_path, status)
Returns:
Updated Repo model instance
Raises:
ValueError: If repo not found
"""
repo = self.get_repo(repo_id)
if not repo:
raise ValueError(f"Repo not found with ID {repo_id}")
# Update fields
for key, value in kwargs.items():
if hasattr(repo, key):
setattr(repo, key, value)
self.db.commit()
self.db.refresh(repo)
return repo

View File

@@ -0,0 +1,263 @@
"""
Sync Service.
Handles Git operations for repository mirroring including:
- Cloning repositories with SSH authentication
- Fetching updates from mirrored repositories
- Counting commits in repositories
- Retrieving commit history
"""
import subprocess
import tempfile
import os
from pathlib import Path
from typing import List, Dict, Optional
from sqlalchemy.orm import Session
import time
from app.models.repo import Repo
class SyncService:
"""
Service for managing Git repository synchronization.
Handles clone and fetch operations with SSH key authentication.
"""
def __init__(self, db: Session):
"""
Initialize the service with a database session.
Args:
db: SQLAlchemy database session
"""
self.db = db
def sync_repo(self, repo: Repo, ssh_key_content: str) -> None:
"""
Synchronize a repository by cloning or fetching.
If the repository doesn't exist locally, clone it.
If it exists, fetch all updates.
Args:
repo: Repo model instance
ssh_key_content: SSH private key content for authentication
Raises:
Exception: If clone or fetch operation fails
"""
local_path = Path(repo.local_path)
# Update repo status to syncing
repo.status = "syncing"
repo.last_sync_at = int(time.time())
self.db.commit()
if local_path.exists():
# Repository exists, fetch updates
self._fetch_repo(str(local_path), ssh_key_content)
else:
# Repository doesn't exist, clone it
self._clone_repo(repo.clone_url, str(local_path), ssh_key_content)
def _clone_repo(self, clone_url: str, local_path: str, ssh_key: str) -> None:
"""
Clone a repository using git clone --mirror.
Creates a bare mirror clone of the repository.
Args:
clone_url: Git clone URL (SSH format)
local_path: Local path where repo should be cloned
ssh_key: SSH private key content for authentication
Raises:
subprocess.CalledProcessError: If git clone fails
IOError: If unable to create temporary SSH key file
"""
# Create a temporary file for SSH key
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.key') as key_file:
key_file.write(ssh_key)
key_file_path = key_file.name
try:
# Set appropriate permissions for SSH key
os.chmod(key_file_path, 0o600)
# Create SSH command wrapper that uses our key
ssh_cmd = f'ssh -i {key_file_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
# Git clone command with mirror option
git_cmd = [
'git',
'clone',
'--mirror',
clone_url,
local_path
]
# Run git clone with SSH authentication
env = os.environ.copy()
env['GIT_SSH_COMMAND'] = ssh_cmd
result = subprocess.run(
git_cmd,
env=env,
capture_output=True,
text=True,
check=True
)
return result
finally:
# Clean up temporary SSH key file
try:
os.unlink(key_file_path)
except OSError:
pass
def _fetch_repo(self, local_path: str, ssh_key: str) -> None:
"""
Fetch all updates for an existing repository.
Args:
local_path: Local path to the repository
ssh_key: SSH private key content for authentication
Raises:
subprocess.CalledProcessError: If git fetch fails
IOError: If unable to create temporary SSH key file
"""
# Create a temporary file for SSH key
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.key') as key_file:
key_file.write(ssh_key)
key_file_path = key_file.name
try:
# Set appropriate permissions for SSH key
os.chmod(key_file_path, 0o600)
# Create SSH command wrapper that uses our key
ssh_cmd = f'ssh -i {key_file_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
# Git fetch command to get all updates
git_cmd = [
'git',
'--git-dir',
local_path,
'fetch',
'--all'
]
# Run git fetch with SSH authentication
env = os.environ.copy()
env['GIT_SSH_COMMAND'] = ssh_cmd
result = subprocess.run(
git_cmd,
env=env,
capture_output=True,
text=True,
check=True
)
return result
finally:
# Clean up temporary SSH key file
try:
os.unlink(key_file_path)
except OSError:
pass
def _count_commits(self, repo_path: str) -> int:
"""
Count the number of commits in a repository.
Args:
repo_path: Path to the repository
Returns:
Number of commits, or 0 if counting fails
"""
try:
git_cmd = [
'git',
'--git-dir',
repo_path,
'rev-list',
'--all',
'--count'
]
result = subprocess.run(
git_cmd,
capture_output=True,
text=True,
check=True
)
return int(result.stdout.strip())
except (subprocess.CalledProcessError, ValueError):
return 0
def get_repo_commits(self, repo: Repo, limit: int = 100) -> List[Dict[str, str]]:
"""
Get commit history for a repository.
Args:
repo: Repo model instance
limit: Maximum number of commits to return
Returns:
List of commit dictionaries containing:
- hash: Commit SHA
- message: Commit message
- author: Author name
- email: Author email
- date: Commit timestamp (Unix timestamp)
"""
repo_path = Path(repo.local_path)
if not repo_path.exists():
return []
try:
git_cmd = [
'git',
'--git-dir',
str(repo_path),
'log',
'--all',
f'--max-count={limit}',
'--format=%H|%s|%an|%ae|%ct'
]
result = subprocess.run(
git_cmd,
capture_output=True,
text=True,
check=True
)
commits = []
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split('|')
if len(parts) == 5:
commits.append({
'hash': parts[0],
'message': parts[1],
'author': parts[2],
'email': parts[3],
'date': int(parts[4])
})
return commits
except subprocess.CalledProcessError:
return []