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:
@@ -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']
|
||||
|
||||
227
backend/app/services/repo_service.py
Normal file
227
backend/app/services/repo_service.py
Normal 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
|
||||
263
backend/app/services/sync_service.py
Normal file
263
backend/app/services/sync_service.py
Normal 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 []
|
||||
Reference in New Issue
Block a user