diff --git a/CHANGELOG_ORGANIZATIONS.md b/CHANGELOG_ORGANIZATIONS.md new file mode 100644 index 0000000..7789e12 --- /dev/null +++ b/CHANGELOG_ORGANIZATIONS.md @@ -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!** 🚀 + diff --git a/ORGANIZATION_FEATURE.md b/ORGANIZATION_FEATURE.md new file mode 100644 index 0000000..f31c770 --- /dev/null +++ b/ORGANIZATION_FEATURE.md @@ -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 в очередь +- ✅ Обрабатывать последовательно +- ✅ Мониторить прогресс + +**Попробуйте!** 🚀 + diff --git a/ORGANIZATION_QUICKSTART.md b/ORGANIZATION_QUICKSTART.md new file mode 100644 index 0000000..e3cf9d0 --- /dev/null +++ b/ORGANIZATION_QUICKSTART.md @@ -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 + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index b2fa5ec..e5a53b2 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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"] diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py new file mode 100644 index 0000000..2f866ac --- /dev/null +++ b/backend/app/api/organizations.py @@ -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']}") + diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py new file mode 100644 index 0000000..c63c8f6 --- /dev/null +++ b/backend/app/api/tasks.py @@ -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"} + diff --git a/backend/app/main.py b/backend/app/main.py index 5c73583..bd2c8c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ffaeb6d..213752b 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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"] diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py new file mode 100644 index 0000000..67cad89 --- /dev/null +++ b/backend/app/models/organization.py @@ -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"" + diff --git a/backend/app/models/review_task.py b/backend/app/models/review_task.py new file mode 100644 index 0000000..92b228b --- /dev/null +++ b/backend/app/models/review_task.py @@ -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"" + diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py new file mode 100644 index 0000000..ebc5733 --- /dev/null +++ b/backend/app/schemas/organization.py @@ -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] = [] + diff --git a/backend/app/workers/task_worker.py b/backend/app/workers/task_worker.py new file mode 100644 index 0000000..e12fa07 --- /dev/null +++ b/backend/app/workers/task_worker.py @@ -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 + diff --git a/backend/migrate.py b/backend/migrate.py new file mode 100644 index 0000000..2eb9526 --- /dev/null +++ b/backend/migrate.py @@ -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()) + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b72aecf..59f6ce1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() {
} /> + } /> } /> + } /> } /> } /> diff --git a/frontend/src/api/organizations.ts b/frontend/src/api/organizations.ts new file mode 100644 index 0000000..2eaacb2 --- /dev/null +++ b/frontend/src/api/organizations.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const response = await fetch(`${API_BASE_URL}/tasks/${id}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete task'); +} + diff --git a/frontend/src/pages/Organizations.tsx b/frontend/src/pages/Organizations.tsx new file mode 100644 index 0000000..67ce3e5 --- /dev/null +++ b/frontend/src/pages/Organizations.tsx @@ -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(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\nБудут найдены все репозитории и PR.`); + setConfirmAction(() => () => scanMutation.mutate(org.id)); + setShowConfirm(true); + }; + + if (isLoading) { + return ( +
+
Загрузка...
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Организации

+

+ Управление организациями и автоматическое сканирование репозиториев +

+
+ +
+ + {/* Organizations list */} +
+ {data?.items.map((org) => ( +
+
+
+
+

{org.name}

+ + {org.is_active ? 'Активна' : 'Неактивна'} + + + {org.platform.toUpperCase()} + +
+ +
+
🌐 {org.base_url}
+ {org.last_scan_at && ( +
+ 🔍 Последнее сканирование:{' '} + {new Date(org.last_scan_at).toLocaleString('ru-RU')} +
+ )} +
+ +
+ Webhook: {org.webhook_url} +
+
+ +
+ + + +
+
+
+ ))} +
+ + {data?.items.length === 0 && ( +
+

Нет организаций

+ +
+ )} + + {/* Create Form Modal */} + {isFormOpen && ( + createMutation.mutate(data)} + onCancel={() => setIsFormOpen(false)} + isSubmitting={createMutation.isPending} + /> + )} + + {/* Edit Form Modal */} + {editingOrg && ( + updateMutation.mutate({ id: editingOrg.id, data })} + onCancel={() => setEditingOrg(null)} + isSubmitting={updateMutation.isPending} + /> + )} + + {/* Modals */} + setShowModal(false)} + title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'} + type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'} + > +

