Newer
Older
gnexus-tasks / backend / app / services / bugtrail.py
"""Черновик задачи из ссылки на багрепорт BugTrail (ТЗ 3.22, 0.92).

Ссылка из панели трекера (`{panel}/r/{share_token}`) публична by design: share-токен
в пути и есть авторизация. Отдельного доступа к BugTrail не нужно, токен у нас нигде
не хранится и не настраивается — только вынимается из вставленной ссылки.

Ходим за отчётом **с бэкенда** (у трекера узкий CORS, а share-токен незачем лишний раз
светить в браузере). Хост, к которому обращаемся, берётся **только из настроек**: из
ссылки вынимается лишь share-токен, и он проверяется по charset. Подставить
произвольный адрес нечем — это весь SSRF-барьер фичи (ср. services/favicon.py).

Отсюда же берётся текст задачи: подписи шагов — слово в слово из русской локали самого
BugTrail (`packages/web/src/i18n/ru.json`), чтобы задача в gntodo читалась так же, как
отчёт в панели.
"""

from __future__ import annotations

import json
import re
from typing import Any, Literal
from urllib.parse import urlsplit

import httpx
from pydantic import BaseModel, ConfigDict, Field

from app.config import get_settings

CONNECT_TIMEOUT = 3.0
READ_TIMEOUT = 4.0
TOTAL_TIMEOUT = 6.0
# Тело отчёта — не картинка: в ReportOut входит mouse_track (до 5000 точек у записи
# экрана), поэтому «толстое» тело тут норма, а не признак подделки. Но граница нужна.
MAX_BYTES = 1_000_000
# Потолки на то, что подставляем в описание: лимита на описание в БД нет вовсе, и
# патологический отчёт не должен раздувать задачу (карточка списка рендерит начало).
MAX_STEPS = 50
MAX_IMAGES = 20
MAX_DESCRIPTION = 20_000

_UA = "gntodo/1.0 (bugtrail import)"
# token_urlsafe(16) — 22 символа [A-Za-z0-9_-]; берём с запасом: чужая ссылка всё
# равно упирается в белый список хостов, а битый токен вернёт 404
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
# Подписи типов шагов — из report.stepTypes русской локали BugTrail
_STEP_LABELS = {
    "click": "Клик",
    "input": "Ввод",
    "url_change": "Смена URL",
    "scroll": "Прокрутка",
    "navigation": "Переход",
    "note": "Заметка",
    "screenshot": "Скриншот",
    "console": "Ошибка консоли",
}

FetchStatus = Literal["ok", "not_found", "unavailable"]


class _Step(BaseModel):
    model_config = ConfigDict(extra="ignore")

    type: str = ""
    offset_ms: int = 0
    data: dict[str, Any] = Field(default_factory=dict)
    screenshot_attachment_id: str | None = None


class _Attachment(BaseModel):
    model_config = ConfigDict(extra="ignore")

    file_id: str = ""
    kind: str = ""
    filename: str = ""
    mime: str = ""
    size: int = 0


class Report(BaseModel):
    """Ответ трекера (ReportDetailOut) — только то, что попадает в задачу.

    Модель внешнего контракта, поэтому локальная и снисходительная: всё
    необязательное (в BugTrail эти же поля бывают `null`), лишнее игнорируем.
    """

    model_config = ConfigDict(extra="ignore")

    title: str | None = None
    description: str | None = None
    page_url: str | None = None
    page_title: str | None = None
    share_token: str | None = None
    element: dict[str, Any] | None = None
    steps: list[_Step] | None = None
    attachments: list[_Attachment] | None = None


class BugTrailDraft(BaseModel):
    """Черновик задачи: заполняем форму, ничего не создаём."""

    title: str
    description: str


