Add organization and task queue features

- Introduced new models for `Organization` and `ReviewTask` to manage organizations and review tasks.
- Implemented API endpoints for CRUD operations on organizations and tasks, including scanning organizations for repositories and PRs.
- Developed a background worker for sequential processing of review tasks with priority handling and automatic retries.
- Created frontend components for managing organizations and monitoring task queues, including real-time updates and filtering options.
- Added comprehensive documentation for organization features and quick start guides.
- Fixed UI issues and improved navigation for better user experience.
This commit is contained in:
Primakov Alexandr Alexandrovich
2025-10-13 00:10:04 +03:00
parent 70889421ea
commit 6ae2d0d8ec
18 changed files with 2725 additions and 3 deletions

View File

@@ -2,13 +2,15 @@
from fastapi import APIRouter
from app.api import repositories, reviews, webhooks
from app.api import repositories, reviews, webhooks, organizations, tasks
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"])
api_router.include_router(organizations.router, prefix="/organizations", tags=["organizations"])
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
__all__ = ["api_router"]

View File

@@ -0,0 +1,404 @@
"""Organizations API endpoints"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List
import secrets
from app.database import get_db
from app.models import Organization, Repository, PullRequest, ReviewTask
from app.schemas.organization import (
OrganizationCreate,
OrganizationUpdate,
OrganizationResponse,
OrganizationList,
OrganizationScanResult
)
from app.utils import encrypt_token, decrypt_token
from app.config import settings
from app.services.gitea import GiteaService
from app.services.github import GitHubService
from app.services.bitbucket import BitbucketService
router = APIRouter()
@router.get("", response_model=OrganizationList)
async def get_organizations(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db)
):
"""Get all organizations"""
# Count total
count_query = select(func.count(Organization.id))
count_result = await db.execute(count_query)
total = count_result.scalar()
# Get organizations
query = select(Organization).order_by(Organization.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
organizations = result.scalars().all()
# Add webhook URLs
items = []
for org in organizations:
webhook_url = f"http://{settings.host}:{settings.port}/api/webhooks/{org.platform.value}/org/{org.id}"
items.append(OrganizationResponse(
**org.__dict__,
webhook_url=webhook_url
))
return OrganizationList(items=items, total=total)
@router.post("", response_model=OrganizationResponse)
async def create_organization(
organization: OrganizationCreate,
db: AsyncSession = Depends(get_db)
):
"""Create a new organization"""
# Generate webhook secret if not provided
webhook_secret = organization.webhook_secret or secrets.token_urlsafe(32)
# Encrypt API token (если указан)
encrypted_token = encrypt_token(organization.api_token) if organization.api_token else None
# Create organization
db_organization = Organization(
name=organization.name,
platform=organization.platform,
base_url=organization.base_url.rstrip('/'),
api_token=encrypted_token,
webhook_secret=webhook_secret,
config=organization.config or {}
)
db.add(db_organization)
await db.commit()
await db.refresh(db_organization)
# Prepare response
webhook_url = f"http://{settings.host}:{settings.port}/api/webhooks/{db_organization.platform.value}/org/{db_organization.id}"
return OrganizationResponse(
**db_organization.__dict__,
webhook_url=webhook_url
)
@router.get("/{organization_id}", response_model=OrganizationResponse)
async def get_organization(
organization_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get organization by ID"""
result = await db.execute(
select(Organization).where(Organization.id == organization_id)
)
organization = result.scalar_one_or_none()
if not organization:
raise HTTPException(status_code=404, detail="Organization not found")
webhook_url = f"http://{settings.host}:{settings.port}/api/webhooks/{organization.platform.value}/org/{organization.id}"
return OrganizationResponse(
**organization.__dict__,
webhook_url=webhook_url
)
@router.put("/{organization_id}", response_model=OrganizationResponse)
async def update_organization(
organization_id: int,
organization_update: OrganizationUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update organization"""
result = await db.execute(
select(Organization).where(Organization.id == organization_id)
)
organization = result.scalar_one_or_none()
if not organization:
raise HTTPException(status_code=404, detail="Organization not found")
# Update fields
update_data = organization_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(organization, field, value)
await db.commit()
await db.refresh(organization)
webhook_url = f"http://{settings.host}:{settings.port}/api/webhooks/{organization.platform.value}/org/{organization.id}"
return OrganizationResponse(
**organization.__dict__,
webhook_url=webhook_url
)
@router.delete("/{organization_id}")
async def delete_organization(
organization_id: int,
db: AsyncSession = Depends(get_db)
):
"""Delete organization"""
result = await db.execute(
select(Organization).where(Organization.id == organization_id)
)
organization = result.scalar_one_or_none()
if not organization:
raise HTTPException(status_code=404, detail="Organization not found")
await db.delete(organization)
await db.commit()
return {"message": "Organization deleted successfully"}
@router.post("/{organization_id}/scan", response_model=OrganizationScanResult)
async def scan_organization(
organization_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Scan organization for repositories and PRs"""
# Get organization
result = await db.execute(
select(Organization).where(Organization.id == organization_id)
)
organization = result.scalar_one_or_none()
if not organization:
raise HTTPException(status_code=404, detail="Organization not found")
# Get API token
if organization.api_token:
try:
api_token = decrypt_token(organization.api_token)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
else:
# Use master token
platform = organization.platform.value.lower()
if platform == "gitea":
api_token = settings.master_gitea_token
elif platform == "github":
api_token = settings.master_github_token
elif platform == "bitbucket":
api_token = settings.master_bitbucket_token
else:
raise HTTPException(status_code=400, detail=f"Unsupported platform: {organization.platform}")
if not api_token:
raise HTTPException(
status_code=400,
detail=f"API token not set and master token for {platform} not configured"
)
# Start scan
scan_result = OrganizationScanResult(
organization_id=organization_id,
repositories_found=0,
repositories_added=0,
pull_requests_found=0,
tasks_created=0,
errors=[]
)
try:
if organization.platform.value == "gitea":
await _scan_gitea_organization(organization, api_token, scan_result, db)
elif organization.platform.value == "github":
await _scan_github_organization(organization, api_token, scan_result, db)
elif organization.platform.value == "bitbucket":
await _scan_bitbucket_organization(organization, api_token, scan_result, db)
else:
raise HTTPException(status_code=400, detail=f"Unsupported platform: {organization.platform}")
# Update last scan time
from datetime import datetime
organization.last_scan_at = datetime.utcnow()
await db.commit()
except Exception as e:
scan_result.errors.append(str(e))
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
return scan_result
async def _scan_gitea_organization(
organization: Organization,
api_token: str,
scan_result: OrganizationScanResult,
db: AsyncSession
):
"""Scan Gitea organization for repositories and PRs"""
import httpx
headers = {
"Authorization": f"token {api_token}",
"Content-Type": "application/json"
}
# Get all repositories in organization
url = f"{organization.base_url}/api/v1/orgs/{organization.name}/repos"
print(f"\n🔍 Сканирование организации {organization.name} на {organization.base_url}")
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
repos = response.json()
scan_result.repositories_found = len(repos)
print(f" Найдено репозиториев: {len(repos)}")
for repo_data in repos:
repo_name = repo_data["name"]
repo_owner = repo_data["owner"]["login"]
repo_url = repo_data["html_url"]
# Check if repository already exists
existing_repo = await db.execute(
select(Repository).where(Repository.url == repo_url)
)
repository = existing_repo.scalar_one_or_none()
if not repository:
# Create new repository
repository = Repository(
name=f"{repo_owner}/{repo_name}",
platform=organization.platform,
url=repo_url,
api_token=organization.api_token, # Use same token as org
webhook_secret=organization.webhook_secret,
config=organization.config
)
db.add(repository)
await db.flush()
scan_result.repositories_added += 1
print(f" ✅ Добавлен репозиторий: {repo_owner}/{repo_name}")
# Scan PRs in this repository
await _scan_repository_prs(
repository,
organization.base_url,
repo_owner,
repo_name,
api_token,
scan_result,
db
)
await db.commit()
async def _scan_github_organization(
organization: Organization,
api_token: str,
scan_result: OrganizationScanResult,
db: AsyncSession
):
"""Scan GitHub organization"""
# TODO: Implement GitHub org scanning
scan_result.errors.append("GitHub organization scanning not yet implemented")
async def _scan_bitbucket_organization(
organization: Organization,
api_token: str,
scan_result: OrganizationScanResult,
db: AsyncSession
):
"""Scan Bitbucket organization"""
# TODO: Implement Bitbucket org scanning
scan_result.errors.append("Bitbucket organization scanning not yet implemented")
async def _scan_repository_prs(
repository: Repository,
base_url: str,
owner: str,
repo: str,
api_token: str,
scan_result: OrganizationScanResult,
db: AsyncSession
):
"""Scan repository for open PRs and create tasks"""
import httpx
headers = {
"Authorization": f"token {api_token}",
"Content-Type": "application/json"
}
# Get open PRs
url = f"{base_url}/api/v1/repos/{owner}/{repo}/pulls?state=open"
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
prs = response.json()
for pr_data in prs:
pr_number = pr_data["number"]
scan_result.pull_requests_found += 1
# Check if PR already exists
existing_pr = await db.execute(
select(PullRequest).where(
PullRequest.repository_id == repository.id,
PullRequest.pr_number == pr_number
)
)
pull_request = existing_pr.scalar_one_or_none()
if not pull_request:
# Create new PR
pull_request = PullRequest(
repository_id=repository.id,
pr_number=pr_number,
title=pr_data["title"],
author=pr_data["user"]["login"],
source_branch=pr_data["head"]["ref"],
target_branch=pr_data["base"]["ref"],
url=pr_data["html_url"],
status="OPEN"
)
db.add(pull_request)
await db.flush()
# Check if task already exists for this PR
existing_task = await db.execute(
select(ReviewTask).where(
ReviewTask.pull_request_id == pull_request.id,
ReviewTask.status.in_(["pending", "in_progress"])
)
)
task = existing_task.scalar_one_or_none()
if not task:
# Create review task
task = ReviewTask(
pull_request_id=pull_request.id,
priority="normal"
)
db.add(task)
scan_result.tasks_created += 1
print(f" 📝 Создана задача для PR #{pr_number}: {pr_data['title']}")

197
backend/app/api/tasks.py Normal file
View File

@@ -0,0 +1,197 @@
"""Task Queue API endpoints"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List
from pydantic import BaseModel
from datetime import datetime
from app.database import get_db
from app.models import ReviewTask, PullRequest
from app.models.review_task import TaskStatusEnum, TaskPriorityEnum
from app.workers.task_worker import get_worker
router = APIRouter()
class TaskResponse(BaseModel):
"""Task response schema"""
id: int
pull_request_id: int
pr_number: int | None
pr_title: str | None
status: TaskStatusEnum
priority: TaskPriorityEnum
created_at: datetime
started_at: datetime | None
completed_at: datetime | None
error_message: str | None
retry_count: int
max_retries: int
class Config:
from_attributes = True
class TaskListResponse(BaseModel):
"""Task list response"""
items: List[TaskResponse]
total: int
pending: int
in_progress: int
completed: int
failed: int
class WorkerStatusResponse(BaseModel):
"""Worker status response"""
running: bool
current_task_id: int | None
poll_interval: int
@router.get("", response_model=TaskListResponse)
async def get_tasks(
status: TaskStatusEnum | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db)
):
"""Get all tasks"""
# Count total
count_query = select(func.count(ReviewTask.id))
if status:
count_query = count_query.where(ReviewTask.status == status)
count_result = await db.execute(count_query)
total = count_result.scalar()
# Count by status
pending_count = await db.execute(
select(func.count(ReviewTask.id)).where(ReviewTask.status == TaskStatusEnum.PENDING)
)
in_progress_count = await db.execute(
select(func.count(ReviewTask.id)).where(ReviewTask.status == TaskStatusEnum.IN_PROGRESS)
)
completed_count = await db.execute(
select(func.count(ReviewTask.id)).where(ReviewTask.status == TaskStatusEnum.COMPLETED)
)
failed_count = await db.execute(
select(func.count(ReviewTask.id)).where(ReviewTask.status == TaskStatusEnum.FAILED)
)
# Get tasks with PR info
query = select(ReviewTask, PullRequest).join(
PullRequest, ReviewTask.pull_request_id == PullRequest.id
).order_by(ReviewTask.created_at.desc())
if status:
query = query.where(ReviewTask.status == status)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
rows = result.all()
items = []
for task, pr in rows:
items.append(TaskResponse(
id=task.id,
pull_request_id=task.pull_request_id,
pr_number=pr.pr_number,
pr_title=pr.title,
status=task.status,
priority=task.priority,
created_at=task.created_at,
started_at=task.started_at,
completed_at=task.completed_at,
error_message=task.error_message,
retry_count=task.retry_count,
max_retries=task.max_retries
))
return TaskListResponse(
items=items,
total=total,
pending=pending_count.scalar(),
in_progress=in_progress_count.scalar(),
completed=completed_count.scalar(),
failed=failed_count.scalar()
)
@router.get("/worker/status", response_model=WorkerStatusResponse)
async def get_worker_status():
"""Get worker status"""
worker = get_worker()
if not worker:
return WorkerStatusResponse(
running=False,
current_task_id=None,
poll_interval=0
)
return WorkerStatusResponse(
running=worker.running,
current_task_id=worker.current_task_id,
poll_interval=worker.poll_interval
)
@router.post("/{task_id}/retry")
async def retry_task(
task_id: int,
db: AsyncSession = Depends(get_db)
):
"""Retry failed task"""
result = await db.execute(
select(ReviewTask).where(ReviewTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status not in [TaskStatusEnum.FAILED, TaskStatusEnum.COMPLETED]:
raise HTTPException(
status_code=400,
detail=f"Cannot retry task with status: {task.status}"
)
# Reset task
task.status = TaskStatusEnum.PENDING
task.error_message = None
task.retry_count = 0
task.started_at = None
task.completed_at = None
await db.commit()
return {"message": "Task queued for retry"}
@router.delete("/{task_id}")
async def delete_task(
task_id: int,
db: AsyncSession = Depends(get_db)
):
"""Delete task"""
result = await db.execute(
select(ReviewTask).where(ReviewTask.id == task_id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task.status == TaskStatusEnum.IN_PROGRESS:
raise HTTPException(
status_code=400,
detail="Cannot delete task that is in progress"
)
await db.delete(task)
await db.commit()
return {"message": "Task deleted"}

View File

@@ -45,9 +45,16 @@ async def lifespan(app: FastAPI):
"""Lifespan events"""
# Startup
await init_db()
# Start task worker
from app.workers.task_worker import start_worker
await start_worker()
yield
# Shutdown
pass
from app.workers.task_worker import stop_worker
await stop_worker()
# Create FastAPI app

View File

@@ -4,6 +4,8 @@ from app.models.repository import Repository
from app.models.pull_request import PullRequest
from app.models.review import Review
from app.models.comment import Comment
from app.models.organization import Organization
from app.models.review_task import ReviewTask
__all__ = ["Repository", "PullRequest", "Review", "Comment"]
__all__ = ["Repository", "PullRequest", "Review", "Comment", "Organization", "ReviewTask"]

View File

@@ -0,0 +1,38 @@
"""Organization model"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Enum
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime
import enum
from app.database import Base
class OrganizationPlatformEnum(str, enum.Enum):
"""Git platform types"""
GITEA = "gitea"
GITHUB = "github"
BITBUCKET = "bitbucket"
class Organization(Base):
"""Organization model for tracking Git organizations"""
__tablename__ = "organizations"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False) # Имя организации
platform = Column(Enum(OrganizationPlatformEnum), nullable=False)
base_url = Column(String, nullable=False) # https://git.example.com
api_token = Column(String, nullable=True) # Encrypted, optional (uses master token)
webhook_secret = Column(String, nullable=False)
config = Column(JSON, default=dict) # Review configuration
is_active = Column(Boolean, default=True)
last_scan_at = Column(DateTime, nullable=True) # Когда последний раз сканировали
created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now())
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, server_default=func.now())
def __repr__(self):
return f"<Organization(id={self.id}, name={self.name}, platform={self.platform})>"

