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

226
CHANGELOG_ORGANIZATIONS.md Normal file
View File

@ -0,0 +1,226 @@
# 📝 Changelog: Организации и Очередь задач
## 🎉 Добавлено
### Backend
#### Новые модели:
- ✅ `Organization` - модель для организаций (Gitea/GitHub/Bitbucket)
- ✅ `ReviewTask` - модель для очереди задач review
#### Новые API endpoints:
- ✅ `GET /api/organizations` - список организаций
- ✅ `POST /api/organizations` - создать организацию
- ✅ `GET /api/organizations/{id}` - получить организацию
- ✅ `PUT /api/organizations/{id}` - обновить организацию
- ✅ `DELETE /api/organizations/{id}` - удалить организацию
- ✅ `POST /api/organizations/{id}/scan` - сканировать организацию
- ✅ `GET /api/tasks` - список задач в очереди
- ✅ `GET /api/tasks?status=pending` - фильтр по статусу
- ✅ `GET /api/tasks/worker/status` - статус worker'а
- ✅ `POST /api/tasks/{id}/retry` - повторить задачу
- ✅ `DELETE /api/tasks/{id}` - удалить задачу
#### Task Worker:
- ✅ Фоновый worker для последовательной обработки задач
- ✅ Гарантия: только 1 review одновременно
- ✅ Поддержка приоритетов (HIGH > NORMAL > LOW)
- ✅ Автоматический retry при ошибках (до 3 попыток)
- ✅ FIFO при равном приоритете
- ✅ Автозапуск при старте приложения
#### Сканирование организаций:
- ✅ Автоматический поиск всех репозиториев в организации
- ✅ Автоматический поиск всех открытых PR
- ✅ Автоматическое создание задач на review
- ✅ Поддержка Gitea (GitHub и Bitbucket - заглушки)
### Frontend
#### Новые страницы:
- ✅ `/organizations` - управление организациями
- ✅ `/tasks` - мониторинг очереди задач
#### Новые компоненты:
- ✅ `Organizations.tsx` - страница организаций с CRUD
- ✅ `Tasks.tsx` - страница очереди задач с мониторингом
- ✅ `OrganizationForm` - форма создания/редактирования организации
#### Новые типы:
- ✅ `Organization` - тип организации
- ✅ `OrganizationCreate` / `OrganizationUpdate` - типы для CRUD
- ✅ `ReviewTask` - тип задачи review
- ✅ `TaskStatus` / `TaskPriority` - типы статусов и приоритетов
- ✅ `WorkerStatus` - тип статуса worker'а
#### API клиент:
- ✅ `getOrganizations()` - получить список
- ✅ `createOrganization()` - создать
- ✅ `updateOrganization()` - обновить
- ✅ `deleteOrganization()` - удалить
- ✅ `scanOrganization()` - сканировать
- ✅ `getTasks()` - получить задачи
- ✅ `getWorkerStatus()` - статус worker'а
- ✅ `retryTask()` - повторить
- ✅ `deleteTask()` - удалить
#### UI улучшения:
- ✅ Навигация обновлена: добавлены пункты "Организации" и "Очередь"
- ✅ Модальные окна исправлены (был баг с `onCancel`)
- ✅ Real-time обновление статистики задач (каждые 5 секунд)
- ✅ Фильтрация задач по статусу
- ✅ Визуализация статуса worker'а
### Документация
- ✅ `ORGANIZATION_FEATURE.md` - подробная документация
- ✅ `ORGANIZATION_QUICKSTART.md` - быстрый старт
- ✅ `backend/migrate.py` - скрипт миграции БД
---
## 🔧 Исправления
### Frontend
- 🐛 Исправлены ошибки в компонентах `Modal` и `ConfirmModal`
- `onCancel``onClose`
- `message` prop → `title` + `children`
- 🐛 Удален неиспользуемый импорт `ReviewTask` из `organizations.ts`
---
## 🎯 Ключевые фичи
### 1. Организации
```
Добавление целой организации → Автосканирование всех репозиториев
→ Поиск всех PR → Создание задач на review
```
### 2. Очередь задач
```
Задачи в очереди → Worker берет по одной → Выполняет review
→ Публикует комментарии → Переходит к следующей
```
### 3. Гарантии
- ✅ **Один review одновременно** - не перегружаем Ollama
- ✅ **Приоритеты** - важные PR обрабатываются быстрее
- ✅ **Retry** - автоматические повторы при ошибках
- ✅ **Мониторинг** - видно состояние очереди и worker'а
---
## 📊 Статистика изменений
### Backend:
- **Новых файлов**: 5
- `models/organization.py`
- `models/review_task.py`
- `api/organizations.py`
- `api/tasks.py`
- `workers/task_worker.py`
- **Изменено файлов**: 3
- `models/__init__.py`
- `api/__init__.py`
- `main.py`
### Frontend:
- **Новых файлов**: 3
- `pages/Organizations.tsx`
- `pages/Tasks.tsx`
- `types/organization.ts`
- **Изменено файлов**: 3
- `App.tsx`
- `api/organizations.ts`
- `pages/Tasks.tsx` (исправления)
### Документация:
- **Новых файлов**: 3
- `ORGANIZATION_FEATURE.md`
- `ORGANIZATION_QUICKSTART.md`
- `CHANGELOG_ORGANIZATIONS.md`
---
## 🚀 Как использовать
### 1. Миграция БД
```bash
cd backend
./venv/Scripts/python.exe migrate.py
```
### 2. Запуск проекта
```bash
# Windows
start.bat
# Linux/Mac
./start.sh
```
### 3. Добавить организацию
1. Открыть http://localhost:8000
2. Перейти в **🏢 Организации**
3. Нажать ** Добавить организацию**
4. Заполнить форму
5. Нажать **🔍 Сканировать**
### 4. Мониторинг
1. Перейти в **📝 Очередь задач**
2. Следить за прогрессом
3. Видеть статус worker'а
4. Фильтровать по статусу
---
## ✅ Тестирование
### Проверено:
- ✅ Создание организации
- ✅ Сканирование организации (Gitea)
- ✅ Создание задач на review
- ✅ Последовательная обработка задач
- ✅ Worker запускается и останавливается
- ✅ Retry при ошибках
- ✅ Frontend собирается без ошибок
- ✅ Модальные окна работают корректно
### Требует тестирования:
- ⏳ Полный цикл review через очередь
- ⏳ Приоритеты задач
- ⏳ Масштабирование (много задач)
- ⏳ GitHub и Bitbucket организации
---
## 📝 TODO для будущих улучшений
- [ ] Реализовать сканирование GitHub организаций
- [ ] Реализовать сканирование Bitbucket организаций
- [ ] Добавить настройку приоритетов вручную
- [ ] Добавить паузу/возобновление worker'а
- [ ] Добавить планирование сканирований (cron)
- [ ] Добавить webhook для организаций
- [ ] Добавить статистику по организациям
- [ ] Добавить логи worker'а в UI
---
## 🎉 Результат
Теперь можно:
1. ✅ Добавлять организации целиком
2. ✅ Сканировать все репозитории одной кнопкой
3. ✅ Автоматически ставить все PR в очередь
4. ✅ Обрабатывать review последовательно
5. ✅ Мониторить прогресс в реальном времени
**Один клик → Вся организация на review!** 🚀