def parse_share_token(url: str) -> str | None:
    """Share-токен из ссылки на отчёт; None — это не ссылка BugTrail.

    Принимаем обе формы: человеческую `{panel}/r/{token}` и API-шную
    `{api}/api/reports/{token}`. Хост обязан совпасть с настроенным (панель или
    API) — иначе ссылку не разбираем вовсе.
    """
    settings = get_settings()
    if not settings.bugtrail_enabled:
        return None
    split = urlsplit(url.strip())
    if split.scheme not in ("http", "https"):
        return None
    if split.netloc.lower() not in {_host(settings.bugtrail_url), _host(settings.bugtrail_panel)}:
        return None
    parts = [part for part in split.path.split("/") if part]
    if len(parts) == 2 and parts[0] == "r":
        token = parts[1]
    elif len(parts) == 3 and parts[:2] == ["api", "reports"]:
        token = parts[2]
    else:
        return None
    return token if _TOKEN_RE.match(token) else None


def _host(base: str) -> str:
    """`https://host:port/` → `host:port` в нижнем регистре — для сверки хостов."""
    return urlsplit(base).netloc.lower()


def _text(value: Any) -> str:
    """Строка из внешних данных; всё прочее (None, числа, словари) — пусто."""
    return value.strip() if isinstance(value, str) else ""


async def _read_limited(resp: httpx.Response, limit: int) -> bytes | None:
    """Тело ответа не длиннее limit; None — превысило (ср. favicon._read_limited)."""
    buf = bytearray()
    async for chunk in resp.aiter_bytes():
        buf += chunk
        if len(buf) > limit:
            return None
    return bytes(buf)


async def _get_json(client: httpx.AsyncClient, url: str) -> tuple[Any, FetchStatus]:
    """JSON по url и что из этого вышло: ok / not_found / unavailable."""
    try:
        async with client.stream("GET", url, follow_redirects=False) as resp:
            if resp.status_code == 404:
                return None, "not_found"
            if resp.status_code != 200:
                return None, "unavailable"
            body = await _read_limited(resp, MAX_BYTES)
    except httpx.HTTPError:
        return None, "unavailable"
    if body is None:
        return None, "unavailable"
    try:
        return json.loads(body), "ok"
    except ValueError:  # не JSON — трекер ответил не тем
        return None, "unavailable"


async def fetch_report(token: str) -> tuple[Report | None, FetchStatus]:
    """Отчёт по share-токену. Клиент создаётся здесь — тесты бьют по `_get_json`."""
    settings = get_settings()
    timeout = httpx.Timeout(TOTAL_TIMEOUT, connect=CONNECT_TIMEOUT, read=READ_TIMEOUT)
    url = f"{settings.bugtrail_url.rstrip('/')}/api/reports/{token}"
    async with httpx.AsyncClient(timeout=timeout, headers={"User-Agent": _UA}) as client:
        payload, status = await _get_json(client, url)
    if status != "ok":
        return None, status
    try:
        return Report.model_validate(payload), "ok"
    except ValueError:  # не наш контракт — считаем трекер недоступным
        return None, "unavailable"


def build_draft(report: Report, *, api_base: str, panel_base: str, token: str) -> BugTrailDraft:
    """Черновик задачи из отчёта: заголовок и markdown-описание.

    `api_base` — для картинок (`/api/reports/by-token/...`), `panel_base` — для
    человеческой ссылки на отчёт. В dev это разные адреса, путать нельзя.
    """
    share = report.share_token or token
    title = _text(report.title) or _text(report.page_title)
    blocks: list[str] = []

    if description := _text(report.description):
        blocks.append(description)

    steps = report.steps or []
    if steps:
        shots = _shots_by_step(report, api_base=api_base, token=share)
        lines: list[str] = []
        for index, step in enumerate(steps[:MAX_STEPS]):
            lines.append(_step_line(step, index + 1))
            if shot := shots.get(step.screenshot_attachment_id or ""):
                lines.append(shot)
        if len(steps) > MAX_STEPS:
            lines.append(f"… ещё {len(steps) - MAX_STEPS}")
        blocks.append("**Записанные шаги**\n\n" + "\n".join(lines))
    elif element := _element_parts(report.element):
        blocks.append(f"**Элемент:** {element}")

    if page := _page_line(report):
        blocks.append(page)

    images, files = _attachments(report, api_base=api_base, token=share)
    if images or files:
        blocks.append("**Вложения**\n\n" + "\n".join([*images, *files]))

    blocks.append(f"**Источник:** BugTrail — {panel_base}/r/{share}")
    return BugTrailDraft(title=title or "Багрепорт BugTrail", description=_cut(blocks))


