"""Repository management endpoints""" import secrets from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from typing import List from cryptography.fernet import Fernet from app.database import get_db from app.models import Repository from app.schemas.repository import ( RepositoryCreate, RepositoryUpdate, RepositoryResponse, RepositoryList ) from app.config import settings router = APIRouter() def get_cipher(): """Get Fernet cipher for encryption""" # Use first 32 bytes of encryption key, base64 encoded key = settings.encryption_key.encode()[:32] # Pad to 32 bytes if needed key = key.ljust(32, b'0') # Base64 encode for Fernet import base64 key_b64 = base64.urlsafe_b64encode(key) return Fernet(key_b64) def encrypt_token(token: str) -> str: """Encrypt API token""" cipher = get_cipher() return cipher.encrypt(token.encode()).decode() def decrypt_token(encrypted_token: str) -> str: """Decrypt API token""" cipher = get_cipher() return cipher.decrypt(encrypted_token.encode()).decode() @router.get("", response_model=RepositoryList) async def list_repositories( skip: int = 0, limit: int = 100, db: AsyncSession = Depends(get_db) ): """List all repositories""" # Get total count count_result = await db.execute(select(func.count(Repository.id))) total = count_result.scalar() # Get repositories result = await db.execute( select(Repository) .offset(skip) .limit(limit) .order_by(Repository.created_at.desc()) ) repositories = result.scalars().all() # Add webhook URL to each repository items = [] for repo in repositories: repo_dict = { "id": repo.id, "name": repo.name, "platform": repo.platform, "url": repo.url, "config": repo.config, "is_active": repo.is_active, "created_at": repo.created_at, "updated_at": repo.updated_at, "webhook_url": f"http://{settings.host}:{settings.port}/api/webhooks/{repo.platform.value}/{repo.id}" } items.append(RepositoryResponse(**repo_dict)) return RepositoryList(items=items, total=total) @router.post("", response_model=RepositoryResponse) async def create_repository( repository: RepositoryCreate, db: AsyncSession = Depends(get_db) ): """Create a new repository""" # Generate webhook secret if not provided webhook_secret = repository.webhook_secret or secrets.token_urlsafe(32) # Encrypt API token (если указан) encrypted_token = encrypt_token(repository.api_token) if repository.api_token else None # Create repository db_repository = Repository( name=repository.name, platform=repository.platform, url=repository.url, api_token=encrypted_token, webhook_secret=webhook_secret, config=repository.config or {} ) db.add(db_repository) await db.commit() await db.refresh(db_repository) # Prepare response webhook_url = f"http://{settings.host}:{settings.port}/api/webhooks/{db_repository.platform.value}/{db_repository.id}" return RepositoryResponse( id=db_repository.id, name=db_repository.name, platform=db_repository.platform, url=db_repository.url, config=db_repository.config, is_active=db_repository.is_active, created_at=db_repository.created_at, updated_at=db_repository.updated_at, webhook_url=webhook_url ) @router.get("/{repository_id}", response_model=RepositoryResponse) async def get_repository( repository_id: int, db: AsyncSession = Depends(get_db) ): """Get repository by ID""" result = await db.execute( select(Repository).where(Repository.id == repository_id) ) repository = result.scalar_one_or_none() if not repository: raise HTTPException(status_code=404, detail="Repository not found") webhook_url = f"http://{settings.host}:{settings.port}/api/webhooks/{repository.platform.value}/{repository.id}" return RepositoryResponse( id=repository.id, name=repository.name, platform=repository.platform, url=repository.url, config=repository.config, is_active=repository.is_active, created_at=repository.created_at, updated_at=repository.updated_at, webhook_url=webhook_url ) @router.put("/{repository_id}", response_model=RepositoryResponse) async def update_repository( repository_id: int, repository_update: RepositoryUpdate, db: AsyncSession = Depends(get_db) ): """Update repository""" result = await db.execute( select(Repository).where(Repository.id == repository_id) ) repository = result.scalar_one_or_none() if not repository: raise HTTPException(status_code=404, detail="Repository not found") # Update fields update_data = repository_update.model_dump(exclude_unset=True) # Encrypt API token if provided and not empty if "api_token" in update_data and update_data["api_token"]: update_data["api_token"] = encrypt_token(update_data["api_token"]) elif "api_token" in update_data and not update_data["api_token"]: # If empty string provided, don't update token del update_data["api_token"] for field, value in update_data.items(): setattr(repository, field, value) await db.commit() await db.refresh(repository) webhook_url = f"http://{settings.host}:{settings.port}/api/webhooks/{repository.platform.value}/{repository.id}" return RepositoryResponse( id=repository.id, name=repository.name, platform=repository.platform, url=repository.url, config=repository.config, is_active=repository.is_active, created_at=repository.created_at, updated_at=repository.updated_at, webhook_url=webhook_url ) @router.delete("/{repository_id}") async def delete_repository( repository_id: int, db: AsyncSession = Depends(get_db) ): """Delete repository""" result = await db.execute( select(Repository).where(Repository.id == repository_id) ) repository = result.scalar_one_or_none() if not repository: raise HTTPException(status_code=404, detail="Repository not found") await db.delete(repository) await db.commit() return {"message": "Repository deleted"} @router.post("/{repository_id}/scan") async def scan_repository( repository_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db) ): """Scan repository for new pull requests and start reviews""" from app.models import PullRequest, Review from app.models.pull_request import PRStatusEnum from app.models.review import ReviewStatusEnum from app.services import GiteaService, GitHubService, BitbucketService from app.utils import decrypt_token # Get repository result = await db.execute( select(Repository).where(Repository.id == repository_id) ) repository = result.scalar_one_or_none() if not repository: raise HTTPException(status_code=404, detail="Repository not found") if not repository.is_active: raise HTTPException(status_code=400, detail="Repository is not active") # Parse repository URL to get owner and name parts = repository.url.rstrip('/').split('/') repo_name = parts[-1].replace('.git', '') repo_owner = parts[-2] base_url = '/'.join(parts[:-2]) # Get appropriate Git service from app.config import settings if repository.api_token: try: decrypted_token = decrypt_token(repository.api_token) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) else: # Используем мастер токен platform = repository.platform.value.lower() if platform == "gitea": decrypted_token = settings.master_gitea_token elif platform == "github": decrypted_token = settings.master_github_token elif platform == "bitbucket": decrypted_token = settings.master_bitbucket_token else: raise HTTPException(status_code=400, detail=f"Unsupported platform: {repository.platform}") if not decrypted_token: raise HTTPException( status_code=400, detail=f"API токен не указан и мастер токен для {platform} не настроен" ) if repository.platform.value == "gitea": git_service = GiteaService(base_url, decrypted_token, repo_owner, repo_name) elif repository.platform.value == "github": git_service = GitHubService(base_url, decrypted_token, repo_owner, repo_name) elif repository.platform.value == "bitbucket": git_service = BitbucketService(base_url, decrypted_token, repo_owner, repo_name) else: raise HTTPException(status_code=400, detail=f"Unsupported platform: {repository.platform}") try: # For Gitea, get list of open PRs import httpx if repository.platform.value == "gitea": url = f"{base_url}/api/v1/repos/{repo_owner}/{repo_name}/pulls" async with httpx.AsyncClient() as client: response = await client.get( url, headers={"Authorization": f"token {decrypted_token}"}, params={"state": "open"} ) response.raise_for_status() prs = response.json() elif repository.platform.value == "github": url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/pulls" async with httpx.AsyncClient() as client: response = await client.get( url, headers={ "Authorization": f"token {decrypted_token}", "Accept": "application/vnd.github.v3+json" }, params={"state": "open"} ) response.raise_for_status() prs = response.json() else: # Bitbucket url = f"https://api.bitbucket.org/2.0/repositories/{repo_owner}/{repo_name}/pullrequests" async with httpx.AsyncClient() as client: response = await client.get( url, headers={"Authorization": f"Bearer {decrypted_token}"}, params={"state": "OPEN"} ) response.raise_for_status() prs = response.json().get("values", []) new_reviews = [] for pr_data in prs: # Get PR number based on platform if repository.platform.value == "bitbucket": pr_number = pr_data["id"] pr_title = pr_data["title"] pr_author = pr_data["author"]["display_name"] pr_url = pr_data["links"]["html"]["href"] source_branch = pr_data["source"]["branch"]["name"] target_branch = pr_data["destination"]["branch"]["name"] else: pr_number = pr_data["number"] pr_title = pr_data["title"] pr_author = pr_data["user"]["login"] pr_url = pr_data["html_url"] source_branch = pr_data["head"]["ref"] target_branch = pr_data["base"]["ref"] # Check if PR already exists result = await db.execute( select(PullRequest).where( PullRequest.repository_id == repository.id, PullRequest.pr_number == pr_number ) ) pr = result.scalar_one_or_none() if not pr: # Create new PR pr = PullRequest( repository_id=repository.id, pr_number=pr_number, title=pr_title, author=pr_author, source_branch=source_branch, target_branch=target_branch, url=pr_url, status=PRStatusEnum.OPEN ) db.add(pr) await db.commit() await db.refresh(pr) # Check if there's already a review for this PR result = await db.execute( select(Review).where( Review.pull_request_id == pr.id, Review.status.in_([ ReviewStatusEnum.PENDING, ReviewStatusEnum.FETCHING, ReviewStatusEnum.ANALYZING, ReviewStatusEnum.COMMENTING ]) ) ) existing_review = result.scalar_one_or_none() if not existing_review: # Create new review review = Review( pull_request_id=pr.id, status=ReviewStatusEnum.PENDING ) db.add(review) await db.commit() await db.refresh(review) # Start review in background from app.api.webhooks import start_review_task background_tasks.add_task( start_review_task, review.id, pr.pr_number, repository.id ) new_reviews.append({ "review_id": review.id, "pr_number": pr.pr_number, "pr_title": pr.title }) return { "message": f"Found {len(prs)} open PR(s), started {len(new_reviews)} new review(s)", "total_prs": len(prs), "new_reviews": len(new_reviews), "reviews": new_reviews } except Exception as e: raise HTTPException(status_code=500, detail=f"Error scanning repository: {str(e)}")