357
ORGANIZATION_FEATURE.md Normal file
View File

@ -0,0 +1,357 @@
# 🏢 Организации и Очередь Задач
## 📋 Новая функциональность
### 1. **Поддержка организаций** 🏢
Теперь можно добавлять целые организации (не только отдельные репозитории):
- Одна кнопка - сканирование всех репозиториев
- Автоматический поиск всех PR
- Создание задач на review
### 2. **Очередь задач** 📝
Review выполняются **последовательно** (по одному):
- Задачи ставятся в очередь
- Worker обрабатывает по одной
- Приоритеты: HIGH > NORMAL > LOW
- Автоматический retry при ошибках
---
## 🎯 Как использовать
### Добавить организацию:
```bash
POST /api/organizations
{
"name": "inno-js",
"platform": "gitea",
"base_url": "https://git.bro-js.ru",
"api_token": "your_token" // опционально
}
```
### Сканировать организацию:
```bash
POST /api/organizations/1/scan
```
**Что произойдет:**
1. ✅ Найдет все репозитории в организации
2. ✅ Добавит отсутствующие репозитории в БД
3. ✅ Найдет все открытые PR
4. ✅ Создаст задачи на review
5. ✅ Worker автоматически начнет обработку
---
## 📊 Структура БД
### Таблица `organizations`:
```sql
CREATE TABLE organizations (
id INTEGER PRIMARY KEY,
name VARCHAR NOT NULL,
platform VARCHAR NOT NULL, -- gitea/github/bitbucket
base_url VARCHAR NOT NULL,
api_token VARCHAR, -- encrypted, optional
webhook_secret VARCHAR NOT NULL,
config JSON,
is_active BOOLEAN DEFAULT TRUE,
last_scan_at DATETIME,
created_at DATETIME,
updated_at DATETIME
);
```
### Таблица `review_tasks`:
```sql
CREATE TABLE review_tasks (
id INTEGER PRIMARY KEY,
pull_request_id INTEGER NOT NULL,
status VARCHAR NOT NULL, -- pending/in_progress/completed/failed
priority VARCHAR NOT NULL, -- low/normal/high
created_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
error_message VARCHAR,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
FOREIGN KEY (pull_request_id) REFERENCES pull_requests(id)
);
```
---
## 🔄 Task Worker
### Как работает:
```python
while True:
# 1. Проверить есть ли in_progress задача
if has_in_progress_task():
wait()
continue
# 2. Взять следующую pending задачу
task = get_next_pending_task()
if not task:
wait()
continue
# 3. Отметить как in_progress
task.status = "in_progress"
# 4. Выполнить review
try:
execute_review(task)
task.status = "completed"
except:
task.retry_count += 1
if task.retry_count >= task.max_retries:
task.status = "failed"
else:
task.status = "pending" # retry
# 5. Подождать 10 секунд
await asyncio.sleep(10)
```
### Гарантии:
**Один review одновременно** - проверяется наличие `in_progress` задач
**Приоритеты** - высокий приоритет обрабатывается первым
**Retry** - автоматически повторяет при ошибках (до 3 раз)
**FIFO** - старые задачи обрабатываются первыми (при равном приоритете)
---
## 📡 API Endpoints
### Organizations:
```
GET /api/organizations # Список организаций
POST /api/organizations # Создать организацию
GET /api/organizations/{id} # Получить организацию
PUT /api/organizations/{id} # Обновить организацию
DELETE /api/organizations/{id} # Удалить организацию
POST /api/organizations/{id}/scan # Сканировать организацию
```
### Tasks:
```
GET /api/tasks # Список задач
GET /api/tasks?status=pending # Фильтр по статусу
GET /api/tasks/worker/status # Статус worker'а
POST /api/tasks/{id}/retry # Повторить задачу
DELETE /api/tasks/{id} # Удалить задачу
```
---
## 🎨 Пример сканирования
### Request:
```bash
POST /api/organizations/1/scan
```
### Response:
```json
{
"organization_id": 1,
"repositories_found": 15,
"repositories_added": 3,
"pull_requests_found": 8,
"tasks_created": 8,
"errors": []
}
```
### Логи:
```
🔍 Сканирование организации inno-js на https://git.bro-js.ru
Найдено репозиториев: 15
✅ Добавлен репозиторий: inno-js/project-a
✅ Добавлен репозиторий: inno-js/project-b
✅ Добавлен репозиторий: inno-js/project-c
📝 Создана задача для PR #5: Add feature X
📝 Создана задача для PR #12: Fix bug Y
📝 Создана задача для PR #3: Update docs
...
```
---
## 🚀 Worker Logs
```
🚀 Task Worker запущен
================================================================================
📋 Начало обработки задачи #1
PR ID: 5
Приоритет: normal
================================================================================
🤖 Запуск AI review для PR #5
✅ Review завершен для PR #5
✅ Задача #1 успешно завершена
================================================================================
📋 Начало обработки задачи #2
PR ID: 12
Приоритет: normal
================================================================================
🤖 Запуск AI review для PR #12
✅ Review завершен для PR #12
✅ Задача #2 успешно завершена
⏳ Задача #3 уже выполняется
...
```
---
## 🎯 Приоритеты задач
### Установка приоритета:
```python
# В коде при создании задачи
task = ReviewTask(
pull_request_id=pr.id,
priority="high" # "low" / "normal" / "high"
)
```
### Порядок обработки:
1. **HIGH** - критичные PR (hotfix, security)
2. **NORMAL** - обычные PR (по умолчанию)
3. **LOW** - некритичные PR (docs, refactoring)
При равном приоритете - FIFO (First In, First Out)
---
## 🔧 Конфигурация Worker
### В коде:
```python
# backend/app/workers/task_worker.py
class ReviewTaskWorker:
def __init__(self):
self.poll_interval = 10 # секунд между проверками
```
### Изменить интервал:
```python
worker.poll_interval = 5 # проверять каждые 5 секунд
```
---
## 📊 Мониторинг
### Статус worker'а:
```bash
GET /api/tasks/worker/status
```
```json
{
"running": true,
"current_task_id": 15,
"poll_interval": 10
}
```
### Статистика задач:
```bash
GET /api/tasks
```
```json
{
"items": [...],
"total": 50,
"pending": 10,
"in_progress": 1,
"completed": 35,
"failed": 4
}
```
---
## 🛠️ Troubleshooting
### Worker не обрабатывает задачи:
```bash
# Проверить статус
curl http://localhost:8000/api/tasks/worker/status
# Проверить есть ли pending задачи
curl http://localhost:8000/api/tasks?status=pending
# Проверить логи backend
journalctl -u ai-review -f
```
### Задача застряла в in_progress:
```bash
# Вручную сбросить статус в БД
sqlite3 backend/review.db
UPDATE review_tasks SET status='pending', started_at=NULL WHERE id=123;
```
### Retry failed задачи:
```bash
POST /api/tasks/123/retry
```
---
## ✅ Преимущества
1. **Последовательная обработка** - не перегружаем Ollama
2. **Приоритеты** - важные PR быстрее
3. **Автоматический retry** - устойчивость к ошибкам
4. **Масштабируемость** - легко добавлять организации
5. **Мониторинг** - видно состояние очереди
---
## 🎉 Готово!
Теперь можно:
- ✅ Добавлять организации целиком
- ✅ Сканировать все репозитории
- ✅ Автоматически ставить PR в очередь
- ✅ Обрабатывать последовательно
- ✅ Мониторить прогресс
**Попробуйте!** 🚀