View File

@@ -0,0 +1,52 @@
"""Review Task Queue model"""
from sqlalchemy import Column, Integer, String, DateTime, Enum, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from datetime import datetime
import enum
from app.database import Base
class TaskStatusEnum(str, enum.Enum):
"""Task status types"""
PENDING = "pending" # В очереди
IN_PROGRESS = "in_progress" # Выполняется
COMPLETED = "completed" # Завершено
FAILED = "failed" # Ошибка
class TaskPriorityEnum(str, enum.Enum):
"""Task priority types"""
LOW = "low"
NORMAL = "normal"
HIGH = "high"
class ReviewTask(Base):
"""Review task queue for sequential processing"""
__tablename__ = "review_tasks"
id = Column(Integer, primary_key=True, index=True)
pull_request_id = Column(Integer, ForeignKey("pull_requests.id"), nullable=False)
status = Column(Enum(TaskStatusEnum), default=TaskStatusEnum.PENDING, nullable=False, index=True)
priority = Column(Enum(TaskPriorityEnum), default=TaskPriorityEnum.NORMAL, nullable=False)
# Tracking
created_at = Column(DateTime, default=datetime.utcnow, server_default=func.now())
started_at = Column(DateTime, nullable=True) # Когда началась обработка
completed_at = Column(DateTime, nullable=True) # Когда завершилась
error_message = Column(String, nullable=True)
# Retry logic
retry_count = Column(Integer, default=0)
max_retries = Column(Integer, default=3)
# Relationships
pull_request = relationship("PullRequest", backref="review_tasks")
def __repr__(self):
return f"<ReviewTask(id={self.id}, pr_id={self.pull_request_id}, status={self.status})>"

