Initial commit

This commit is contained in:
redlineo committed 2026-09-16 17:00:24 +03:00
commit 08e551c8bc
5 files changed
+964

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# Игнорируем временные файлы
downloads/
__pycache__/
*.pyc
# Игнорируем состояние (история отправленных релизов)
state.json
# Игнорируем логи
sandbox.log
debug.log
# IDE
.vscode/
.idea/
# Системные
.DS_Store
Thumbs.db
+273
View File
@@ -0,0 +1,273 @@
# Интеграция GitFlic → PT Sandbox
PoC-скрипт для автоматической выгрузки релизов из **GitFlic** в **PT Sandbox** на анализ.
## Содержание
- [Как это работает](#как-это-работает)
- [Подготовка: данные и ключи](#подготовка-данные-и-ключи)
- [GitFlic: создание API-токена](#gitflic-создание-api-токена)
- [PT Sandbox: получение API-ключа](#pt-sandbox-получение-api-ключа)
- [Установка и запуск](#установка-и-запуск)
- [Зависимости](#зависимости)
- [Настройка config.yaml](#настройка-configyaml)
- [Запуск](#запуск)
- [Конфигурация (справочник)](#конфигурация-справочник)
- [Файл состояния state.json](#файл-состояния-statejson)
- [Примеры запуска](#примеры-запуска)
---
## Как это работает
Скрипт выполняет один проход (или бесконечный цикл):
1. Загружает конфигурацию из `config.yaml`
2. Загружает историю отправленных релизов из `state.json`
3. Авторизуется в GitFlic по **API-токену** пользователя
4. Получает список проектов (все / по списку / компании / команды)
5. Для каждого проекта получает релизы
6. Фильтрует уже отправленные (ключ: `project_id + release_id + tag + version`)
7. Для каждого нового релиза:
- Скачивает **архив исходного кода** по тегу релиза
- Скачивает **прикреплённые файлы** (assets), если они есть
- Каждый файл отправляет в **PT Sandbox** отдельной задачей
- Помечает релиз как отправленный в `state.json`
8. Логирует всё в консоль и/или файл
9. Если включён цикл — ждёт заданный интервал и повторяет
---
## Подготовка: данные и ключи
### GitFlic: создание API-токена
Скрипт использует **персональный API-токен** пользователя GitFlic с правами на чтение.
**Как создать:**
1. Войдите в GitFlic под своей учётной записью.
2. Перейдите в **Профиль → Настройки профиля → Ключи → API токены**.
_(или откройте ссылку: https://gitflic.ru/settings/tokens)_
3. Нажмите **«Создать токен»**.
4. Задайте имя, например: `sandbox-integration`
5. В разделе **Scopes (области)** отметьте:
- ✅ **api** — полный доступ к API (требуется для чтения проектов и релизов)
- ✅ **read_repository** — чтение репозиториев (для скачивания архива)
6. Нажмите **«Создать»**.
7. **Скопируйте токен сразу** — после закрытия страницы он будет недоступен.
> **Важно:** токен даёт доступ ко всем проектам, в которых ваш пользователь является участником. Храните его в безопасном месте, не добавляйте в публичные репозитории.
В `config.yaml` токен указывается в поле:
```yaml
gitflic:
api_token: "glpat-XXXXXXXXXXXXXXXXXXXX"
### PT Sandbox: получение API-ключа
Способ получения API-ключа зависит от версии PT Sandbox и способа развёртывания.
**Вариант 1. Веб-интерфейс Sandbox**
1. Войдите в веб-интерфейс PT Sandbox.
2. Перейдите в **Настройки → API → Ключи доступа**.
3. Нажмите **«Создать ключ»**, скопируйте сгенерированный ключ.
**Вариант 2. Конфигурационный файл сервера**
Найдите файл конфигурации Sandbox (обычно `config.yml`, `settings.py` или `.env`).
В нём может быть определён параметр наподобие:
```yaml
api_keys:
- "your-api-key-here"
```
**Вариант 3. Обратиться к администратору**
Если Sandbox развёрнут вашей командой — уточните у DevOps/администратора, какой заголовок и значение используются для авторизации.
**Типовые параметры авторизации (могут отличаться):**
| Параметр | Наиболее частое значение |
|---|---|
| Заголовок (`auth_header`) | `X-API-Key` или `Authorization` |
| Передача | Просто ключ в заголовке или `Bearer <key>` |
В `config.yaml`:
```yaml
pt_sandbox:
api_key: "ваш-ключ"
auth_header: "X-API-Key" # или "Authorization"
## Установка и запуск
### Зависимости
- Python 3.9+
- Установка зависимостей:
```bash
pip install -r requirements.txt
```
### Настройка config.yaml
Скопируйте шаблон из `config.yaml` и заполните обязательные поля:
```yaml
gitflic:
base_url: "https://gitflic.ru" # или ваш self-hosted URL
api_token: "glpat-..." # API-токен пользователя (обязательно)
pt_sandbox:
base_url: "http://192.168.1.100:8080" # URL вашей инсталляции Sandbox
api_key: "..." # API-ключ (обязательно)
```
### Запуск
Однократный проход:
```bash
python main.py
```
Циклический запуск (каждый час):
```yaml
# config.yaml
loop:
enabled: true
interval_seconds: 3600
```
```bash
python main.py # работает до Ctrl+C
```
## Конфигурация (справочник)
### `gitflic` — подключение к GitFlic
| Поле | Тип | Обяз. | По умолч. | Описание |
|---|---|---|---|---|
| `base_url` | строка | да | `https://gitflic.ru` | Базовый URL GitFlic |
| `api_token` | строка | **да** | — | API-токен пользователя |
| `project_source` | строка | нет | `all` | `all` / `list` / `company` / `team` |
| `project_list` | массив | при list | `[]` | `["namespace/project"]` |
| `project_ids` | массив | при list | `[]` | `[123, 456]` |
| `company_id` | число | при company | `null` | ID компании |
| `team_id` | число | при team | `null` | ID команды |
| `scan_mode` | строка | нет | `latest` | `latest` или `all` |
| `per_page` | число | нет | `50` | Страница пагинации |
| `download_chunk_size` | число | нет | `8388608` | Чанк скачивания (8 МБ) |
### `pt_sandbox` — подключение к PT Sandbox
| Поле | Тип | Обяз. | По умолч. | Описание |
|---|---|---|---|---|
| `base_url` | строка | да | `http://localhost:8080` | URL Sandbox |
| `upload_endpoint` | строка | нет | `/api/v1/files/upload` | Путь загрузки файла |
| `auth_header` | строка | нет | `X-API-Key` | Заголовок авторизации |
| `api_key` | строка | **да** | — | Ключ API |
| `file_field_name` | строка | нет | `file` | Имя поля multipart |
| `extra_fields` | словарь | нет | `{source: gitflic}` | Доп. поля запроса |
| `timeout_connect` | число | нет | `30` | Таймаут соединения (с) |
| `timeout_read` | число | нет | `300` | Таймаут чтения (с) |
### `state`, `logging`, `loop`
| Раздел | Поле | Тип | По умолч. | Описание |
|---|---|---|---|---|
| `state` | `file_path` | строка | `state.json` | Файл состояния |
| `logging` | `level` | строка | `INFO` | DEBUG, INFO, WARNING, ERROR |
| `logging` | `format` | строка | `%(asctime)s...` | Формат лога |
| `logging` | `file` | строка | — | Файл лога (иначе только консоль) |
| `loop` | `enabled` | bool | `false` | Вкл. бесконечный цикл |
| `loop` | `interval_seconds` | число | `3600` | Интервал (с) |
## Файл состояния `state.json`
Автоматически создаётся при первом запуске и хранит историю отправленных релизов.
```json
{
"sent_releases": {
"1:42:v1.0:v1.0": {
"project_id": 1,
"project_name": "org/my-project",
"release_id": 42,
"release_tag": "v1.0",
"release_version": "v1.0",
"files": [
{
"filename": "source_org_my-project_v1.0.zip",
"type": "source_archive",
"sandbox_task_id": "task-uuid-12345"
}
],
"sent_at": "2026-09-16T12:00:00+00:00"
}
}
}
```
Чтобы **переотправить** релиз — удалите соответствующую запись из `state.json` и перезапустите скрипт.
## Примеры запуска
**1. Однократно, только последний релиз:**
```bash
python main.py
```
**2. В цикле, проверка каждые 30 минут:**
```yaml
# config.yaml
loop:
enabled: true
interval_seconds: 1800
```
```bash
python main.py # Ctrl+C для остановки
```
**3. Только проекты компании ID=5:**
```yaml
gitflic:
project_source: "company"
company_id: 5
```
**4. Конкретные проекты по имени:**
```yaml
gitflic:
project_source: "list"
project_list:
- "my-namespace/app-frontend"
- "my-namespace/app-backend"
project_ids: [101, 203]
```
**5. Детальное логирование:**
```yaml
logging:
level: "DEBUG"
file: "debug.log"
```
```
```
+83
View File
@@ -0,0 +1,83 @@
# ============================================================
# Конфигурация интеграции GitFlic → PT Sandbox
# ============================================================
gitflic:
# Базовый URL GitFlic (облачный или self-hosted)
base_url: "https://gitflic.ru"
# API-токен пользователя (read-only, scope: api, read_repository)
api_token: "" # required
# Как получать проекты:
# "all" — все доступные токену проекты
# "list" — только из списка project_ids/namespace+name ниже
# "company" — проекты внутри указанной компании (company_id)
# "team" — проекты внутри указанной команды (team_id)
project_source: "all"
# Список проектов, если project_source = "list"
# Указывать в формате: ["namespace/project-name"] или ["owner/project-name"]
# Либо project_ids: [123, 456]
project_list: []
project_ids: []
# ID компании или команды (если project_source = "company" или "team")
company_id: null
team_id: null
# Режим сканирования релизов:
# "latest" — только последний (по дате создания) неотправленный релиз
# "all" — все неотправленные релизы (с пагинацией)
scan_mode: "latest"
# Макисмальное количество релизов на страницу (пагинация)
per_page: 50
# Размер чанка для скачивания файлов (в байтах)
download_chunk_size: 8388608 # 8 MB
pt_sandbox:
# Базовый URL PT Sandbox (локальная инсталляция)
base_url: "http://localhost:8080"
# Endpoint для загрузки файла на анализ
upload_endpoint: "/api/v1/files/upload"
# Заголовок и значение для авторизации в Sandbox
auth_header: "X-API-Key"
api_key: "" # required
# Формат multipart-поля с файлом
file_field_name: "file"
# Дополнительные поля, передаваемые с файлом (опционально)
extra_fields:
# Например: source: "gitflic"
source: "gitflic"
# Таймауты (секунды)
timeout_connect: 30
timeout_read: 300 # 5 минут на передачу большого файла
state:
# Путь к файлу состояния (хранит историю отправленных релизов)
file_path: "state.json"
logging:
# Уровень: DEBUG, INFO, WARNING, ERROR
level: "INFO"
# Формат лога
format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
# Вывод в файл (если указан) + в консоль
file: "sandbox.log"
# Главный цикл (периодический запуск)
loop:
# Включить бесконечный цикл
enabled: false
# Интервал между итерациями (секунды). Если enabled: false — однократный проход.
interval_seconds: 3600 # 1 час
+587
View File
@@ -0,0 +1,587 @@
#!/usr/bin/env python3
"""
Интеграция GitFlic → PT Sandbox
=================================
PoC-скрипт для автоматической выгрузки релизов из GitFlic
в PT Sandbox на анализ.
"""
import json
import logging
import sys
import time
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
import yaml
logger = logging.getLogger("sandbox_gitflic")
def setup_logging(cfg: dict) -> None:
"""Настройка логирования из конфигурации."""
log_cfg = cfg.get("logging", {})
level_name = log_cfg.get("level", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
fmt = log_cfg.get("format", "%(asctime)s [%(levelname)s] %(name)s: %(message)s")
handlers: List[logging.Handler] = [logging.StreamHandler(sys.stdout)]
log_file = log_cfg.get("file")
if log_file:
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
logging.basicConfig(level=level, format=fmt, handlers=handlers)
logger.setLevel(level)
def load_config(path: str = "config.yaml") -> dict:
"""Загрузить YAML-конфигурацию."""
path = Path(path)
if not path.exists():
logger.error("Файл конфигурации не найден: %s", path.absolute())
sys.exit(1)
with open(path, "r", encoding="utf-8") as fh:
cfg = yaml.safe_load(fh)
logger.info("Конфигурация загружена из %s", path)
return cfg
# ─────────────────────────────────────────────────────────
# Управление состоянием
# ─────────────────────────────────────────────────────────
def load_state(path: str) -> dict:
"""
Загрузить состояние из JSON-файла.
Формат:
{
"sent_releases": {
"<project_id>:<release_id>:<tag>:<version>": {
"project_id": int,
"project_name": str,
"release_id": int,
"release_tag": str,
"release_version": str,
"files": [{"filename": str, "sandbox_task_id": str|null}],
"sent_at": "2024-01-01T00:00:00Z"
}
}
}
"""
path_obj = Path(path)
if not path_obj.exists():
logger.info("Файл состояния не найден. Начинаем с пустого.")
return {"sent_releases": {}}
try:
with open(path_obj, "r", encoding="utf-8") as fh:
state = json.load(fh)
logger.info("Состояние загружено из %s (%d релизов отправлено)",
path, len(state.get("sent_releases", {})))
return state
except (json.JSONDecodeError, OSError) as exc:
logger.error("Ошибка загрузки состояния из %s: %s. Начинаем с пустого.",
path, exc)
return {"sent_releases": {}}
def save_state(state: dict, path: str) -> None:
"""Сохранить состояние в JSON-файл (атомарно через rename)."""
path_obj = Path(path)
tmp_path = path_obj.with_suffix(".json.tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
tmp_path.replace(path_obj)
logger.debug("Состояние сохранено в %s", path)
except OSError as exc:
logger.error("Ошибка сохранения состояния в %s: %s", path, exc)
def make_release_key(project_id: int, release_id: int,
tag: str, version: str) -> str:
"""Сформировать ключ для идентификации релиза."""
return f"{project_id}:{release_id}:{tag}:{version}"
def is_release_sent(state: dict, project_id: int, release_id: int,
tag: str, version: str) -> bool:
"""Проверить, был ли релиз уже отправлен."""
key = make_release_key(project_id, release_id, tag, version)
return key in state.get("sent_releases", {})
def mark_release_sent(state: dict, project_id: int, project_name: str,
release_id: int, tag: str, version: str,
files: List[Dict[str, Any]]) -> None:
"""Пометить релиз как отправленный."""
key = make_release_key(project_id, release_id, tag, version)
if "sent_releases" not in state:
state["sent_releases"] = {}
state["sent_releases"][key] = {
"project_id": project_id,
"project_name": project_name,
"release_id": release_id,
"release_tag": tag,
"release_version": version,
"files": files,
"sent_at": datetime.now(timezone.utc).isoformat(),
}
logger.info("Релиз помечен как отправленный: %s", key)
# ─────────────────────────────────────────────────────────
# API GitFlic
# ─────────────────────────────────────────────────────────
class GitFlicClient:
"""Клиент для REST API GitFlic."""
def __init__(self, cfg: dict):
gf_cfg = cfg["gitflic"]
self.base_url = gf_cfg["base_url"].rstrip("/")
self.api_token = gf_cfg["api_token"]
self.per_page = gf_cfg.get("per_page", 50)
self.chunk_size = gf_cfg.get("download_chunk_size", 8 * 1024 * 1024)
if not self.api_token:
logger.error("API-токен GitFlic не задан в конфигурации!")
sys.exit(1)
self._session = requests.Session()
self._session.headers.update({
"Authorization": f"Bearer {self.api_token}",
"Accept": "application/json",
"User-Agent": "Sandbox-GitFlic-PoC/1.0",
})
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
"""Выполнить HTTP-запрос к API GitFlic."""
url = f"{self.base_url}{path}"
try:
resp = self._session.request(method, url, **kwargs)
resp.raise_for_status()
return resp
except requests.exceptions.Timeout:
logger.error("Тайм-аут при запросе %s %s", method, url)
raise
except requests.exceptions.HTTPError as exc:
logger.error("HTTP-ошибка %s: %s - %s",
resp.status_code, resp.reason, resp.text[:500])
raise
except requests.exceptions.RequestException as exc:
logger.error("Ошибка запроса %s %s: %s", method, url, exc)
raise
def _paginated_get(self, path: str, params: Optional[dict] = None) -> List[dict]:
"""Получить все страницы пагинированного GET-запроса."""
if params is None:
params = {}
params.setdefault("per_page", self.per_page)
page = 1
all_items: List[dict] = []
while True:
params["page"] = page
resp = self._request("GET", path, params=params)
items = resp.json()
if not isinstance(items, list):
all_items = items
break
all_items.extend(items)
total_pages = resp.headers.get("X-Total-Pages")
if total_pages is not None:
if page >= int(total_pages):
break
else:
if not items:
break
page += 1
return all_items
def get_projects(self) -> List[dict]:
"""GET /api/v1/projects."""
return self._paginated_get("/api/v1/projects", {"membership": True})
def get_company_projects(self, company_id: int) -> List[dict]:
"""GET /api/v1/companies/{id}/projects."""
return self._paginated_get(f"/api/v1/companies/{company_id}/projects")
def resolve_projects(self, cfg: dict) -> List[dict]:
"""Определить список проектов для сканирования."""
gf_cfg = cfg["gitflic"]
source = gf_cfg.get("project_source", "all")
if source == "list":
names = gf_cfg.get("project_list", [])
ids = gf_cfg.get("project_ids", [])
projects = []
for name in names:
proj = self.get_project_by_path(name)
if proj:
projects.append(proj)
else:
logger.warning("Проект не найден: %s", name)
for pid in ids:
proj = self.get_project(pid)
if proj:
projects.append(proj)
else:
logger.warning("Проект не найден по ID: %d", pid)
return projects
if source == "company":
cid = gf_cfg.get("company_id")
if not cid:
logger.error("Не указан company_id при project_source=company")
return []
return self.get_company_projects(cid)
if source == "team":
tid = gf_cfg.get("team_id")
if not tid:
logger.error("Не указан team_id при project_source=team")
return []
return self.get_team_projects(tid)
return self.get_projects()
def get_project(self, project_id: int) -> Optional[dict]:
try:
resp = self._request("GET", f"/api/v1/projects/{project_id}")
return resp.json()
except requests.RequestException:
return None
def get_project_by_path(self, path: str) -> Optional[dict]:
try:
resp = self._request("GET", f"/api/v1/projects/{path}")
return resp.json()
except requests.RequestException:
return None
def get_releases(self, project_id: int) -> List[dict]:
"""GET /api/v1/projects/{id}/releases."""
return self._paginated_get(
f"/api/v1/projects/{project_id}/releases",
{"per_page": self.per_page},
)
def get_release_assets(self, project_id: int, release_id: int) -> List[dict]:
"""Получить assets релиза."""
try:
resp = self._request(
"GET",
f"/api/v1/projects/{project_id}/releases/{release_id}/assets",
)
items = resp.json()
if isinstance(items, list):
return items
if isinstance(items, dict):
return items.get("assets", items.get("links", []))
return []
except requests.RequestException:
logger.debug("Assets не получены: %s/%s", project_id, release_id)
return []
def download_file(self, url: str, local_path: Path) -> Path:
"""Скачать файл по ссылке."""
if not url.startswith("http"):
url = f"{self.base_url}{url}"
headers = {}
if url.startswith(self.base_url):
headers["Authorization"] = f"Bearer {self.api_token}"
logger.info("Скачивание: %s -> %s", url, local_path)
resp = self._session.get(url, headers=headers, stream=True,
timeout=(30, 300))
resp.raise_for_status()
local_path.parent.mkdir(parents=True, exist_ok=True)
with open(local_path, "wb") as fh:
for chunk in resp.iter_content(chunk_size=self.chunk_size):
if chunk:
fh.write(chunk)
logger.debug("Скачан файл: %s (%d bytes)",
local_path, local_path.stat().st_size)
return local_path
def get_release_source_archive_url(self, project_id: int,
tag_name: str) -> str:
"""URL архива исходного кода по тегу релиза."""
return (f"{self.base_url}/api/v1/projects/{project_id}"
f"/repository/archive.zip?ref={tag_name}")
def get_release_asset_download_url(self, project_id: int,
release_id: int,
asset_filename: str) -> str:
"""URL для скачивания прикреплённого файла (asset)."""
return (f"{self.base_url}/api/v1/projects/{project_id}"
f"/releases/{release_id}/downloads/{asset_filename}")
# ─────────────────────────────────────────────────────────
# Клиент PT Sandbox
# ─────────────────────────────────────────────────────────
class PTSandboxClient:
"""Клиент для REST API PT Sandbox."""
def __init__(self, cfg: dict):
sb_cfg = cfg["pt_sandbox"]
self.base_url = sb_cfg["base_url"].rstrip("/")
self.upload_endpoint = sb_cfg.get("upload_endpoint", "/api/v1/files/upload")
self.auth_header = sb_cfg.get("auth_header", "X-API-Key")
self.api_key = sb_cfg.get("api_key", "")
self.file_field_name = sb_cfg.get("file_field_name", "file")
self.extra_fields = sb_cfg.get("extra_fields", {})
self.timeout_connect = sb_cfg.get("timeout_connect", 30)
self.timeout_read = sb_cfg.get("timeout_read", 300)
if not self.api_key:
logger.error("API-ключ PT Sandbox не задан в конфигурации!")
sys.exit(1)
self._session = requests.Session()
self._session.headers.update({
self.auth_header: self.api_key,
"User-Agent": "Sandbox-GitFlic-PoC/1.0",
})
def upload_file(self, file_path: Path) -> Optional[str]:
"""
Отправить файл в PT Sandbox на анализ.
Возвращает ID задачи (task_id) или None при ошибке.
"""
url = f"{self.base_url}{self.upload_endpoint}"
logger.info("Отправка файла в Sandbox: %s (%d bytes)",
file_path.name, file_path.stat().st_size)
try:
with open(file_path, "rb") as fh:
files = {self.file_field_name: (file_path.name, fh)}
fields = dict(self.extra_fields)
resp = self._session.post(
url, files=files, data=fields,
timeout=(self.timeout_connect, self.timeout_read),
)
if resp.status_code in (200, 201, 202):
result = resp.json()
task_id = (result.get("task_id") or result.get("id")
or result.get("uuid") or str(result))
logger.info("Файл %s принят Sandbox. task_id=%s",
file_path.name, task_id)
return str(task_id)
else:
logger.error("Sandbox вернул %s: %s",
resp.status_code, resp.text[:500])
return None
except requests.exceptions.Timeout:
logger.error("Тайм-аут при отправке файла %s в Sandbox",
file_path.name)
return None
except requests.exceptions.RequestException as exc:
logger.error("Ошибка отправки файла %s в Sandbox: %s",
file_path.name, exc)
return None
except OSError as exc:
logger.error("Ошибка чтения файла %s: %s", file_path.name, exc)
return None
# ─────────────────────────────────────────────────────────
# Вспомогательные функции
# ─────────────────────────────────────────────────────────
def get_temp_dir() -> Path:
"""Получить временную директорию для скачанных файлов."""
tmp = Path("downloads")
tmp.mkdir(parents=True, exist_ok=True)
return tmp
# ─────────────────────────────────────────────────────────
# Основная логика сканирования
# ─────────────────────────────────────────────────────────
def scan_new_releases(gf: GitFlicClient, sb: PTSandboxClient,
state: dict, cfg: dict) -> dict:
"""
Основная функция:
1. Получить проекты
2. Для каждого проекта релизы
3. Отфильтровать отправленные
4. Скачать файлы (архив + assets)
5. Отправить в Sandbox
6. Обновить состояние
"""
projects = gf.resolve_projects(cfg)
if not projects:
logger.warning("Нет проектов для сканирования.")
return state
scan_mode = cfg["gitflic"].get("scan_mode", "latest")
tmp_dir = get_temp_dir()
state = deepcopy(state)
for project in projects:
pid = project.get("id")
pname = project.get("path_with_namespace") or project.get("name", str(pid))
logger.info("Обработка проекта: %s (id=%s)", pname, pid)
try:
releases = gf.get_releases(pid)
except requests.RequestException:
logger.error("Пропускаем проект %s - ошибка получения релизов", pname)
continue
if not releases:
continue
releases.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if scan_mode == "latest":
rel = releases[0]
rel_id = rel.get("id")
rel_tag = rel.get("tag_name", "")
rel_version = rel.get("name", "")
if is_release_sent(state, pid, rel_id, rel_tag, rel_version):
logger.info("Последний релиз уже отправлен. Пропускаем.")
continue
releases_to_process = [rel]
else:
releases_to_process = [
r for r in releases
if not is_release_sent(state, pid, r.get("id"),
r.get("tag_name", ""), r.get("name", ""))
]
if not releases_to_process:
logger.info("Нет новых релизов в проекте %s", pname)
continue
for rel in releases_to_process:
rel_id = rel.get("id")
rel_tag = rel.get("tag_name", "")
rel_version = rel.get("name", "")
rel_key = make_release_key(pid, rel_id, rel_tag, rel_version)
logger.info("Новый релиз: %s (tag=%s, version=%s)",
rel_key, rel_tag, rel_version)
# Собираем файлы для скачивания
files_to_download: List[Dict[str, Any]] = []
local_files: List[Dict[str, Any]] = []
# 1. Архив исходного кода по тегу
source_url = gf.get_release_source_archive_url(pid, rel_tag)
sf = f"source_{pname.replace('/', '_')}_{rel_tag}.zip"
files_to_download.append({"type": "source_archive", "url": source_url, "filename": sf})
# 2. Assets (прикреплённые файлы)
try:
assets = gf.get_release_assets(pid, rel_id)
for asset in assets:
aname = asset.get("name") or asset.get("filename", "")
if not aname:
continue
aurl = gf.get_release_asset_download_url(pid, rel_id, aname)
files_to_download.append({"type": "asset", "url": aurl, "filename": aname})
except requests.RequestException:
logger.debug("Assets для %s не получены", rel_key)
# Скачиваем все файлы
for fi in files_to_download:
safe = fi["filename"].replace("/", "_").replace("\\", "_")
lpath = tmp_dir / f"{pid}_{rel_id}_{safe}"
try:
gf.download_file(fi["url"], lpath)
local_files.append({"filename": fi["filename"], "type": fi["type"], "local_path": str(lpath)})
except requests.RequestException as exc:
logger.error("Ошибка скачивания %s: %s", fi["filename"], exc)
# Отправляем каждый файл в Sandbox
sent_files: List[Dict[str, Any]] = []
for lf in local_files:
lpath = Path(lf["local_path"])
if not lpath.exists():
logger.warning("Файл %s не найден", lpath)
continue
task_id = sb.upload_file(lpath)
sent_files.append({"filename": lf["filename"], "type": lf["type"], "sandbox_task_id": task_id})
try:
lpath.unlink()
except OSError as exc:
logger.warning("Не удалось удалить %s: %s", lpath, exc)
mark_release_sent(state, pid, pname, rel_id, rel_tag, rel_version, sent_files)
logger.info("Релиз %s обработан: %d файлов отправлено", rel_key, len(sent_files))
return state
# ─────────────────────────────────────────────────────────
# Точка входа
# ─────────────────────────────────────────────────────────
def main() -> None:
"""Главная функция."""
cfg = load_config()
setup_logging(cfg)
logger.info("=" * 60)
logger.info("Запуск интеграции GitFlic -> PT Sandbox")
logger.info("=" * 60)
gf = GitFlicClient(cfg)
sb = PTSandboxClient(cfg)
loop_cfg = cfg.get("loop", {})
loop_enabled = loop_cfg.get("enabled", False)
interval = loop_cfg.get("interval_seconds", 3600)
state_path = cfg.get("state", {}).get("file_path", "state.json")
iteration = 0
while True:
iteration += 1
logger.info("Итерация #%d", iteration)
state = load_state(state_path)
try:
new_state = scan_new_releases(gf, sb, state, cfg)
save_state(new_state, state_path)
except Exception as exc:
logger.exception("Критическая ошибка на итерации #%d: %s",
iteration, exc)
try:
save_state(state, state_path)
except Exception:
pass
if not loop_enabled:
logger.info("Однократный проход завершён.")
break
logger.info("Ожидание %d секунд до следующей итерации...", interval)
try:
time.sleep(interval)
except KeyboardInterrupt:
logger.info("Получен сигнал прерывания. Завершение.")
break
logger.info("Работа завершена.")
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
requests>=2.28.0
PyYAML>=6.0