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,411 @@
"""
Tests for Repo Service.
"""
import base64
import pytest
import time
from pathlib import Path
from app.models.repo import Repo
from app.models.server import Server
from app.services.repo_service import RepoService
from app.services.server_service import ServerService
from app.services.ssh_key_service import SshKeyService
# Valid test SSH key
VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAAAAJi/vqmQv6pk
AAAAAtzc2gtZWQyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAA
AAAEBD0cWNQnpLDUYEGNMSgVIApVJfCFuRfGG3uxJZRKLvqH+kM3CM1zOc1pGUssXkb2E
JA2uuo1ntB4rIsjL+e8cAAAADm1lc3NlbmdlckBrZW50cm9zBAgMEBQ=
-----END OPENSSH PRIVATE KEY-----
"""
def create_test_server_with_repo(db_session, server_name="test-server"):
"""Helper to create a test server."""
ssh_service = SshKeyService(db_session)
ssh_key = ssh_service.create_ssh_key(name="test-ssh-key", private_key=VALID_SSH_KEY)
server_service = ServerService(db_session)
return server_service.create_server(
name=server_name,
url="https://gitea.example.com",
api_token="test-api-token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
class TestCreateRepo:
"""Tests for create_repo method."""
def test_create_repo_success(self, db_session, test_env_vars):
"""Test successful repository creation."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="test-repo",
full_name="owner/test-repo",
clone_url="git@gitea.example.com:owner/test-repo.git",
local_path="/tmp/test-repo"
)
assert repo.id is not None
assert repo.server_id == server.id
assert repo.name == "test-repo"
assert repo.full_name == "owner/test-repo"
assert repo.clone_url == "git@gitea.example.com:owner/test-repo.git"
assert repo.local_path == "/tmp/test-repo"
assert repo.status == "pending"
assert repo.last_sync_at is None
assert repo.created_at is not None
def test_create_repo_with_invalid_server_id(self, db_session, test_env_vars):
"""Test that invalid server_id is rejected."""
service = RepoService(db_session)
with pytest.raises(ValueError, match="Server not found"):
service.create_repo(
server_id=99999,
name="test-repo",
full_name="owner/test-repo",
clone_url="git@gitea.example.com:owner/test-repo.git",
local_path="/tmp/test-repo"
)
def test_create_repo_generates_local_path(self, db_session, test_env_vars):
"""Test that local_path can be generated correctly."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
# Test with None local_path to let it generate
repo = service.create_repo(
server_id=server.id,
name="auto-path-repo",
full_name="owner/auto-path-repo",
clone_url="git@gitea.example.com:owner/auto-path-repo.git",
local_path=None
)
# Should generate path based on server and repo name
assert repo.local_path is not None
assert "auto-path-repo" in repo.local_path
def test_create_repo_duplicate_name_same_server(self, db_session, test_env_vars):
"""Test that duplicate repo names on same server are allowed (different from servers)."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
# Create first repo
service.create_repo(
server_id=server.id,
name="duplicate-repo",
full_name="owner1/duplicate-repo",
clone_url="git@gitea.example.com:owner1/duplicate-repo.git",
local_path="/tmp/duplicate-repo-1"
)
# Create second repo with same name but different full_name
repo2 = service.create_repo(
server_id=server.id,
name="duplicate-repo",
full_name="owner2/duplicate-repo",
clone_url="git@gitea.example.com:owner2/duplicate-repo.git",
local_path="/tmp/duplicate-repo-2"
)
assert repo2 is not None
assert repo2.full_name == "owner2/duplicate-repo"
def test_create_repo_default_status(self, db_session, test_env_vars):
"""Test that new repos have default 'pending' status."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-test-repo",
full_name="owner/status-test-repo",
clone_url="git@gitea.example.com:owner/status-test-repo.git",
local_path="/tmp/status-test-repo"
)
assert repo.status == "pending"
class TestListRepos:
"""Tests for list_repos method."""
def test_list_repos_empty(self, db_session, test_env_vars):
"""Test listing repos when none exist."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repos = service.list_repos(server.id)
assert repos == []
def test_list_repos_multiple(self, db_session, test_env_vars):
"""Test listing multiple repos for a server."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
service.create_repo(
server_id=server.id,
name="repo-1",
full_name="owner/repo-1",
clone_url="git@gitea.example.com:owner/repo-1.git",
local_path="/tmp/repo-1"
)
service.create_repo(
server_id=server.id,
name="repo-2",
full_name="owner/repo-2",
clone_url="git@gitea.example.com:owner/repo-2.git",
local_path="/tmp/repo-2"
)
repos = service.list_repos(server.id)
assert len(repos) == 2
assert any(r.name == "repo-1" for r in repos)
assert any(r.name == "repo-2" for r in repos)
def test_list_repos_filters_by_server(self, db_session, test_env_vars):
"""Test that list_repos only returns repos for specified server."""
server1 = create_test_server_with_repo(db_session, "server-1")
server2 = create_test_server_with_repo(db_session, "server-2")
service = RepoService(db_session)
# Create repo for server1
service.create_repo(
server_id=server1.id,
name="server1-repo",
full_name="owner/server1-repo",
clone_url="git@gitea.example.com:owner/server1-repo.git",
local_path="/tmp/server1-repo"
)
# Create repo for server2
service.create_repo(
server_id=server2.id,
name="server2-repo",
full_name="owner/server2-repo",
clone_url="git@gitea.example.com:owner/server2-repo.git",
local_path="/tmp/server2-repo"
)
# List repos for server1 should only return server1's repo
server1_repos = service.list_repos(server1.id)
assert len(server1_repos) == 1
assert server1_repos[0].name == "server1-repo"
# List repos for server2 should only return server2's repo
server2_repos = service.list_repos(server2.id)
assert len(server2_repos) == 1
assert server2_repos[0].name == "server2-repo"
def test_list_repos_ordered_by_creation(self, db_session, test_env_vars):
"""Test that repos are listed in creation order."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo1 = service.create_repo(
server_id=server.id,
name="first-repo",
full_name="owner/first-repo",
clone_url="git@gitea.example.com:owner/first-repo.git",
local_path="/tmp/first-repo"
)
time.sleep(0.1) # Small delay to ensure timestamp difference
repo2 = service.create_repo(
server_id=server.id,
name="second-repo",
full_name="owner/second-repo",
clone_url="git@gitea.example.com:owner/second-repo.git",
local_path="/tmp/second-repo"
)
repos = service.list_repos(server.id)
assert repos[0].id == repo1.id
assert repos[1].id == repo2.id
class TestGetRepo:
"""Tests for get_repo method."""
def test_get_repo_by_id(self, db_session, test_env_vars):
"""Test getting a repo by ID."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
created_repo = service.create_repo(
server_id=server.id,
name="get-test-repo",
full_name="owner/get-test-repo",
clone_url="git@gitea.example.com:owner/get-test-repo.git",
local_path="/tmp/get-test-repo"
)
retrieved_repo = service.get_repo(created_repo.id)
assert retrieved_repo is not None
assert retrieved_repo.id == created_repo.id
assert retrieved_repo.name == "get-test-repo"
def test_get_repo_not_found(self, db_session, test_env_vars):
"""Test getting a non-existent repo."""
service = RepoService(db_session)
repo = service.get_repo(99999)
assert repo is None
class TestUpdateRepoStatus:
"""Tests for update_repo_status method."""
def test_update_repo_status_to_syncing(self, db_session, test_env_vars):
"""Test updating repo status to syncing."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-repo",
full_name="owner/status-repo",
clone_url="git@gitea.example.com:owner/status-repo.git",
local_path="/tmp/status-repo"
)
updated_repo = service.update_repo_status(repo.id, "syncing")
assert updated_repo.status == "syncing"
assert updated_repo.id == repo.id
def test_update_repo_status_to_success(self, db_session, test_env_vars):
"""Test updating repo status to success."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-repo",
full_name="owner/status-repo",
clone_url="git@gitea.example.com:owner/status-repo.git",
local_path="/tmp/status-repo"
)
updated_repo = service.update_repo_status(repo.id, "success")
assert updated_repo.status == "success"
def test_update_repo_status_to_failed(self, db_session, test_env_vars):
"""Test updating repo status to failed."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="status-repo",
full_name="owner/status-repo",
clone_url="git@gitea.example.com:owner/status-repo.git",
local_path="/tmp/status-repo"
)
updated_repo = service.update_repo_status(repo.id, "failed")
assert updated_repo.status == "failed"
def test_update_repo_status_not_found(self, db_session, test_env_vars):
"""Test updating status for non-existent repo."""
service = RepoService(db_session)
with pytest.raises(ValueError, match="Repo not found"):
service.update_repo_status(99999, "syncing")
class TestDeleteRepo:
"""Tests for delete_repo method."""
def test_delete_repo_success(self, db_session, test_env_vars):
"""Test successful repo deletion."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
repo = service.create_repo(
server_id=server.id,
name="delete-test-repo",
full_name="owner/delete-test-repo",
clone_url="git@gitea.example.com:owner/delete-test-repo.git",
local_path="/tmp/delete-test-repo"
)
result = service.delete_repo(repo.id)
assert result is True
# Verify the repo is deleted
retrieved_repo = service.get_repo(repo.id)
assert retrieved_repo is None
def test_delete_repo_not_found(self, db_session, test_env_vars):
"""Test deleting a non-existent repo."""
service = RepoService(db_session)
result = service.delete_repo(99999)
assert result is False
class TestIntegration:
"""Integration tests for Repo service."""
def test_repo_lifecycle(self, db_session, test_env_vars):
"""Test complete lifecycle of a repo."""
server = create_test_server_with_repo(db_session)
service = RepoService(db_session)
# Create repo
repo = service.create_repo(
server_id=server.id,
name="lifecycle-repo",
full_name="owner/lifecycle-repo",
clone_url="git@gitea.example.com:owner/lifecycle-repo.git",
local_path="/tmp/lifecycle-repo"
)
assert repo.status == "pending"
# Update status to syncing
repo = service.update_repo_status(repo.id, "syncing")
assert repo.status == "syncing"
# Update status to success
repo = service.update_repo_status(repo.id, "success")
assert repo.status == "success"
# Verify we can retrieve it
retrieved = service.get_repo(repo.id)
assert retrieved is not None
assert retrieved.status == "success"
# List repos for server
repos = service.list_repos(server.id)
assert len(repos) == 1
assert repos[0].id == repo.id
# Delete repo
result = service.delete_repo(repo.id)
assert result is True
# Verify it's gone
repos = service.list_repos(server.id)
assert len(repos) == 0

View File

@@ -0,0 +1,323 @@
"""
Tests for Sync Service.
"""
import base64
import pytest
import subprocess
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock, call
from app.models.repo import Repo
from app.models.server import Server
from app.models.ssh_key import SshKey
from app.services.sync_service import SyncService
# Valid test SSH key
VALID_SSH_KEY = """-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAAAAJi/vqmQv6pk
AAAAAtzc2gtZWQyNTUxOQAAACB/pDNwjNcznNaRlLNF5G9hCQNjbqNZ7QeKyLIy/nvHAA
AAAEBD0cWNQnpLDUYEGNMSgVIApVJfCFuRfGG3uxJZRKLvqH+kM3CM1zOc1pGUssXkb2E
JA2uuo1ntB4rIsjL+e8cAAAADm1lc3NlbmdlckBrZW50cm9zBAgMEBQ=
-----END OPENSSH PRIVATE KEY-----
"""
def create_test_server(db_session, name="test-server"):
"""Helper to create a test server."""
from app.services.ssh_key_service import SshKeyService
from app.services.server_service import ServerService
ssh_service = SshKeyService(db_session)
ssh_key = ssh_service.create_ssh_key(name="test-ssh-key", private_key=VALID_SSH_KEY)
server_service = ServerService(db_session)
return server_service.create_server(
name=name,
url="https://gitea.example.com",
api_token="test-api-token",
ssh_key_id=ssh_key.id,
sync_enabled=False,
schedule_cron=None
)
def create_test_repo(db_session, server_id=None, name="test-repo", local_path=None):
"""Helper to create a test repo."""
if server_id is None:
server = create_test_server(db_session)
server_id = server.id
import time
repo = Repo(
server_id=server_id,
name=name,
full_name=f"owner/{name}",
clone_url="git@gitea.example.com:owner/test-repo.git",
local_path=local_path or "/tmp/test-repo",
status="pending",
created_at=int(time.time())
)
db_session.add(repo)
db_session.commit()
db_session.refresh(repo)
return repo
class TestSyncRepo:
"""Tests for sync_repo method."""
def test_sync_repo_clones_new_repo(self, db_session, test_env_vars, tmp_path):
"""Test syncing a new repository triggers clone."""
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
service = SyncService(db_session)
with patch.object(service, '_clone_repo') as mock_clone:
service.sync_repo(repo, VALID_SSH_KEY)
mock_clone.assert_called_once()
def test_sync_repo_fetches_existing_repo(self, db_session, test_env_vars, tmp_path):
"""Test syncing an existing repository triggers fetch."""
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
# Create the directory to simulate existing repo
tmp_path.joinpath("test-repo").mkdir()
service = SyncService(db_session)
with patch.object(service, '_fetch_repo') as mock_fetch:
with patch('pathlib.Path.exists', return_value=True):
service.sync_repo(repo, VALID_SSH_KEY)
mock_fetch.assert_called_once()
def test_sync_repo_updates_status_to_syncing(self, db_session, test_env_vars, tmp_path):
"""Test that sync_repo updates repo status to syncing."""
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
service = SyncService(db_session)
with patch.object(service, '_clone_repo'):
service.sync_repo(repo, VALID_SSH_KEY)
assert repo.status == "syncing"
def test_sync_repo_updates_last_sync_at(self, db_session, test_env_vars, tmp_path):
"""Test that sync_repo updates last_sync_at timestamp."""
import time
repo = create_test_repo(db_session, local_path=str(tmp_path / "test-repo"))
service = SyncService(db_session)
with patch.object(service, '_clone_repo'):
before_sync = int(time.time())
service.sync_repo(repo, VALID_SSH_KEY)
after_sync = int(time.time())
assert before_sync <= repo.last_sync_at <= after_sync
class TestCloneRepo:
"""Tests for _clone_repo method."""
def test_clone_repo_calls_git_command(self, db_session, test_env_vars):
"""Test that _clone_repo calls git clone with correct arguments."""
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=0)
service._clone_repo(
"git@gitea.example.com:owner/repo.git",
"/tmp/repo",
VALID_SSH_KEY
)
# Verify subprocess.run was called
assert mock_run.called
call_args = mock_run.call_args
assert 'git' in call_args[0][0]
def test_clone_repo_creates_ssh_wrapper(self, db_session, test_env_vars, tmp_path):
"""Test that _clone_repo creates SSH key wrapper."""
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=0)
with patch('tempfile.NamedTemporaryFile') as mock_temp:
mock_temp.return_value.__enter__.return_value.name = "/tmp/test_key"
service._clone_repo(
"git@gitea.example.com:owner/repo.git",
str(tmp_path / "repo"),
VALID_SSH_KEY
)
# Verify temp file was created
mock_temp.assert_called()
def test_clone_repo_handles_failure(self, db_session, test_env_vars, tmp_path):
"""Test that _clone_repo handles git clone failures."""
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
with pytest.raises(Exception):
service._clone_repo(
"git@gitea.example.com:owner/repo.git",
str(tmp_path / "repo"),
VALID_SSH_KEY
)
class TestFetchRepo:
"""Tests for _fetch_repo method."""
def test_fetch_repo_calls_git_fetch(self, db_session, test_env_vars):
"""Test that _fetch_repo calls git fetch --all."""
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
mock_run.return_value = Mock(returncode=0)
service._fetch_repo("/tmp/repo", VALID_SSH_KEY)
# Verify subprocess.run was called
assert mock_run.called
call_args = mock_run.call_args
assert 'git' in call_args[0][0]
def test_fetch_repo_handles_failure(self, db_session, test_env_vars):
"""Test that _fetch_repo handles git fetch failures."""
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
with pytest.raises(Exception):
service._fetch_repo("/tmp/repo", VALID_SSH_KEY)
class TestCountCommits:
"""Tests for _count_commits method."""
def test_count_commits_returns_zero_for_nonexistent_repo(self, db_session, test_env_vars):
"""Test that _count_commits returns 0 for non-existent repo."""
service = SyncService(db_session)
count = service._count_commits("/nonexistent/repo")
assert count == 0
def test_count_commits_parses_git_output(self, db_session, test_env_vars, tmp_path):
"""Test that _count_commits parses git rev-list output."""
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
# Simulate git output with commit count
result = Mock()
result.stdout.decode.return_value = "a1b2c3d\ne4f5g6h\n"
result.returncode = 0
mock_run.return_value = result
count = service._count_commits(str(tmp_path))
assert count == 2
def test_count_commits_handles_command_failure(self, db_session, test_env_vars, tmp_path):
"""Test that _count_commits handles git command failure."""
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
count = service._count_commits(str(tmp_path))
assert count == 0
class TestGetRepoCommits:
"""Tests for get_repo_commits method."""
def test_get_repo_commits_returns_empty_list_for_nonexistent_repo(self, db_session, test_env_vars):
"""Test that get_repo_commits returns empty list for non-existent repo."""
repo = create_test_repo(db_session, local_path="/nonexistent/repo")
service = SyncService(db_session)
commits = service.get_repo_commits(repo, limit=10)
assert commits == []
def test_get_repo_commits_parses_git_log_output(self, db_session, test_env_vars, tmp_path):
"""Test that get_repo_commits parses git log output."""
repo = create_test_repo(db_session, local_path=str(tmp_path))
service = SyncService(db_session)
git_log_output = """a1b2c3d4|First commit|author1|author1@example.com|1000000
e4f5g6h7|Second commit|author2|author2@example.com|2000000"""
with patch('subprocess.run') as mock_run:
result = Mock()
result.stdout.decode.return_value = git_log_output
result.returncode = 0
mock_run.return_value = result
commits = service.get_repo_commits(repo, limit=10)
assert len(commits) == 2
assert commits[0]['hash'] == 'a1b2c3d4'
assert commits[0]['message'] == 'First commit'
assert commits[1]['hash'] == 'e4f5g6h7'
assert commits[1]['message'] == 'Second commit'
def test_get_repo_commits_respects_limit(self, db_session, test_env_vars, tmp_path):
"""Test that get_repo_commits respects the limit parameter."""
repo = create_test_repo(db_session, local_path=str(tmp_path))
service = SyncService(db_session)
git_log_output = """a1b2c3d4|Commit 1|author1|author1@example.com|1000000
e4f5g6h7|Commit 2|author2|author2@example.com|2000000
i7j8k9l0|Commit 3|author3|author3@example.com|3000000"""
with patch('subprocess.run') as mock_run:
result = Mock()
result.stdout.decode.return_value = git_log_output
result.returncode = 0
mock_run.return_value = result
commits = service.get_repo_commits(repo, limit=2)
assert len(commits) == 2
def test_get_repo_commits_handles_command_failure(self, db_session, test_env_vars, tmp_path):
"""Test that get_repo_commits handles git command failure."""
repo = create_test_repo(db_session, local_path=str(tmp_path))
service = SyncService(db_session)
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, 'git')
commits = service.get_repo_commits(repo, limit=10)
assert commits == []
class TestIntegration:
"""Integration tests for Sync service."""
def test_sync_and_get_commits_workflow(self, db_session, test_env_vars, tmp_path):
"""Test complete workflow of syncing and getting commits."""
repo = create_test_repo(db_session, local_path=str(tmp_path / "repo"))
service = SyncService(db_session)
with patch.object(service, '_clone_repo'):
service.sync_repo(repo, VALID_SSH_KEY)
# Simulate commits exist
git_log_output = "a1b2c3d4|Test commit|author|author@example.com|1000000"
with patch('subprocess.run') as mock_run:
result = Mock()
result.stdout.decode.return_value = git_log_output
result.returncode = 0
mock_run.return_value = result
commits = service.get_repo_commits(repo, limit=10)
assert len(commits) == 1
assert repo.status == "syncing"