256
ORGANIZATION_QUICKSTART.md Normal file
View File

@ -0,0 +1,256 @@
# 🚀 Быстрый старт: Организации и Очередь задач
## 📝 Что добавлено
1. **Организации** - добавление целых организаций (Gitea/GitHub/Bitbucket)
2. **Автосканирование** - поиск всех репозиториев и PR в организации
3. **Очередь задач** - последовательная обработка review (по одному)
4. **Мониторинг** - отслеживание состояния очереди и worker'а
---
## ⚡ Быстрый старт
### 1. Запустить проект
```bash
# Windows
start.bat
# Linux/Mac
./start.sh
```
### 2. Добавить организацию
1. Открыть http://localhost:8000
2. Перейти в раздел **🏢 Организации**
3. Нажать ** Добавить организацию**
4. Заполнить:
- **Название**: `inno-js`
- **Платформа**: `Gitea`
- **Base URL**: `https://git.bro-js.ru`
- **API токен**: (опционально, если не указан - используется master токен)
5. Нажать **Создать**
### 3. Сканировать организацию
1. Найти добавленную организацию
2. Нажать **🔍 Сканировать**
3. Подтвердить
**Результат:**
```
✅ Сканирование завершено!
📦 Репозиториев найдено: 15
Репозиториев добавлено: 3
🔀 PR найдено: 8
📝 Задач создано: 8
```
### 4. Мониторинг очереди
1. Перейти в раздел **📝 Очередь задач**
2. Увидите:
- 🚀 **Worker активен** - статус worker'а
- **Статистика** - всего/ожидает/выполняется/завершено/ошибок
- **Список задач** - каждая задача с PR и статусом
### 5. Наблюдать за работой
Worker автоматически:
1. Берет следующую задачу из очереди
2. Запускает AI review для PR
3. Публикует комментарии
4. Переходит к следующей задаче
**Важно:** Обрабатывается только 1 задача одновременно! ⚡
---
## 🔧 Настройка master токенов
Если не хотите указывать токен для каждой организации:
### backend/.env
```env
# Master tokens (опциональные)
MASTER_GITEA_TOKEN=your_gitea_token_here
MASTER_GITHUB_TOKEN=your_github_token_here
MASTER_BITBUCKET_TOKEN=your_bitbucket_token_here
```
При создании организации просто оставьте поле "API токен" пустым.
---
## 📊 API Endpoints
### Организации
```bash
# Получить список
GET /api/organizations
# Создать
POST /api/organizations
{
"name": "inno-js",
"platform": "gitea",
"base_url": "https://git.bro-js.ru",
"api_token": "optional_token"
}
# Сканировать
POST /api/organizations/{id}/scan
```
### Очередь задач
```bash
# Получить список задач
GET /api/tasks
GET /api/tasks?status=pending
# Статус worker'а
GET /api/tasks/worker/status
# Повторить задачу
POST /api/tasks/{id}/retry
# Удалить задачу
DELETE /api/tasks/{id}
```
---
## 🎯 Как это работает
### 1. Сканирование организации
```
1. Fetch /orgs/{name}/repos → Получить все репозитории
2. For each repo:
- Проверить существует ли в БД
- Если нет → добавить
- Fetch /repos/{owner}/{repo}/pulls?state=open
- For each PR:
- Проверить существует ли в БД
- Если нет → добавить
- Создать ReviewTask(status=pending)
```
### 2. Worker обработки
```python
while True:
# Проверить есть ли in_progress задача
if has_in_progress_task():
wait(10 seconds)
continue
# Взять следующую pending задачу (по приоритету)
task = get_next_pending_task()
if not task:
wait(10 seconds)
continue
# Отметить как in_progress
task.status = "in_progress"
# Выполнить review
try:
run_ai_review(task.pull_request)
task.status = "completed"
except Exception as e:
task.retry_count += 1
if task.retry_count >= 3:
task.status = "failed"
else:
task.status = "pending" # retry
# Подождать 10 секунд перед следующей
wait(10 seconds)
```
### 3. Гарантии
**Только 1 review одновременно**
**Приоритеты**: HIGH → NORMAL → LOW
**Автоматический retry** (до 3 попыток)
**FIFO** при равном приоритете
---
## 🐛 Troubleshooting
### Worker не обрабатывает задачи
```bash
# Проверить статус
curl http://localhost:8000/api/tasks/worker/status
# Должно быть:
{"running": true, "current_task_id": null, "poll_interval": 10}
```
### Задача застряла в "in_progress"
```bash
# Вручную сбросить через API
POST /api/tasks/{id}/retry
```
### Все задачи failed
Проверить:
1. ✅ Ollama запущена (`ollama list`)
2. ✅ Модель скачана (`ollama pull mistral:7b`)
3. ✅ API токены правильные
4. ✅ Репозитории доступны
---
## 📈 Мониторинг
### Frontend UI
- **🏢 Организации** - управление организациями, сканирование
- **📝 Очередь задач** - мониторинг задач, статус worker'а
- **🔍 Reviews** - результаты review, комментарии
### Логи
```bash
# Backend логи (Windows)
# Смотреть в консоли где запущен start.bat
# Backend логи (Linux/systemd)
journalctl -u ai-review -f
```
---
## ✅ Готово!
Теперь вы можете:
1. Добавлять организации
2. 🔍 Сканировать репозитории и PR
3. 📝 Следить за очередью задач
4. 🤖 AI автоматически проводит review
5. 💬 Комментарии публикуются в PR
**Один клик** → Все репозитории и PR организации в review! 🚀
---
## 📚 Подробная документация
- [ORGANIZATION_FEATURE.md](ORGANIZATION_FEATURE.md) - Полная документация
- [README.md](README.md) - Общая информация о проекте
- [API Docs](http://localhost:8000/docs) - Swagger UI

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())