View File

@@ -0,0 +1,60 @@
"""Organization schemas"""
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
from datetime import datetime
from app.models.organization import OrganizationPlatformEnum
class OrganizationBase(BaseModel):
"""Base organization schema"""
name: str = Field(..., description="Organization name")
platform: OrganizationPlatformEnum = Field(..., description="Git platform")
base_url: str = Field(..., description="Base URL (e.g., https://git.example.com)")
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Review configuration")
class OrganizationCreate(OrganizationBase):
"""Schema for creating organization"""
api_token: Optional[str] = Field(None, description="API token (optional, uses master token if not set)")
webhook_secret: Optional[str] = Field(None, description="Webhook secret (generated if not provided)")
class OrganizationUpdate(BaseModel):
"""Schema for updating organization"""
name: Optional[str] = None
base_url: Optional[str] = None
api_token: Optional[str] = None
webhook_secret: Optional[str] = None
config: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None
class OrganizationResponse(OrganizationBase):
"""Schema for organization response"""
id: int
is_active: bool
last_scan_at: Optional[datetime]
created_at: datetime
updated_at: datetime
webhook_url: str = Field(..., description="Webhook URL for this organization")
class Config:
from_attributes = True
class OrganizationList(BaseModel):
"""Schema for organization list response"""
items: List[OrganizationResponse]
total: int
class OrganizationScanResult(BaseModel):
"""Schema for organization scan result"""
organization_id: int
repositories_found: int
repositories_added: int
pull_requests_found: int
tasks_created: int
errors: List[str] = []