{modalMessage}

+
+ setShowConfirm(false)} + onConfirm={() => { + if (confirmAction) confirmAction(); + setShowConfirm(false); + }} + title="Подтверждение" + message={confirmMessage} + /> +
+ ); +} + +// Organization Form Component +function OrganizationForm({ + organization, + onSubmit, + onCancel, + isSubmitting, +}: { + organization?: Organization; + onSubmit: (data: OrganizationCreate) => void; + onCancel: () => void; + isSubmitting: boolean; +}) { + const [formData, setFormData] = useState({ + 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 ( +
+
+

+ {organization ? 'Редактировать организацию' : 'Новая организация'} +

+ +
+
+ + 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" + /> +
+ +
+ + +
+ +
+ + 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" + /> +
+ +
+ + 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 токен если не указан)" + /> +

+ 💡 Если не указан, будет использован master токен из конфигурации сервера +

+
+ +
+ + 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="Опционально (генерируется автоматически)" + /> +
+ +
+ + +
+
+
+
+ ); +} + diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx new file mode 100644 index 0000000..aab6fc7 --- /dev/null +++ b/frontend/src/pages/Tasks.tsx @@ -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(); + + // 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 ( +
+
Загрузка...
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Очередь задач

+

+ Мониторинг и управление задачами на review +

+
+ + {/* Worker Status */} + {workerStatus && ( +
+
+
+
+ + {workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'} + +
+
+ {workerStatus.current_task_id && ( + Обрабатывается задача #{workerStatus.current_task_id} + )} + {!workerStatus.current_task_id && workerStatus.running && ( + Ожидание задач... + )} +
+
+
+ )} + + {/* Stats */} + {tasksData && ( +
+
setStatusFilter(undefined)} + > +
{tasksData.total}
+
Всего
+
+
setStatusFilter('pending')} + > +
{tasksData.pending}
+
Ожидает
+
+
setStatusFilter('in_progress')} + > +
{tasksData.in_progress}
+
Выполняется
+
+
setStatusFilter('completed')} + > +
{tasksData.completed}
+
Завершено
+
+
setStatusFilter('failed')} + > +
{tasksData.failed}
+
Ошибок
+
+
+ )} + + {/* Tasks List */} +
+ {tasksData?.items.map((task) => ( +
+
+
+
+ + #{task.id} + + + {getStatusLabel(task.status)} + + + {task.priority === 'high' && '🔴 Высокий'} + {task.priority === 'normal' && '⚪ Обычный'} + {task.priority === 'low' && '🟢 Низкий'} + +
+ +
+
+ PR:{' '} + #{task.pr_number}{' '} + {task.pr_title} +
+ +
+ + Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })} + + {task.started_at && ( + + Начато: {formatDistanceToNow(new Date(task.started_at), { addSuffix: true, locale: ru })} + + )} + {task.completed_at && ( + + Завершено: {formatDistanceToNow(new Date(task.completed_at), { addSuffix: true, locale: ru })} + + )} +
+ + {task.error_message && ( +
+ Ошибка: {task.error_message} +
+ )} + + {task.retry_count > 0 && ( +
+ Попыток: {task.retry_count} / {task.max_retries} +
+ )} +
+
+ +
+ {(task.status === 'failed' || task.status === 'completed') && ( + + )} + {task.status !== 'in_progress' && ( + + )} +
+
+
+ ))} +
+ + {tasksData?.items.length === 0 && ( +
+

+ {statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'} +

+
+ )} + + {/* Modals */} + setShowModal(false)} + title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'} + type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'} + > +

{modalMessage}

+
+ setShowConfirm(false)} + onConfirm={() => { + if (confirmAction) confirmAction(); + setShowConfirm(false); + }} + title="Подтверждение" + message={confirmMessage} + /> +
+ ); +} + diff --git a/frontend/src/types/organization.ts b/frontend/src/types/organization.ts new file mode 100644 index 0000000..8d7dd35 --- /dev/null +++ b/frontend/src/types/organization.ts @@ -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; +} + +export interface OrganizationUpdate { + name?: string; + base_url?: string; + api_token?: string; + webhook_secret?: string; + config?: Record; + 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; +} +