From 08e551c8bcf5976c92b216547506bae6344aac5f Mon Sep 17 00:00:00 2001 From: redlineo Date: Wed, 16 Sep 2026 17:00:24 +0300 Subject: [PATCH] Initial commit --- .gitignore | 19 ++ README.md | 273 ++++++++++++++++++++++ config.yaml | 83 +++++++ main.py | 587 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 5 files changed, 964 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.yaml create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb19da9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Игнорируем временные файлы +downloads/ +__pycache__/ +*.pyc + +# Игнорируем состояние (история отправленных релизов) +state.json + +# Игнорируем логи +sandbox.log +debug.log + +# IDE +.vscode/ +.idea/ + +# Системные +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..71bd1e6 --- /dev/null +++ b/README.md @@ -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 ` | + +В `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" +``` +``` +``` \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..764fbc2 --- /dev/null +++ b/config.yaml @@ -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 час \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..3810a66 --- /dev/null +++ b/main.py @@ -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": 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() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5148229 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +PyYAML>=6.0 \ No newline at end of file