View File

@@ -0,0 +1,199 @@
"""Task Worker for sequential review processing"""
import asyncio
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import ReviewTask, PullRequest, Repository, Review
from app.models.review_task import TaskStatusEnum
from app.agents.reviewer import CodeReviewAgent
from app.config import settings
logger = logging.getLogger(__name__)
class ReviewTaskWorker:
"""Worker that processes review tasks sequentially"""
def __init__(self):
self.running = False
self.current_task_id = None
self.poll_interval = 10 # секунд между проверками
async def start(self):
"""Start the worker"""
self.running = True
logger.info("🚀 Task Worker запущен")
while self.running:
try:
await self._process_next_task()
except Exception as e:
logger.error(f"❌ Ошибка в Task Worker: {e}")
import traceback
traceback.print_exc()
# Подождать перед следующей проверкой
await asyncio.sleep(self.poll_interval)
async def stop(self):
"""Stop the worker"""
self.running = False
logger.info("⏹️ Task Worker остановлен")
async def _process_next_task(self):
"""Process next pending task"""
async with AsyncSessionLocal() as db:
# Проверяем есть ли уже выполняющаяся задача
in_progress_query = select(ReviewTask).where(
ReviewTask.status == TaskStatusEnum.IN_PROGRESS
)
result = await db.execute(in_progress_query)
in_progress = result.scalar_one_or_none()
if in_progress:
# Уже есть задача в работе, ждем
logger.debug(f"⏳ Задача #{in_progress.id} уже выполняется")
return
# Берем следующую pending задачу (с приоритетом)
pending_query = select(ReviewTask).where(
ReviewTask.status == TaskStatusEnum.PENDING
).order_by(
ReviewTask.priority.desc(), # HIGH > NORMAL > LOW
ReviewTask.created_at.asc() # Старые первыми
).limit(1)
result = await db.execute(pending_query)
task = result.scalar_one_or_none()
if not task:
# Нет задач в очереди
return
logger.info(f"\n{'='*80}")
logger.info(f"📋 Начало обработки задачи #{task.id}")
logger.info(f" PR ID: {task.pull_request_id}")
logger.info(f" Приоритет: {task.priority}")
logger.info(f"={'='*80}\n")
# Отмечаем задачу как in_progress
task.status = TaskStatusEnum.IN_PROGRESS
task.started_at = datetime.utcnow()
self.current_task_id = task.id
await db.commit()
try:
# Выполняем review
await self._execute_review(task, db)
# Успешно завершено
task.status = TaskStatusEnum.COMPLETED
task.completed_at = datetime.utcnow()
logger.info(f"✅ Задача #{task.id} успешно завершена")
except Exception as e:
# Ошибка при выполнении
task.retry_count += 1
task.error_message = str(e)
if task.retry_count >= task.max_retries:
# Превышено количество попыток
task.status = TaskStatusEnum.FAILED
task.completed_at = datetime.utcnow()
logger.error(f"❌ Задача #{task.id} провалена после {task.retry_count} попыток: {e}")
else:
# Вернуть в pending для повторной попытки
task.status = TaskStatusEnum.PENDING
logger.warning(f"⚠️ Задача #{task.id} вернулась в очередь (попытка {task.retry_count}/{task.max_retries}): {e}")
import traceback
traceback.print_exc()
finally:
self.current_task_id = None
await db.commit()
async def _execute_review(self, task: ReviewTask, db: AsyncSession):
"""Execute review for the task"""
# Get PR with repository
result = await db.execute(
select(PullRequest).where(PullRequest.id == task.pull_request_id)
)
pull_request = result.scalar_one_or_none()
if not pull_request:
raise ValueError(f"PullRequest {task.pull_request_id} not found")
# Get repository
result = await db.execute(
select(Repository).where(Repository.id == pull_request.repository_id)
)
repository = result.scalar_one_or_none()
if not repository:
raise ValueError(f"Repository {pull_request.repository_id} not found")
# Check if review already exists and is not failed
existing_review = await db.execute(
select(Review).where(
Review.pull_request_id == pull_request.id
).order_by(Review.started_at.desc())
)
review = existing_review.scalar_one_or_none()
if review and review.status not in ["failed", "pending"]:
logger.info(f" Review already exists with status: {review.status}")
return
# Create new review if doesn't exist
if not review:
review = Review(
pull_request_id=pull_request.id,
status="pending"
)
db.add(review)
await db.commit()
await db.refresh(review)
# Run review agent
logger.info(f" 🤖 Запуск AI review для PR #{pull_request.pr_number}")
agent = CodeReviewAgent(db)
await agent.review_pull_request(
repository_id=repository.id,
pr_number=pull_request.pr_number,
review_id=review.id
)
logger.info(f" ✅ Review завершен для PR #{pull_request.pr_number}")
# Global worker instance
_worker_instance: ReviewTaskWorker | None = None
async def start_worker():
"""Start the global worker instance"""
global _worker_instance
if _worker_instance is None:
_worker_instance = ReviewTaskWorker()
# Запускаем в фоне
asyncio.create_task(_worker_instance.start())
async def stop_worker():
"""Stop the global worker instance"""
global _worker_instance
if _worker_instance:
await _worker_instance.stop()
_worker_instance = None
def get_worker() -> ReviewTaskWorker | None:
"""Get the current worker instance"""
return _worker_instance

27
backend/migrate.py Normal file
View File

@@ -0,0 +1,27 @@
"""
Простой скрипт для создания таблиц в БД
"""
import asyncio
from app.database import engine, Base
from app.models import Organization, ReviewTask, Repository, PullRequest, Review, Comment
async def create_tables():
"""Создать все таблицы"""
async with engine.begin() as conn:
# Создать все таблицы
await conn.run_sync(Base.metadata.create_all)
print("✅ Таблицы созданы успешно!")
print(" - organizations")
print(" - review_tasks")
print(" - repositories")
print(" - pull_requests")
print(" - reviews")
print(" - comments")
if __name__ == "__main__":
asyncio.run(create_tables())