This commit is contained in:
Primakov Alexandr Alexandrovich
2025-10-12 23:15:09 +03:00
commit 09cdd06307
88 changed files with 15007 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
"""API endpoints"""
from fastapi import APIRouter
from app.api import repositories, reviews, webhooks
api_router = APIRouter()
api_router.include_router(repositories.router, prefix="/repositories", tags=["repositories"])
api_router.include_router(reviews.router, prefix="/reviews", tags=["reviews"])
api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
__all__ = ["api_router"]

View File

@@ -0,0 +1,419 @@
"""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)}")

218
backend/app/api/reviews.py Normal file
View File

@@ -0,0 +1,218 @@
"""Review management endpoints"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import joinedload
from app.database import get_db
from app.models import Review, Comment, PullRequest
from app.schemas.review import ReviewResponse, ReviewList, ReviewStats, PullRequestInfo, CommentResponse
from app.agents import ReviewerAgent
router = APIRouter()
@router.get("", response_model=ReviewList)
async def list_reviews(
skip: int = 0,
limit: int = 100,
repository_id: int = None,
status: str = None,
db: AsyncSession = Depends(get_db)
):
"""List all reviews with filters"""
query = select(Review).options(joinedload(Review.pull_request))
# Apply filters
if repository_id:
query = query.join(PullRequest).where(PullRequest.repository_id == repository_id)
if status:
query = query.where(Review.status == status)
# Get total count
count_query = select(func.count(Review.id))
if repository_id:
count_query = count_query.join(PullRequest).where(PullRequest.repository_id == repository_id)
if status:
count_query = count_query.where(Review.status == status)
count_result = await db.execute(count_query)
total = count_result.scalar()
# Get reviews
query = query.offset(skip).limit(limit).order_by(Review.started_at.desc())
result = await db.execute(query)
reviews = result.scalars().all()
# Convert to response models
items = []
for review in reviews:
pr_info = PullRequestInfo(
id=review.pull_request.id,
pr_number=review.pull_request.pr_number,
title=review.pull_request.title,
author=review.pull_request.author,
source_branch=review.pull_request.source_branch,
target_branch=review.pull_request.target_branch,
url=review.pull_request.url
)
items.append(ReviewResponse(
id=review.id,
pull_request_id=review.pull_request_id,
pull_request=pr_info,
status=review.status,
started_at=review.started_at,
completed_at=review.completed_at,
files_analyzed=review.files_analyzed,
comments_generated=review.comments_generated,
error_message=review.error_message
))
return ReviewList(items=items, total=total)
@router.get("/{review_id}", response_model=ReviewResponse)
async def get_review(
review_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get review by ID with comments"""
result = await db.execute(
select(Review)
.options(joinedload(Review.pull_request), joinedload(Review.comments))
.where(Review.id == review_id)
)
review = result.unique().scalar_one_or_none()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
pr_info = PullRequestInfo(
id=review.pull_request.id,
pr_number=review.pull_request.pr_number,
title=review.pull_request.title,
author=review.pull_request.author,
source_branch=review.pull_request.source_branch,
target_branch=review.pull_request.target_branch,
url=review.pull_request.url
)
comments = [
CommentResponse(
id=comment.id,
file_path=comment.file_path,
line_number=comment.line_number,
content=comment.content,
severity=comment.severity,
posted=comment.posted,
posted_at=comment.posted_at,
created_at=comment.created_at
)
for comment in review.comments
]
return ReviewResponse(
id=review.id,
pull_request_id=review.pull_request_id,
pull_request=pr_info,
status=review.status,
started_at=review.started_at,
completed_at=review.completed_at,
files_analyzed=review.files_analyzed,
comments_generated=review.comments_generated,
error_message=review.error_message,
comments=comments
)
async def run_review_task(review_id: int, pr_number: int, repository_id: int, db: AsyncSession):
"""Background task to run review"""
agent = ReviewerAgent(db)
await agent.run_review(review_id, pr_number, repository_id)
@router.post("/{review_id}/retry")
async def retry_review(
review_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Retry a failed review"""
result = await db.execute(
select(Review).options(joinedload(Review.pull_request)).where(Review.id == review_id)
)
review = result.scalar_one_or_none()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
# Reset review status
from app.models.review import ReviewStatusEnum
review.status = ReviewStatusEnum.PENDING
review.error_message = None
await db.commit()
# Run review in background
background_tasks.add_task(
run_review_task,
review.id,
review.pull_request.pr_number,
review.pull_request.repository_id,
db
)
return {"message": "Review queued"}
@router.get("/stats/dashboard", response_model=ReviewStats)
async def get_review_stats(db: AsyncSession = Depends(get_db)):
"""Get review statistics for dashboard"""
# Total reviews
total_result = await db.execute(select(func.count(Review.id)))
total_reviews = total_result.scalar()
# Active reviews
from app.models.review import ReviewStatusEnum
active_result = await db.execute(
select(func.count(Review.id)).where(
Review.status.in_([
ReviewStatusEnum.PENDING,
ReviewStatusEnum.FETCHING,
ReviewStatusEnum.ANALYZING,
ReviewStatusEnum.COMMENTING
])
)
)
active_reviews = active_result.scalar()
# Completed reviews
completed_result = await db.execute(
select(func.count(Review.id)).where(Review.status == ReviewStatusEnum.COMPLETED)
)
completed_reviews = completed_result.scalar()
# Failed reviews
failed_result = await db.execute(
select(func.count(Review.id)).where(Review.status == ReviewStatusEnum.FAILED)
)
failed_reviews = failed_result.scalar()
# Total comments
comments_result = await db.execute(select(func.count(Comment.id)))
total_comments = comments_result.scalar()
# Average comments per review
avg_comments = total_comments / total_reviews if total_reviews > 0 else 0
return ReviewStats(
total_reviews=total_reviews,
active_reviews=active_reviews,
completed_reviews=completed_reviews,
failed_reviews=failed_reviews,
total_comments=total_comments,
avg_comments_per_review=round(avg_comments, 2)
)

110
backend/app/api/webhooks.py Normal file
View File

@@ -0,0 +1,110 @@
"""Webhook endpoints"""
from fastapi import APIRouter, Depends, Request, Header, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from app.database import get_db
from app.schemas.webhook import GiteaWebhook, GitHubWebhook, BitbucketWebhook
from app.webhooks import handle_gitea_webhook, handle_github_webhook, handle_bitbucket_webhook
from app.agents import ReviewerAgent
router = APIRouter()
async def start_review_task(review_id: int, pr_number: int, repository_id: int):
"""Background task to start review"""
from app.database import async_session_maker
async with async_session_maker() as db:
agent = ReviewerAgent(db)
await agent.run_review(review_id, pr_number, repository_id)
@router.post("/gitea/{repository_id}")
async def gitea_webhook(
repository_id: int,
request: Request,
background_tasks: BackgroundTasks,
x_gitea_signature: Optional[str] = Header(None),
db: AsyncSession = Depends(get_db)
):
"""Handle Gitea webhook"""
raw_payload = await request.body()
webhook_data = GiteaWebhook(**await request.json())
result = await handle_gitea_webhook(
webhook_data=webhook_data,
signature=x_gitea_signature or "",
raw_payload=raw_payload,
db=db
)
# Start review in background if created
if "review_id" in result:
background_tasks.add_task(
start_review_task,
result["review_id"],
webhook_data.number,
repository_id
)
return result
@router.post("/github/{repository_id}")
async def github_webhook(
repository_id: int,
request: Request,
background_tasks: BackgroundTasks,
x_hub_signature_256: Optional[str] = Header(None),
db: AsyncSession = Depends(get_db)
):
"""Handle GitHub webhook"""
raw_payload = await request.body()
webhook_data = GitHubWebhook(**await request.json())
result = await handle_github_webhook(
webhook_data=webhook_data,
signature=x_hub_signature_256 or "",
raw_payload=raw_payload,
db=db
)
# Start review in background if created
if "review_id" in result:
background_tasks.add_task(
start_review_task,
result["review_id"],
webhook_data.number,
repository_id
)
return result
@router.post("/bitbucket/{repository_id}")
async def bitbucket_webhook(
repository_id: int,
request: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Handle Bitbucket webhook"""
webhook_data = BitbucketWebhook(**await request.json())
result = await handle_bitbucket_webhook(
webhook_data=webhook_data,
db=db
)
# Start review in background if created
if "review_id" in result:
background_tasks.add_task(
start_review_task,
result["review_id"],
webhook_data.pullrequest.id,
repository_id
)
return result