code-review-agent/backend/app/api/repositories.py
Primakov Alexandr Alexandrovich 09cdd06307 init
2025-10-12 23:15:09 +03:00

420 lines
14 KiB
Python

"""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)}")