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:
parent
70889421ea
commit
6ae2d0d8ec
226
CHANGELOG_ORGANIZATIONS.md
Normal file
226
CHANGELOG_ORGANIZATIONS.md
Normal 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
357
ORGANIZATION_FEATURE.md
Normal 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
256
ORGANIZATION_QUICKSTART.md
Normal 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
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
404
backend/app/api/organizations.py
Normal file
404
backend/app/api/organizations.py
Normal 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
197
backend/app/api/tasks.py
Normal 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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
38
backend/app/models/organization.py
Normal file
38
backend/app/models/organization.py
Normal 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})>"
|
||||
|
||||
52
backend/app/models/review_task.py
Normal file
52
backend/app/models/review_task.py
Normal 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})>"
|
||||
|
||||
60
backend/app/schemas/organization.py
Normal file
60
backend/app/schemas/organization.py
Normal 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] = []
|
||||
|
||||
199
backend/app/workers/task_worker.py
Normal file
199
backend/app/workers/task_worker.py
Normal 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
27
backend/migrate.py
Normal 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())
|
||||
|
||||
@ -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>
|
||||
|
||||
108
frontend/src/api/organizations.ts
Normal file
108
frontend/src/api/organizations.ts
Normal 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');
|
||||
}
|
||||
|
||||
380
frontend/src/pages/Organizations.tsx
Normal file
380
frontend/src/pages/Organizations.tsx
Normal 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\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>
|
||||
);
|
||||
}
|
||||
|
||||
323
frontend/src/pages/Tasks.tsx
Normal file
323
frontend/src/pages/Tasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
78
frontend/src/types/organization.ts
Normal file
78
frontend/src/types/organization.ts
Normal 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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user