View File

@ -4,6 +4,8 @@ import Dashboard from './pages/Dashboard';
import Repositories from './pages/Repositories';
import Reviews from './pages/Reviews';
import ReviewDetail from './pages/ReviewDetail';
import Organizations from './pages/Organizations';
import Tasks from './pages/Tasks';
import WebSocketStatus from './components/WebSocketStatus';
const queryClient = new QueryClient({
@ -20,7 +22,9 @@ function Navigation() {
const navLinks = [
{ path: '/', label: 'Дашборд', icon: '📊' },
{ path: '/organizations', label: 'Организации', icon: '🏢' },
{ path: '/repositories', label: 'Репозитории', icon: '📁' },
{ path: '/tasks', label: 'Очередь', icon: '📝' },
{ path: '/reviews', label: 'Ревью', icon: '🔍' },
];
@ -67,7 +71,9 @@ function AppContent() {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/organizations" element={<Organizations />} />
<Route path="/repositories" element={<Repositories />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/reviews" element={<Reviews />} />
<Route path="/reviews/:id" element={<ReviewDetail />} />
</Routes>

View File

@ -0,0 +1,108 @@
/**
* Organization API client
*/
import {
Organization,
OrganizationCreate,
OrganizationUpdate,
OrganizationScanResult,
TaskListResponse,
TaskStatus,
WorkerStatus,
} from '../types/organization';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
// Organizations
export async function getOrganizations(skip = 0, limit = 100): Promise<{ items: Organization[]; total: number }> {
const response = await fetch(`${API_BASE_URL}/organizations?skip=${skip}&limit=${limit}`);
if (!response.ok) throw new Error('Failed to fetch organizations');
return response.json();
}
export async function getOrganization(id: number): Promise<Organization> {
const response = await fetch(`${API_BASE_URL}/organizations/${id}`);
if (!response.ok) throw new Error('Failed to fetch organization');
return response.json();
}
export async function createOrganization(data: OrganizationCreate): Promise<Organization> {
const response = await fetch(`${API_BASE_URL}/organizations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create organization');
}
return response.json();
}
export async function updateOrganization(id: number, data: OrganizationUpdate): Promise<Organization> {
const response = await fetch(`${API_BASE_URL}/organizations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to update organization');
}
return response.json();
}
export async function deleteOrganization(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/organizations/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete organization');
}
export async function scanOrganization(id: number): Promise<OrganizationScanResult> {
const response = await fetch(`${API_BASE_URL}/organizations/${id}/scan`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to scan organization');
}
return response.json();
}
// Tasks
export async function getTasks(status?: TaskStatus, skip = 0, limit = 100): Promise<TaskListResponse> {
const params = new URLSearchParams({ skip: skip.toString(), limit: limit.toString() });
if (status) params.append('status', status);
const response = await fetch(`${API_BASE_URL}/tasks?${params}`);
if (!response.ok) throw new Error('Failed to fetch tasks');
return response.json();
}
export async function getWorkerStatus(): Promise<WorkerStatus> {
const response = await fetch(`${API_BASE_URL}/tasks/worker/status`);
if (!response.ok) throw new Error('Failed to fetch worker status');
return response.json();
}
export async function retryTask(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/tasks/${id}/retry`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to retry task');
}
}
export async function deleteTask(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/tasks/${id}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete task');
}

View File

@ -0,0 +1,380 @@
/**
* Organizations page
*/
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getOrganizations,
createOrganization,
updateOrganization,
deleteOrganization,
scanOrganization,
} from '../api/organizations';
import { Organization, OrganizationCreate, OrganizationPlatform } from '../types/organization';
import { Modal, ConfirmModal } from '../components/Modal';
export default function Organizations() {
const queryClient = useQueryClient();
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingOrg, setEditingOrg] = useState<Organization | null>(null);
// Modal states
const [modalMessage, setModalMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [confirmAction, setConfirmAction] = useState<(() => void) | null>(null);
const [confirmMessage, setConfirmMessage] = useState('');
const [showConfirm, setShowConfirm] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['organizations'],
queryFn: () => getOrganizations(),
});
const createMutation = useMutation({
mutationFn: createOrganization,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations'] });
setIsFormOpen(false);
setModalMessage('✅ Организация успешно добавлена');
setShowModal(true);
},
onError: (error: Error) => {
setModalMessage(`❌ Ошибка: ${error.message}`);
setShowModal(true);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: any }) => updateOrganization(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations'] });
setEditingOrg(null);
setModalMessage('✅ Организация успешно обновлена');
setShowModal(true);
},
onError: (error: Error) => {
setModalMessage(`❌ Ошибка: ${error.message}`);
setShowModal(true);
},
});
const deleteMutation = useMutation({
mutationFn: deleteOrganization,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations'] });
setModalMessage('✅ Организация успешно удалена');
setShowModal(true);
},
onError: (error: Error) => {
setModalMessage(`❌ Ошибка: ${error.message}`);
setShowModal(true);
},
});
const scanMutation = useMutation({
mutationFn: scanOrganization,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['organizations'] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
let message = `✅ Сканирование завершено!\n\n`;
message += `📦 Репозиториев найдено: ${result.repositories_found}\n`;
message += ` Репозиториев добавлено: ${result.repositories_added}\n`;
message += `🔀 PR найдено: ${result.pull_requests_found}\n`;
message += `📝 Задач создано: ${result.tasks_created}`;
if (result.errors.length > 0) {
message += `\n\n⚠ Ошибки:\n${result.errors.join('\n')}`;
}
setModalMessage(message);
setShowModal(true);
},
onError: (error: Error) => {
setModalMessage(`❌ Ошибка сканирования: ${error.message}`);
setShowModal(true);
},
});
const handleDelete = (org: Organization) => {
setConfirmMessage(`Вы уверены, что хотите удалить организацию "${org.name}"?`);
setConfirmAction(() => () => deleteMutation.mutate(org.id));
setShowConfirm(true);
};
const handleScan = (org: Organization) => {
setConfirmMessage(`Начать сканирование организации "${org.name}"?\n\удут найдены все репозитории и PR.`);
setConfirmAction(() => () => scanMutation.mutate(org.id));
setShowConfirm(true);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Загрузка...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Организации</h1>
<p className="text-gray-600 mt-1">
Управление организациями и автоматическое сканирование репозиториев
</p>
</div>
<button
onClick={() => setIsFormOpen(true)}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Добавить организацию
</button>
</div>
{/* Organizations list */}
<div className="grid gap-4">
{data?.items.map((org) => (
<div
key={org.id}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold text-gray-900">{org.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
org.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{org.is_active ? 'Активна' : 'Неактивна'}
</span>
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
{org.platform.toUpperCase()}
</span>
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
<div>🌐 {org.base_url}</div>
{org.last_scan_at && (
<div>
🔍 Последнее сканирование:{' '}
{new Date(org.last_scan_at).toLocaleString('ru-RU')}
</div>
)}
</div>
<div className="mt-3 text-xs text-gray-500">
Webhook: <code className="bg-gray-100 px-2 py-1 rounded">{org.webhook_url}</code>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleScan(org)}
disabled={scanMutation.isPending}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50"
>
🔍 Сканировать
</button>
<button
onClick={() => setEditingOrg(org)}
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
Изменить
</button>
<button
onClick={() => handleDelete(org)}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
>
🗑 Удалить
</button>
</div>
</div>
</div>
))}
</div>
{data?.items.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<p className="text-gray-500">Нет организаций</p>
<button
onClick={() => setIsFormOpen(true)}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Добавить первую организацию
</button>
</div>
)}
{/* Create Form Modal */}
{isFormOpen && (
<OrganizationForm
onSubmit={(data) => createMutation.mutate(data)}
onCancel={() => setIsFormOpen(false)}
isSubmitting={createMutation.isPending}
/>
)}
{/* Edit Form Modal */}
{editingOrg && (
<OrganizationForm
organization={editingOrg}
onSubmit={(data) => updateMutation.mutate({ id: editingOrg.id, data })}
onCancel={() => setEditingOrg(null)}
isSubmitting={updateMutation.isPending}
/>
)}
{/* Modals */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
>
<p className="text-gray-700 whitespace-pre-line">{modalMessage}</p>
</Modal>
<ConfirmModal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={() => {
if (confirmAction) confirmAction();
setShowConfirm(false);
}}
title="Подтверждение"
message={confirmMessage}
/>
</div>
);
}
// Organization Form Component
function OrganizationForm({
organization,
onSubmit,
onCancel,
isSubmitting,
}: {
organization?: Organization;
onSubmit: (data: OrganizationCreate) => void;
onCancel: () => void;
isSubmitting: boolean;
}) {
const [formData, setFormData] = useState<OrganizationCreate>({
name: organization?.name || '',
platform: (organization?.platform as OrganizationPlatform) || 'gitea',
base_url: organization?.base_url || '',
api_token: '',
webhook_secret: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">
{organization ? 'Редактировать организацию' : 'Новая организация'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название организации *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="inno-js"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Платформа *
</label>
<select
value={formData.platform}
onChange={(e) => setFormData({ ...formData, platform: e.target.value as OrganizationPlatform })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
<option value="bitbucket">Bitbucket</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Base URL *
</label>
<input
type="url"
required
value={formData.base_url}
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="https://git.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API токен
</label>
<input
type="password"
value={formData.api_token}
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Опционально (используется master токен если не указан)"
/>
<p className="text-xs text-gray-500 mt-1">
💡 Если не указан, будет использован master токен из конфигурации сервера
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Webhook Secret
</label>
<input
type="text"
value={formData.webhook_secret}
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Опционально (генерируется автоматически)"
/>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Сохранение...' : organization ? 'Сохранить' : 'Создать'}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,323 @@
/**
* Tasks page - Task Queue monitoring
*/
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTasks, getWorkerStatus, retryTask, deleteTask } from '../api/organizations';
import { TaskStatus } from '../types/organization';
import { Modal, ConfirmModal } from '../components/Modal';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
export default function Tasks() {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<TaskStatus | undefined>();
// Modal states
const [modalMessage, setModalMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [confirmAction, setConfirmAction] = useState<(() => void) | null>(null);
const [confirmMessage, setConfirmMessage] = useState('');
const [showConfirm, setShowConfirm] = useState(false);
const { data: tasksData, isLoading } = useQuery({
queryKey: ['tasks', statusFilter],
queryFn: () => getTasks(statusFilter),
refetchInterval: 5000, // Обновление каждые 5 секунд
});
const { data: workerStatus } = useQuery({
queryKey: ['workerStatus'],
queryFn: getWorkerStatus,
refetchInterval: 5000,
});
const retryMutation = useMutation({
mutationFn: retryTask,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setModalMessage('✅ Задача поставлена в очередь повторно');
setShowModal(true);
},
onError: (error: Error) => {
setModalMessage(`❌ Ошибка: ${error.message}`);
setShowModal(true);
},
});
const deleteMutation = useMutation({
mutationFn: deleteTask,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
setModalMessage('✅ Задача удалена');
setShowModal(true);
},
onError: (error: Error) => {
setModalMessage(`❌ Ошибка: ${error.message}`);
setShowModal(true);
},
});
const handleRetry = (taskId: number) => {
setConfirmMessage('Повторить выполнение задачи?');
setConfirmAction(() => () => retryMutation.mutate(taskId));
setShowConfirm(true);
};
const handleDelete = (taskId: number) => {
setConfirmMessage('Удалить задачу из очереди?');
setConfirmAction(() => () => deleteMutation.mutate(taskId));
setShowConfirm(true);
};
const getStatusColor = (status: TaskStatus) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'in_progress':
return 'bg-blue-100 text-blue-800';
case 'completed':
return 'bg-green-100 text-green-800';
case 'failed':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusLabel = (status: TaskStatus) => {
switch (status) {
case 'pending':
return '⏳ Ожидает';
case 'in_progress':
return '⚙️ Выполняется';
case 'completed':
return '✅ Завершено';
case 'failed':
return '❌ Ошибка';
default:
return status;
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'bg-red-100 text-red-800';
case 'normal':
return 'bg-gray-100 text-gray-800';
case 'low':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Загрузка...</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">Очередь задач</h1>
<p className="text-gray-600 mt-1">
Мониторинг и управление задачами на review
</p>
</div>
{/* Worker Status */}
{workerStatus && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
<span className="font-medium">
{workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'}
</span>
</div>
<div className="text-sm text-gray-600">
{workerStatus.current_task_id && (
<span>Обрабатывается задача #{workerStatus.current_task_id}</span>
)}
{!workerStatus.current_task_id && workerStatus.running && (
<span>Ожидание задач...</span>
)}
</div>
</div>
</div>
)}
{/* Stats */}
{tasksData && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
statusFilter === undefined ? 'ring-2 ring-indigo-500' : ''
}`}
onClick={() => setStatusFilter(undefined)}
>
<div className="text-2xl font-bold text-gray-900">{tasksData.total}</div>
<div className="text-sm text-gray-600">Всего</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
statusFilter === 'pending' ? 'ring-2 ring-yellow-500' : ''
}`}
onClick={() => setStatusFilter('pending')}
>
<div className="text-2xl font-bold text-yellow-600">{tasksData.pending}</div>
<div className="text-sm text-gray-600">Ожидает</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
statusFilter === 'in_progress' ? 'ring-2 ring-blue-500' : ''
}`}
onClick={() => setStatusFilter('in_progress')}
>
<div className="text-2xl font-bold text-blue-600">{tasksData.in_progress}</div>
<div className="text-sm text-gray-600">Выполняется</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
statusFilter === 'completed' ? 'ring-2 ring-green-500' : ''
}`}
onClick={() => setStatusFilter('completed')}
>
<div className="text-2xl font-bold text-green-600">{tasksData.completed}</div>
<div className="text-sm text-gray-600">Завершено</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
statusFilter === 'failed' ? 'ring-2 ring-red-500' : ''
}`}
onClick={() => setStatusFilter('failed')}
>
<div className="text-2xl font-bold text-red-600">{tasksData.failed}</div>
<div className="text-sm text-gray-600">Ошибок</div>
</div>
</div>
)}
{/* Tasks List */}
<div className="space-y-3">
{tasksData?.items.map((task) => (
<div
key={task.id}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<span className="text-lg font-mono font-semibold text-gray-900">
#{task.id}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(task.status)}`}>
{getStatusLabel(task.status)}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${getPriorityColor(task.priority)}`}>
{task.priority === 'high' && '🔴 Высокий'}
{task.priority === 'normal' && '⚪ Обычный'}
{task.priority === 'low' && '🟢 Низкий'}
</span>
</div>
<div className="mt-2 space-y-1">
<div className="text-sm">
<span className="text-gray-600">PR:</span>{' '}
<span className="font-medium">#{task.pr_number}</span>{' '}
<span className="text-gray-700">{task.pr_title}</span>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<span>
Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })}
</span>
{task.started_at && (
<span>
Начато: {formatDistanceToNow(new Date(task.started_at), { addSuffix: true, locale: ru })}
</span>
)}
{task.completed_at && (
<span>
Завершено: {formatDistanceToNow(new Date(task.completed_at), { addSuffix: true, locale: ru })}
</span>
)}
</div>
{task.error_message && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700">
<strong>Ошибка:</strong> {task.error_message}
</div>
)}
{task.retry_count > 0 && (
<div className="text-xs text-gray-500">
Попыток: {task.retry_count} / {task.max_retries}
</div>
)}
</div>
</div>
<div className="flex gap-2 ml-4">
{(task.status === 'failed' || task.status === 'completed') && (
<button
onClick={() => handleRetry(task.id)}
disabled={retryMutation.isPending}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
>
🔄 Повторить
</button>
)}
{task.status !== 'in_progress' && (
<button
onClick={() => handleDelete(task.id)}
disabled={deleteMutation.isPending}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors disabled:opacity-50"
>
🗑 Удалить
</button>
)}
</div>
</div>
</div>
))}
</div>
{tasksData?.items.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<p className="text-gray-500">
{statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'}
</p>
</div>
)}
{/* Modals */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
>
<p className="text-gray-700 whitespace-pre-line">{modalMessage}</p>
</Modal>
<ConfirmModal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={() => {
if (confirmAction) confirmAction();
setShowConfirm(false);
}}
title="Подтверждение"
message={confirmMessage}
/>
</div>
);
}

View File

@ -0,0 +1,78 @@
/**
* Organization types for frontend
*/
export type OrganizationPlatform = 'gitea' | 'github' | 'bitbucket';
export interface Organization {
id: number;
name: string;
platform: OrganizationPlatform;
base_url: string;
is_active: boolean;
last_scan_at: string | null;
created_at: string;
updated_at: string;
webhook_url: string;
}
export interface OrganizationCreate {
name: string;
platform: OrganizationPlatform;
base_url: string;
api_token?: string;
webhook_secret?: string;
config?: Record<string, any>;
}
export interface OrganizationUpdate {
name?: string;
base_url?: string;
api_token?: string;
webhook_secret?: string;
config?: Record<string, any>;
is_active?: boolean;
}
export interface OrganizationScanResult {
organization_id: number;
repositories_found: number;
repositories_added: number;
pull_requests_found: number;
tasks_created: number;
errors: string[];
}
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export type TaskPriority = 'low' | 'normal' | 'high';
export interface ReviewTask {
id: number;
pull_request_id: number;
pr_number: number | null;
pr_title: string | null;
status: TaskStatus;
priority: TaskPriority;
created_at: string;
started_at: string | null;
completed_at: string | null;
error_message: string | null;
retry_count: number;
max_retries: number;
}
export interface TaskListResponse {
items: ReviewTask[];
total: number;
pending: number;
in_progress: number;
completed: number;
failed: number;
}
export interface WorkerStatus {
running: boolean;
current_task_id: number | null;
poll_interval: number;
}