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 fastapi import APIRouter
|
||||||
|
|
||||||
from app.api import repositories, reviews, webhooks
|
from app.api import repositories, reviews, webhooks, organizations, tasks
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(repositories.router, prefix="/repositories", tags=["repositories"])
|
api_router.include_router(repositories.router, prefix="/repositories", tags=["repositories"])
|
||||||
api_router.include_router(reviews.router, prefix="/reviews", tags=["reviews"])
|
api_router.include_router(reviews.router, prefix="/reviews", tags=["reviews"])
|
||||||
api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
|
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"]
|
__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"""
|
"""Lifespan events"""
|
||||||
# Startup
|
# Startup
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
|
# Start task worker
|
||||||
|
from app.workers.task_worker import start_worker
|
||||||
|
await start_worker()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
pass
|
from app.workers.task_worker import stop_worker
|
||||||
|
await stop_worker()
|
||||||
|
|
||||||
|
|
||||||
# Create FastAPI app
|
# Create FastAPI app
|
||||||
|
|||||||
@ -4,6 +4,8 @@ from app.models.repository import Repository
|
|||||||
from app.models.pull_request import PullRequest
|
from app.models.pull_request import PullRequest
|
||||||
from app.models.review import Review
|
from app.models.review import Review
|
||||||
from app.models.comment import Comment
|
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 Repositories from './pages/Repositories';
|
||||||
import Reviews from './pages/Reviews';
|
import Reviews from './pages/Reviews';
|
||||||
import ReviewDetail from './pages/ReviewDetail';
|
import ReviewDetail from './pages/ReviewDetail';
|
||||||
|
import Organizations from './pages/Organizations';
|
||||||
|
import Tasks from './pages/Tasks';
|
||||||
import WebSocketStatus from './components/WebSocketStatus';
|
import WebSocketStatus from './components/WebSocketStatus';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@ -20,7 +22,9 @@ function Navigation() {
|
|||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ path: '/', label: 'Дашборд', icon: '📊' },
|
{ path: '/', label: 'Дашборд', icon: '📊' },
|
||||||
|
{ path: '/organizations', label: 'Организации', icon: '🏢' },
|
||||||
{ path: '/repositories', label: 'Репозитории', icon: '📁' },
|
{ path: '/repositories', label: 'Репозитории', icon: '📁' },
|
||||||
|
{ path: '/tasks', label: 'Очередь', icon: '📝' },
|
||||||
{ path: '/reviews', 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">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/organizations" element={<Organizations />} />
|
||||||
<Route path="/repositories" element={<Repositories />} />
|
<Route path="/repositories" element={<Repositories />} />
|
||||||
|
<Route path="/tasks" element={<Tasks />} />
|
||||||
<Route path="/reviews" element={<Reviews />} />
|
<Route path="/reviews" element={<Reviews />} />
|
||||||
<Route path="/reviews/:id" element={<ReviewDetail />} />
|
<Route path="/reviews/:id" element={<ReviewDetail />} />
|
||||||
</Routes>
|
</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