fix: исправление API-эндпоинтов GitFlic (GitLab-стиль -> GitFlic REST API)

This commit is contained in:
redlineo committed 2026-09-16 17:43:28 +03:00
1 parent 08e551c8bc
commit 9dea59dd43
2 files changed
+86 -89

No files matched your search

+2
View File
@@ -4,6 +4,8 @@
gitflic: gitflic:
# Базовый URL GitFlic (облачный или self-hosted) # Базовый URL GitFlic (облачный или self-hosted)
# Для облачной версии: https://api.gitflic.ru
# Для self-hosted: http://<host>:<port>/rest-api
base_url: "https://gitflic.ru" base_url: "https://gitflic.ru"
# API-токен пользователя (read-only, scope: api, read_repository) # API-токен пользователя (read-only, scope: api, read_repository)
api_token: "" # required api_token: "" # required
+84 -89
View File
@@ -13,7 +13,7 @@ import time
from copy import deepcopy from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Tuple
import requests import requests
import yaml import yaml
@@ -100,21 +100,21 @@ def save_state(state: dict, path: str) -> None:
logger.error("Ошибка сохранения состояния в %s: %s", path, exc) logger.error("Ошибка сохранения состояния в %s: %s", path, exc)
def make_release_key(project_id: int, release_id: int, def make_release_key(project_id: str, release_id: str,
tag: str, version: str) -> str: tag: str, version: str) -> str:
"""Сформировать ключ для идентификации релиза.""" """Сформировать ключ для идентификации релиза."""
return f"{project_id}:{release_id}:{tag}:{version}" return f"{project_id}:{release_id}:{tag}:{version}"
def is_release_sent(state: dict, project_id: int, release_id: int, def is_release_sent(state: dict, project_id: str, release_id: str,
tag: str, version: str) -> bool: tag: str, version: str) -> bool:
"""Проверить, был ли релиз уже отправлен.""" """Проверить, был ли релиз уже отправлен."""
key = make_release_key(project_id, release_id, tag, version) key = make_release_key(project_id, release_id, tag, version)
return key in state.get("sent_releases", {}) return key in state.get("sent_releases", {})
def mark_release_sent(state: dict, project_id: int, project_name: str, def mark_release_sent(state: dict, project_id: str, project_name: str,
release_id: int, tag: str, version: str, release_id: str, tag: str, version: str,
files: List[Dict[str, Any]]) -> None: files: List[Dict[str, Any]]) -> None:
"""Пометить релиз как отправленный.""" """Пометить релиз как отправленный."""
key = make_release_key(project_id, release_id, tag, version) key = make_release_key(project_id, release_id, tag, version)
@@ -153,7 +153,7 @@ class GitFlicClient:
self._session = requests.Session() self._session = requests.Session()
self._session.headers.update({ self._session.headers.update({
"Authorization": f"Bearer {self.api_token}", "Authorization": f"token {self.api_token}",
"Accept": "application/json", "Accept": "application/json",
"User-Agent": "Sandbox-GitFlic-PoC/1.0", "User-Agent": "Sandbox-GitFlic-PoC/1.0",
}) })
@@ -176,43 +176,48 @@ class GitFlicClient:
logger.error("Ошибка запроса %s %s: %s", method, url, exc) logger.error("Ошибка запроса %s %s: %s", method, url, exc)
raise raise
def _paginated_get(self, path: str, params: Optional[dict] = None) -> List[dict]: def _paginated_get(self, path: str, params: Optional[dict] = None,
"""Получить все страницы пагинированного GET-запроса.""" list_key: Optional[str] = None) -> List[dict]:
"""
Получить все страницы пагинированного GET-запроса.
GitFlic использует: size (не per_page), page начиная с 0,
и ответ в формате {"_embedded": {"<listKey>": [...]}, "page": {...}}.
"""
if params is None: if params is None:
params = {} params = {}
params.setdefault("per_page", self.per_page) params.setdefault("size", self.per_page)
page = 1 page = 0
all_items: List[dict] = [] all_items: List[dict] = []
while True: while True:
params["page"] = page params["page"] = page
resp = self._request("GET", path, params=params) resp = self._request("GET", path, params=params)
items = resp.json() data = resp.json()
if not isinstance(items, list):
all_items = items if list_key:
break items = data.get("_embedded", {}).get(list_key, [])
else:
embedded = data.get("_embedded", {})
items = next(iter(embedded.values())) if embedded else []
all_items.extend(items) all_items.extend(items)
total_pages = resp.headers.get("X-Total-Pages") page_info = data.get("page", {})
if total_pages is not None: total_pages = page_info.get("totalPages", 0)
if page >= int(total_pages): if page >= total_pages - 1:
break
else:
if not items:
break break
page += 1 page += 1
return all_items return all_items
def get_projects(self) -> List[dict]: def get_my_projects(self) -> List[dict]:
"""GET /api/v1/projects.""" """GET /project/my — собственные проекты пользователя."""
return self._paginated_get("/api/v1/projects", {"membership": True}) return self._paginated_get("/project/my", list_key="projectList")
def get_company_projects(self, company_id: int) -> List[dict]: def get_shared_projects(self) -> List[dict]:
"""GET /api/v1/companies/{id}/projects.""" """GET /project/shared — проекты с участием пользователя."""
return self._paginated_get(f"/api/v1/companies/{company_id}/projects") return self._paginated_get("/project/shared", list_key="projectList")
def resolve_projects(self, cfg: dict) -> List[dict]: def resolve_projects(self, cfg: dict) -> List[dict]:
"""Определить список проектов для сканирования.""" """Определить список проектов для сканирования."""
@@ -234,7 +239,7 @@ class GitFlicClient:
if proj: if proj:
projects.append(proj) projects.append(proj)
else: else:
logger.warning("Проект не найден по ID: %d", pid) logger.warning("Проект не найден по ID: %s", pid)
return projects return projects
if source == "company": if source == "company":
@@ -251,45 +256,47 @@ class GitFlicClient:
return [] return []
return self.get_team_projects(tid) return self.get_team_projects(tid)
return self.get_projects() return self.get_shared_projects()
def get_project(self, project_id: int) -> Optional[dict]: def get_project(self, project_id: str) -> Optional[dict]:
"""GET /project/{projectUuid}."""
try: try:
resp = self._request("GET", f"/api/v1/projects/{project_id}") resp = self._request("GET", f"/project/{project_id}")
return resp.json() return resp.json()
except requests.RequestException: except requests.RequestException:
return None return None
def get_project_by_path(self, path: str) -> Optional[dict]: def get_project_by_path(self, path: str) -> Optional[dict]:
"""
GET /project/{ownerAlias}/{projectAlias}.
path — строка вида "owner/project-name".
"""
try: try:
resp = self._request("GET", f"/api/v1/projects/{path}") resp = self._request("GET", f"/project/{path}")
return resp.json() return resp.json()
except requests.RequestException: except requests.RequestException:
return None return None
def get_releases(self, project_id: int) -> List[dict]: def get_releases(self, owner_alias: str, project_alias: str) -> List[dict]:
"""GET /api/v1/projects/{id}/releases.""" """GET /project/{ownerAlias}/{projectAlias}/release."""
return self._paginated_get( return self._paginated_get(
f"/api/v1/projects/{project_id}/releases", f"/project/{owner_alias}/{project_alias}/release",
{"per_page": self.per_page}, list_key="releaseTagModelList",
) )
def get_release_assets(self, project_id: int, release_id: int) -> List[dict]: def get_company_projects(self, company_id: str) -> List[dict]:
"""Получить assets релиза.""" """GET /company/{id}/project."""
try: return self._paginated_get(
resp = self._request( f"/company/{company_id}/project",
"GET", list_key="projectList",
f"/api/v1/projects/{project_id}/releases/{release_id}/assets", )
def get_team_projects(self, team_id: str) -> List[dict]:
"""GET /team/{id}/project."""
return self._paginated_get(
f"/team/{team_id}/project",
list_key="projectList",
) )
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: def download_file(self, url: str, local_path: Path) -> Path:
"""Скачать файл по ссылке.""" """Скачать файл по ссылке."""
@@ -298,7 +305,7 @@ class GitFlicClient:
headers = {} headers = {}
if url.startswith(self.base_url): if url.startswith(self.base_url):
headers["Authorization"] = f"Bearer {self.api_token}" headers["Authorization"] = f"token {self.api_token}"
logger.info("Скачивание: %s -> %s", url, local_path) logger.info("Скачивание: %s -> %s", url, local_path)
resp = self._session.get(url, headers=headers, stream=True, resp = self._session.get(url, headers=headers, stream=True,
@@ -314,19 +321,6 @@ class GitFlicClient:
local_path, local_path.stat().st_size) local_path, local_path.stat().st_size)
return local_path 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 # Клиент PT Sandbox
@@ -422,7 +416,7 @@ def scan_new_releases(gf: GitFlicClient, sb: PTSandboxClient,
1. Получить проекты 1. Получить проекты
2. Для каждого проекта релизы 2. Для каждого проекта релизы
3. Отфильтровать отправленные 3. Отфильтровать отправленные
4. Скачать файлы (архив + assets) 4. Скачать файлы (attachmentFiles из релиза)
5. Отправить в Sandbox 5. Отправить в Sandbox
6. Обновить состояние 6. Обновить состояние
""" """
@@ -437,25 +431,32 @@ def scan_new_releases(gf: GitFlicClient, sb: PTSandboxClient,
for project in projects: for project in projects:
pid = project.get("id") pid = project.get("id")
pname = project.get("path_with_namespace") or project.get("name", str(pid)) owner_alias = project.get("owner", {}).get("alias", "")
logger.info("Обработка проекта: %s (id=%s)", pname, pid) project_alias = project.get("alias", "")
pname = f"{owner_alias}/{project_alias}"
logger.info("Обработка проекта: %s (uuid=%s)", pname, pid)
if not owner_alias or not project_alias:
logger.warning("Пропускаем проект %s: нет owner/project alias", pid)
continue
try: try:
releases = gf.get_releases(pid) releases = gf.get_releases(owner_alias, project_alias)
except requests.RequestException: except requests.RequestException:
logger.error("Пропускаем проект %s - ошибка получения релизов", pname) logger.error("Пропускаем проект %s - ошибка получения релизов", pname)
continue continue
if not releases: if not releases:
logger.info("Нет релизов в проекте %s", pname)
continue continue
releases.sort(key=lambda r: r.get("created_at", ""), reverse=True) releases.sort(key=lambda r: r.get("createdAt", ""), reverse=True)
if scan_mode == "latest": if scan_mode == "latest":
rel = releases[0] rel = releases[0]
rel_id = rel.get("id") rel_id = rel.get("id")
rel_tag = rel.get("tag_name", "") rel_tag = rel.get("tagName", "")
rel_version = rel.get("name", "") rel_version = rel.get("title", "")
if is_release_sent(state, pid, rel_id, rel_tag, rel_version): if is_release_sent(state, pid, rel_id, rel_tag, rel_version):
logger.info("Последний релиз уже отправлен. Пропускаем.") logger.info("Последний релиз уже отправлен. Пропускаем.")
@@ -465,7 +466,7 @@ def scan_new_releases(gf: GitFlicClient, sb: PTSandboxClient,
releases_to_process = [ releases_to_process = [
r for r in releases r for r in releases
if not is_release_sent(state, pid, r.get("id"), if not is_release_sent(state, pid, r.get("id"),
r.get("tag_name", ""), r.get("name", "")) r.get("tagName", ""), r.get("title", ""))
] ]
if not releases_to_process: if not releases_to_process:
logger.info("Нет новых релизов в проекте %s", pname) logger.info("Нет новых релизов в проекте %s", pname)
@@ -473,32 +474,26 @@ def scan_new_releases(gf: GitFlicClient, sb: PTSandboxClient,
for rel in releases_to_process: for rel in releases_to_process:
rel_id = rel.get("id") rel_id = rel.get("id")
rel_tag = rel.get("tag_name", "") rel_tag = rel.get("tagName", "")
rel_version = rel.get("name", "") rel_version = rel.get("title", "")
rel_key = make_release_key(pid, rel_id, rel_tag, rel_version) rel_key = make_release_key(pid, rel_id, rel_tag, rel_version)
logger.info("Новый релиз: %s (tag=%s, version=%s)", logger.info("Новый релиз: %s (tag=%s, title=%s)",
rel_key, rel_tag, rel_version) rel_key, rel_tag, rel_version)
# Собираем файлы для скачивания # Собираем файлы для скачивания (attachmentFiles из релиза)
files_to_download: List[Dict[str, Any]] = [] files_to_download: List[Dict[str, Any]] = []
local_files: List[Dict[str, Any]] = [] local_files: List[Dict[str, Any]] = []
# 1. Архив исходного кода по тегу assets = rel.get("attachmentFiles", [])
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: for asset in assets:
aname = asset.get("name") or asset.get("filename", "") aname = asset.get("name", "")
if not aname: aurl = asset.get("link", "")
if not aname or not aurl:
continue continue
aurl = gf.get_release_asset_download_url(pid, rel_id, aname)
files_to_download.append({"type": "asset", "url": aurl, "filename": aname}) files_to_download.append({"type": "asset", "url": aurl, "filename": aname})
except requests.RequestException:
logger.debug("Assets для %s не получены", rel_key) if not files_to_download:
logger.info("Нет файлов для скачивания в релизе %s", rel_key)
# Скачиваем все файлы # Скачиваем все файлы
for fi in files_to_download: for fi in files_to_download: