420 lines
14 KiB
Python
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)}")
|
|
|