def _step_line(step: _Step, number: int) -> str:
    """Строка шага — как в панели BugTrail (`aiPrompt.ts:111`, `stepLine`)."""
    data = step.data
    parts: list[str] = []
    if element := _element_parts(data.get("element")):
        parts.append(element)
    if value := _text(data.get("value")):
        parts.append(f"«{value}»")
    if isinstance(length := data.get("value_length"), int):
        parts.append(f"({length} симв.)")
    from_url, to_url = _text(data.get("from_url")), _text(data.get("to_url"))
    if from_url and to_url:
        parts.append(f"{from_url} → {to_url}")
    elif to_url:
        parts.append(to_url)
    x, y = data.get("x"), data.get("y")
    if isinstance(x, int | float) and isinstance(y, int | float):
        parts.append(f"({x}, {y})")
    if text := _text(data.get("text")):
        parts.append(text)
    label = _STEP_LABELS.get(step.type, step.type)
    tail = f": {' '.join(parts)}" if parts else ""
    return f"{number}. {step.offset_ms / 1000:.1f} с — {label}{tail}"


def _element_parts(element: Any) -> str:
    """`<button> «Войти» #submit` — тег, подпись, селектор."""
    if not isinstance(element, dict):
        return ""
    parts: list[str] = []
    if tag := _text(element.get("tag")):
        parts.append(f"<{tag}>")
    if snippet := _text(element.get("text_snippet")):
        parts.append(f"«{snippet}»")
    if selector := _text(element.get("unique_selector")) or _text(element.get("selector")):
        parts.append(selector)
    return " ".join(parts)


def _page_line(report: Report) -> str:
    """`**Страница:** Вход — http://…` — что из отчёта есть, то и пишем."""
    url, title = _text(report.page_url), _text(report.page_title)
    if url and title:
        return f"**Страница:** {title} — {url}"
    if url or title:
        return f"**Страница:** {url or title}"
    return ""


def _file_url(api_base: str, token: str, file_id: str) -> str:
    """Публичная ссылка на файл вложения: токен в пути и есть авторизация."""
    return f"{api_base}/api/reports/by-token/{token}/files/{file_id}"


def _shots_by_step(report: Report, *, api_base: str, token: str) -> dict[str, str]:
    """Картинка, привязанная к шагу (`screenshot_attachment_id`), — под своим шагом."""
    images = {a.file_id for a in report.attachments or [] if (a.mime or "").startswith("image/")}
    result: dict[str, str] = {}
    for step in report.steps or []:
        shot = step.screenshot_attachment_id or ""
        if shot in images:
            result[shot] = f"![скриншот]({_file_url(api_base, token, shot)})"
    return result


def _attachments(
    report: Report, *, api_base: str, token: str
) -> tuple[list[str], list[str]]:
    """(картинки, прочие файлы) — то, что не встало к шагам."""
    inlined = set(_shots_by_step(report, api_base=api_base, token=token))
    images: list[str] = []
    files: list[str] = []
    for attachment in report.attachments or []:
        if not attachment.file_id or attachment.file_id in inlined:
            continue
        url = _file_url(api_base, token, attachment.file_id)
        mime = attachment.mime or ""
        if mime.startswith("image/"):
            if len(images) < MAX_IMAGES:
                images.append(f"![скриншот]({url})")
        elif mime.startswith("video/"):
            files.append(f"- Запись экрана: {url}")
        else:
            files.append(f"- {_text(attachment.filename) or 'файл'}: {url}")
    return images, files


def _cut(blocks: list[str]) -> str:
    """Собрать блоки в описание и подрезать по потолку (лимита в БД нет)."""
    text = "\n\n".join(block for block in blocks if block)
    if len(text) <= MAX_DESCRIPTION:
        return text
    head = text[:MAX_DESCRIPTION]
    cut = head.rfind("\n")
    if cut > 0:
        head = head[:cut]
    return head + "\n\n… отчёт большой, показано начало"