diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0150036..559f0bc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,27 +7,28 @@ | Модуль | Ответственность | Зависит от | |---|---|---| | `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib | -| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect`, время Jira → UTC (`jira_utc`) | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | +| `db` | схема SQLite (v6: + `issue_attachment`, `attachment_file`), миграции по `schema_version` (копия базы перед миграцией), `connect`, время Jira → UTC (`jira_utc`) | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` | -| `mirror` | зеркало задач, связей и комментариев (время комментария в UTC — `created_at`), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history` | +| `attachments` | вложения для интерфейсов: ссылки на них в вики-разметке текста (`ref_names`, `resolve`), формат размера (`fmt_size`), состояние `ready`/`fetching`/`failed`/`none` по `attachment_file`, хранилищу и последней `fetch_attachment` (`state`, `get`) | `files` | +| `mirror` | зеркало задач, связей, комментариев (время комментария в UTC — `created_at`) и списка вложений `issue_attachment` (схема v6; `check_attachments`, пишет `apply_state`; `attachment_file` ему не принадлежит), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверками `check_comments`, `check_attachments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history`, `attachments` | | `history` | журнал изменений задач `issue_change` (схема v5): проверка и запись пачек агента (`check_changes`, `record_changes`, логическое имя поля `logical_field`), параметр `history` для выдачи `refresh` (`delivery_param`: отметка — последний полный прогон с `sync_run.history_at`, минус 15 минут; стартовая JQL `backfill_jql`), признак принятого журнала `accepted`, лента `feed` (журнал + новые комментарии по `issue_comment.created_at`) и история задачи `issue_changes` | `db`, `settings`, `projects` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | -| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` | +| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты (для `fetch_attachment` в `done` — проверка файла в хранилище и запись `attachment_file`), `request_fetch` (постановка скачивания вложения в одной транзакции, `TooLarge`, `AgentTooOld`; пакет без хвостового `refresh` и без резервной копии), отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `attachments`, `db` | | `backup` | копии файла базы, ротация | `paths` | -| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` | +| `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ, кроме `allow_empty` для файла от агента), `touch` (mtime при выдаче содержимого), `sweep_downloads` (каталоги копий MCP `downloads//`, старше 30 дней), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}`, `POST /agent/files` (от агента, пустой допустим; каталог `Hub.files_dir`, 0700 при старте); вложения `GET /attachments/{id}`, `POST /attachments/{id}/fetch` (`queue.request_fetch`: `TooLarge` → 413, `AgentTooOld` → 409, нет → 404 `no_attachment`), `GET /attachments/{id}/content` (`Content-Disposition: filename*=UTF-8''…`, `nosniff`, `files.touch`); уборка раз в сутки трогает и `Hub.downloads_dir`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `attachments`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | -| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) | +| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета; `issue`/`comment` показывают вложения, инструмент `attachment` ставит скачивание (`POST /attachments/{id}/fetch`), опрашивает `GET /attachments/{id}` и кладёт копию в `<данные>/downloads//<имя>` (0700, через временный файл) | `client` (`get_bytes` — тело ответа как есть), `commands`, `paths`, `files` (только `MAX_SIZE`), `attachments` (только `fmt_size`) | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | `web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`), «Задачи» (`/app/issues`) и «Лента» (`/app/feed`: журнал изменений `GET /changes` за период; `web/src/feed/` — чистые функции `events.ts` (период, виды, текст события, группировка) и `EventList.tsx`, общий с блоком «История» карточки); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует -страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку +страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Вложения в карточке — `web/src/issues/attachments/`: `format.ts` (размер, адрес в Jira, значок по типу, предел 10 МБ), `useAttachmentDownload.ts` (хук карточки: `POST /attachments/{id}/fetch`, опрос `GET /attachments/{id}` раз в 2 с до `ready`, затем `api.blob` на `/content` и сохранение через временную ссылку; одно состояние на вложение для всех кнопок; опрос стоит при закрытии окна и смене задачи), `AttachmentList.tsx` (раздел «Вложения»), `AttachmentChips.tsx` (плашки под комментарием); ссылки `!имя!`, `[^имя]` в тексте разбирает `issues/wiki/inline.ts` по списку вложений задачи (узел `attachment`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` @@ -40,13 +41,14 @@ боковой панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только источник и события снимка. -Цепочки: `api → queue → commands/mirror/settings/projects/history → db`; `mirror → history → projects/settings`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. +Цепочки: `api → queue → commands/mirror/settings/projects/history → db`; `mirror → history → projects/settings`; `mirror → attachments → files`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. - Создание проекта: `POST /projects` (веб или `project_create`) → `projects.create` и пакет «Новый проект <метка>» (`refresh` по общей JQL, `batch.project = NULL`) в одной транзакции → `claim` (хаб дописывает в `refresh` доски проектов, `boards`) → агент определяет тип доски → отчёт `refresh` пишет `board_type` → `ensure_sprint` проекта получает `board_id`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. +- Скачивание вложения: вход — `POST /attachments/{id}/fetch` (из веб-приложения и из MCP `attachment`); `queue.request_fetch` (одна транзакция: состояние `attachments.state` + пакет `fetch_attachment {key, attachment_id}`) → `claim` (без копии базы) → агент → `POST /agent/files` → результат `{file, size}` → `report_result` проверяет файл в `files/` и пишет `attachment_file`; нет файла или чужой размер — команда `failed`. Выход — `GET /attachments/{id}/content`: в вебе Blob и сохранение браузером, в MCP копия `downloads//<имя>`. - Журнал изменений: выдача `refresh` (`queue.deliver_params` дописывает `history` — `since` и/или стартовую `jql`) → агент v13 после состояния читает changelog Jira → `POST /agent/history` → `history.record_changes` (повторы отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона @@ -56,12 +58,12 @@ - Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. ## Границы -- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. +- Зеркало (включая `issue_attachment`) пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Журнал `issue_change` пишет только `history.record_changes` по `POST /agent/history`; API и MCP его только читают. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). - `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`/`history` напрямую и ходит только через `client`; из `files` берёт только предел - `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). + `MAX_SIZE`, из `attachments` — только `fmt_size`; `downloads/` пишет только он (хаб эту папку лишь убирает) (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. diff --git a/CLAUDE.md b/CLAUDE.md index e5d691f..126af69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней), `downloads/` (копии вложений для MCP `/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/`, `files/` и `downloads/` — права 0700. Порт по умолчанию 8790. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура diff --git a/docs/superpowers/plans/2026-10-22-attachment-fetch-hub.md b/docs/superpowers/plans/2026-10-22-attachment-fetch-hub.md new file mode 100644 index 0000000..6a1b519 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-attachment-fetch-hub.md @@ -0,0 +1,185 @@ +# Вложения из Jira по запросу (хаб) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** хаб хранит список вложений задач (схема v6), находит ссылки на них в тексте, по запросу ставит агенту +`fetch_attachment`, принимает файл `POST /agent/files` и отдаёт его интерфейсам; MCP (`issue`, `comment`, новый +`attachment`) и веб-карточка показывают и скачивают вложения. + +**Architecture:** список вложений — часть зеркала (`issue_attachment`, пишет только `apply_state`); скачанное — отдельная +таблица `attachment_file`; состояние вложения вычисляется при чтении в новом модуле `attachments` (там же разбор ссылок и +формат размера). Постановка скачивания — `queue.request_fetch` (одна транзакция: проверка «уже качается» + пакет). +HTTP — новые маршруты в `api`; MCP ходит только через `client`; веб — через `web/src/api/client.ts`. + +**Tech Stack:** Python 3.13 (uv), stdlib HTTP/SQLite, pytest; React 19 + TypeScript + Vite, TanStack Query, Vitest. + +**Spec:** `openspec/changes/attachment-fetch/` этого worktree — `design.md` (решения 1–18), дельты +`specs/{data-model,commands,hub-api,mcp,web-app,agent}/spec.md`. Агентская часть — отдельный план +`../../jira-agent/.wt/attach-fetch/docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md` (не исполнять здесь). + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/attach-fetch`, ветка `feat/83-attachment-fetch` +(aep/jira-hub#83). Все команды — из корня worktree. codegraph: `projectPath` = путь worktree. + +## Global Constraints + +- `SCHEMA_VERSION = 6`; миграция v5 → v6 только создаёт таблицы (колонки — через `_add_column`-подобную защиту не нужны: таблицы новые, `CREATE TABLE IF NOT EXISTS`). +- `issue_attachment(attachment_id TEXT PRIMARY KEY, key TEXT NOT NULL, filename TEXT NOT NULL, size INTEGER, mime_type TEXT, author TEXT, created TEXT, created_at TEXT)`, индекс `issue_attachment_key` по `key`. +- `attachment_file(attachment_id TEXT PRIMARY KEY, file TEXT NOT NULL, size INTEGER NOT NULL, fetched_at TEXT NOT NULL)`. +- Предел — `files.MAX_SIZE` (10 485 760 байт); другого числа в коде нет. +- Тип команды — `fetch_attachment`, параметры `key`, `attachment_id`; зазор `pacing` по умолчанию 2 с. +- Заголовок пакета: `Скачать вложение ()`; `batch.project = NULL`; без хвостового `refresh`. +- Состояния вложения: `ready`, `fetching`, `failed`, `none`. +- Коды ошибок API: 404 `no_attachment`, 413 `too_large`, 409 `agent_too_old`, 404 `not_fetched`. +- Тексты (точно): «у нет вложения # в зеркале — обновите задачу»; «файл больше 10 МБ (<размер>) — скачайте в Jira»; «агент v не умеет скачивать вложения — нужен v14»; «хаб: файл не найден в хранилище». +- Формат размера (`attachments.fmt_size`): `< 1024` — «N Б»; `< 1024²` — КБ, `≥` — МБ, один знак после запятой, запятая как разделитель («234,4 КБ», «23,8 МБ»); `None` — «размер неизвестен». +- Каталог копий MCP: `<каталог данных>/downloads//<имя>` (каталог данных — `paths.db_path().parent`; у `Hub` — `db_path.parent`). +- Тексты интерфейсов — по-русски; комментарии в коде — по-русски, плотность как в окружающем коде. +- Каждый коммит — с `(#83)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 `. +- Зелёный — весь набор: `uv run pytest` (Python) и `cd web && npm test -- --run && npm run lint` (фронт); перед финалом `make check`. +- Решения, не записанные в спеке и плане, — в `openspec/changes/attachment-fetch/design.md`, раздел «Decisions made without discussion» (продолжение нумерации, маркер `R-n (design #N)`). Находки ревью задачи n — `T.`. +- `ARCHITECTURE.md`: каждая задача дописывает свои модули/маршруты/границы в таблицу и «Потоки данных» в том же коммите. + +## Review Focus + +1. Два одновременных `POST /attachments/{id}/fetch` (веб + MCP) — одна команда; второй ответ `fetching` с тем же `command_id` (тест в Task 3: два потока на одной базе). +2. Имя вложения с кириллицей, пробелами, `:`, `/` — `Content-Disposition` с `filename*=UTF-8''…` корректен, копия MCP с заменой `/ \ :` и управляющих на `_` (тесты в Task 4 и Task 5). +3. Отчёт `done` по `fetch_attachment` после уборки файла или с чужим размером — `failed`, `attachment_file` не тронута (тест в Task 3). +4. Повторная `fetch_attachment` после `failed` — новый пакет; после `done` и уборки файла — новый пакет (тест в Task 3). +5. Ссылка `!http://x/y.png!` и `[^имя]` внутри другой разметки — внешняя картинка не считается вложением, вложение с тем же именем — самое новое (тест в Task 2). + +--- + +### Task 1: Схема v6 и список вложений в зеркале + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/jira_hub/db.py` — `SCHEMA_VERSION = 6`, таблицы в `SCHEMA`, шаг `5: …` в `MIGRATIONS` +- Modify: `src/jira_hub/mirror.py` — `check_attachments`, `apply_state` +- Modify: `src/jira_hub/api.py` — `h_agent_state` вызывает `mirror.check_attachments` рядом с `check_comments` +- Test: `tests/test_db.py`, `tests/test_mirror.py`, `tests/test_api.py` + +**Interfaces:** +- Produces: `mirror.check_attachments(issues) -> None` (ValueError «: attachments[].<поле> — …», как `check_comments`); `apply_state` пишет `issue_attachment` из `it["attachments"]` (поля состояния: `id, filename, size, mime_type, author, created`), `created_at = db.jira_utc(created)`. + +- [ ] **Step 1: Тесты** — `test_db.py::test_migrate_v5_to_v6` (база v5 → копия в `backups/…-schema-v5.sqlite`, таблицы `issue_attachment`, `attachment_file` пусты, `schema_version = 6`); обновить ожидания прежних тестов миграций v1–v4 на `6`. `test_mirror.py::test_attachments_replace` (было #1, #2; прислано #2, #3 → #2 с новыми полями и #3, #1 удалено, `created_at` в UTC), `test_attachments_absent_kept` (без поля — список на месте), `test_attachment_file_untouched` (строка `attachment_file` переживает `apply_state`), `test_check_attachments_errors` (параметризовано: не список; элемент не объект; `id` пустой; `filename` не строка/пустой; `size = -1`, `size = "1"`, `size = True`; `mime_type = 5`). `test_api.py::test_agent_state_bad_attachment_422` (422 `bad_state`, пачка не применена). +- [ ] **Step 2: Прогон** — `uv run pytest tests/test_db.py tests/test_mirror.py tests/test_api.py -q` → новые тесты FAIL. +- [ ] **Step 3: Реализация** по сигнатурам выше; `INSERT OR REPLACE` по `attachment_id` (вложение «переехало» на другой ключ — строка перезаписывается). +- [ ] **Step 4: Прогон** — `uv run pytest -q` → весь набор PASS. +- [ ] **Step 5: ARCHITECTURE.md** — `db` (схема v6), `mirror` (вложения в `apply_state`); «Границы»: `issue_attachment` пишет только `apply_state`. +- [ ] **Step 6: Commit** — `Схема v6: список вложений задач в зеркале (#83)`. + +### Task 2: Модуль `attachments` и вложения в карточке задачи + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `src/jira_hub/attachments.py` +- Modify: `src/jira_hub/mirror.py` — `get_issue(conn, key, files_dir=None)` +- Modify: `src/jira_hub/api.py` — `h_issue` передаёт `hub.files_dir` +- Test: `tests/test_attachments.py` (новый), `tests/test_mirror.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: таблицы Task 1; `files.exists(root, sha)`. +- Produces (`attachments.py`, без импорта `queue`/`api`): + - `fmt_size(size: int | None) -> str` — формат из Global Constraints; + - `ref_names(text: str | None) -> list[str]` — имена из `!имя!`, `!имя|…!`, `[^имя]`, `[текст|^имя]` в порядке появления, без повторов; имя с `://` (у `!…!`) — пропуск; + - `resolve(names: list[str], items: list[dict]) -> list[str]` — `attachment_id` по точному имени, при повторе имени — самое новое по `created_at` (затем больший `attachment_id` числом); без совпадения — пропуск; без повторов id; + - `state(conn, files_dir, attachment_id: str) -> dict` → `{"state", "error", "command_id", "batch_id"}` по правилам `data-model` «Скачанные вложения» (`files_dir=None` — файла «нет»); последняя `fetch_attachment` — `SELECT … FROM command WHERE type = 'fetch_attachment' AND json_extract(params, '$.attachment_id') = ? ORDER BY id DESC`; + - `get(conn, files_dir, attachment_id) -> dict | None` — строка `issue_attachment` (`attachment_id, key, filename, size, mime_type, author, created`) + поля `state()`. +- Produces (`mirror.get_issue`): `attachments` (новые сверху: `created_at DESC`, затем `CAST(attachment_id AS INTEGER) DESC`; поля `attachment_id, filename, size, mime_type, author, created, state, error`), `description_attachment_ids`, у каждого комментария `attachment_ids`; `commands` — без `type = 'fetch_attachment'`. + +- [ ] **Step 1: Тесты** — `test_attachments.py`: `test_fmt_size` (`512 → "512 Б"`, `240000 → "234,4 КБ"`, `25_000_000 → "23,8 МБ"`, `None → "размер неизвестен"`); `test_ref_names` («см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!» → `["скрин 1.png", "ОСВ 60.xlsx"]`; `[отчёт|^a.pdf]` → `["a.pdf"]`; `!http://x/y.png!` → `[]`; `None` → `[]`); `test_resolve_newest` (два «скрин.png» #10 от 14.10 и #20 от 20.10 → `["20"]`); `test_state_*` — `none`, `fetching` (команда `queued` и `claimed`), `ready` (строка + файл), `ready` → файл удалён → `none`, `failed` с `error` последней команды, `cancelled` → `failed`. `test_mirror.py::test_get_issue_attachments` (сценарий «Скриншот в комментарии» из `hub-api`: `attachment_ids = ["31337", "31338"]`, `description_attachment_ids = []`, порядок вложений новые сверху), `test_get_issue_commands_skip_fetch`. `test_api.py::test_issue_has_attachments`. +- [ ] **Step 2: Прогон** — `uv run pytest tests/test_attachments.py tests/test_mirror.py -q` → FAIL. +- [ ] **Step 3: Реализация.** Разбор ссылок — регулярные выражения: `!([^!|\n]+)(?:\|[^!\n]*)?!` и `\[(?:[^\]|\n]*\|)?\^([^\]\n]+)\]`. +- [ ] **Step 4: Прогон** — `uv run pytest -q` → PASS. +- [ ] **Step 5: ARCHITECTURE.md** — строка модуля `attachments`, зависимость `mirror → attachments → files`. +- [ ] **Step 6: Commit** — `Вложения в карточке задачи: ссылки из текста и состояние (#83)`. + +### Task 3: Команда `fetch_attachment` в очереди + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/jira_hub/commands.py` — `SPEC["fetch_attachment"]`, `check_confirmed` +- Modify: `src/jira_hub/settings.py` — `pacing` по умолчанию + запасной зазор для типа, которого нет в сохранённой настройке +- Modify: `src/jira_hub/queue.py` — `_check_setup`, `_check_project`, `create_batch`, `claim`, `report_result`, новая `request_fetch` +- Modify: `src/jira_hub/api.py` — `h_agent_result` передаёт `files_dir=hub.files_dir` +- Test: `tests/test_commands.py`, `tests/test_queue.py`, `tests/test_settings.py` + +**Interfaces:** +- Consumes: `attachments.state`, `attachments.get`, `attachments.fmt_size` (Task 2). +- Produces: + - `SPEC["fetch_attachment"] = {"required": ["key", "attachment_id"], "optional": [], "keys": ["key"], "hints": {"attachment_id": "id вложения из issue (цифры)"}}`; `WRITE_TYPES` без `fetch_attachment`; + - `queue.request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> dict` — ответ как `attachments.get` + `command_id`, `batch_id`; исключения: `KeyError` (нет в зеркале), `queue.TooLarge(text)`, `queue.AgentTooOld(text)` (оба — подклассы `ValueError`); + - `queue.report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0, files_dir=None)`. + +- [ ] **Step 1: Тесты** — `test_commands.py::test_fetch_not_confirmed_by_state` (`check_confirmed` → `(False, None)`, не `None`). `test_settings.py::test_pacing_fallback_for_new_type` (сохранённый `pacing` без `fetch_attachment` → `settings.pacing(conn)["fetch_attachment"] == 2`). `test_queue.py`: `test_fetch_batch_no_tail_refresh`; `test_fetch_checks` (`attachment_id = "1a"` → 422; нет у `key` → 422 с текстом; размер 25 000 000 → `TooLarge` «файл больше 10 МБ (23,8 МБ) — скачайте в Jira»; `size = NULL` → ставится); `test_fetch_no_backup` (`claim(backup=…)` не вызывает копию); `test_fetch_project_batch_no_checks` (пакет с `project` и `fetch_attachment` по задаче другого проекта — создан); `test_request_fetch_once` (два вызова → одна команда, оба ответа с тем же `command_id`; второй — из другого потока на отдельном соединении к той же базе-файлу); `test_request_fetch_ready` (`ready` → новой команды нет); `test_request_fetch_agent_too_old` (`agent_status.agent_version = '13'` → `AgentTooOld` «агент v13 не умеет скачивать вложения — нужен v14»; `NULL` → ставится); `test_request_fetch_after_failed` (новый пакет); `test_report_fetch_done_records_file` (файл в `files/` того же размера → строка `attachment_file`, `state = ready`); `test_report_fetch_done_missing_file` (→ `failed` «хаб: файл не найден в хранилище», строки нет); `test_report_fetch_wrong_size` (→ то же). +- [ ] **Step 2: Прогон** — `uv run pytest tests/test_commands.py tests/test_queue.py tests/test_settings.py -q` → FAIL. +- [ ] **Step 3: Реализация.** `request_fetch` — внутри одного `db.transaction(conn)` (BEGIN IMMEDIATE — проверить, что `db.transaction` берёт блокировку записи; если нет — взять её явно): `attachments.state` → при `ready`/`fetching` вернуть как есть; иначе проверки и вставка пакета (выделить из `create_batch` внутреннюю вставку без собственной транзакции, чтобы не вкладывать транзакции). Хвост в `create_batch`: не добавлять, если все типы в `{"refresh", "fetch_attachment"}`. `has_writes` в `claim`: `type NOT IN ('refresh', 'fetch_attachment')`. `_check_project`: `fetch_attachment` — как `refresh`, без проверок. `report_result`: для `fetch_attachment` и `done` — проверка `files.size(files_dir, result["file"]) == result["size"]`, иначе статус `failed` с текстом; при успехе `INSERT OR REPLACE INTO attachment_file`. +- [ ] **Step 4: Прогон** — `uv run pytest -q` → PASS. +- [ ] **Step 5: ARCHITECTURE.md** — `queue` (`request_fetch`, запись `attachment_file`), поток «Скачивание вложения». +- [ ] **Step 6: Commit** — `Команда fetch_attachment: постановка, выдача, приём результата (#83)`. + +### Task 4: HTTP: файл от агента, маршруты вложений, уборка + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/jira_hub/files.py` — `put(root, data, allow_empty=False)`, `touch(root, sha) -> None`, `sweep_downloads(root, now=None) -> list[str]` +- Modify: `src/jira_hub/api.py` — `Hub.downloads_dir` (`db_path.parent / "downloads"`), `sweep_files` убирает и `downloads/`; маршруты `POST /agent/files` (agent), `GET /attachments/(\d+)`, `POST /attachments/(\d+)/fetch`, `GET /attachments/(\d+)/content` (client) +- Test: `tests/test_files.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `queue.request_fetch` и его исключения (Task 3), `attachments.get` (Task 2), `api.Raw`. +- Produces: маршруты по `hub-api` «Скачивание вложений»; ответ ошибок — `{error, detail}` как везде. + +- [ ] **Step 1: Тесты** — `test_files.py`: `test_put_allow_empty` (пустой с `allow_empty=True` — sha пустого содержимого, размер 0; без флага — `EmptyFile`); `test_sweep_downloads` (каталог `31337/` старше 30 дней удалён, свежий — нет, файлы в корне `downloads/` не трогаются); `test_touch_updates_mtime`. `test_api.py`: `test_agent_files_post` (агентский токен → `{file, size}`; клиентский → 401; пустой → 200 `size: 0`; 11 МБ → 413); `test_attachment_get_404`; `test_attachment_fetch_flow` (POST fetch → `fetching` + пакет; агент `claim` → `fetch_attachment` с `pause_seconds` 2; `POST /agent/files` + отчёт `done` → `GET /attachments/{id}` `ready`; `GET …/content` → 200, тело, `Content-Type: image/png`, `Content-Disposition: attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`, `X-Content-Type-Options: nosniff`); `test_attachment_content_not_fetched` (404 `not_fetched`); `test_attachment_content_bad_mime` (`mime_type = "weird"` → `application/octet-stream`); `test_attachment_fetch_too_large_413`; `test_attachment_fetch_agent_too_old_409`; `test_sweep_files_downloads` (через `Hub.sweep_files`). +- [ ] **Step 2: Прогон** — `uv run pytest tests/test_files.py tests/test_api.py -q` → FAIL. +- [ ] **Step 3: Реализация.** `Content-Disposition` — `urllib.parse.quote(filename, safe="")`. Выдача содержимого вызывает `files.touch`. +- [ ] **Step 4: Прогон** — `uv run pytest -q` → PASS. +- [ ] **Step 5: ARCHITECTURE.md** — `api` (маршруты, `downloads/` в уборке), `files` (`allow_empty`, `touch`, `sweep_downloads`); `CLAUDE.md` — строка «Данные»: `downloads/` (копии MCP, уборка 30 дней). +- [ ] **Step 6: Commit** — `API вложений: файл от агента, скачивание, уборка копий (#83)`. + +### Task 5: MCP — вложения в `issue`/`comment` и инструмент `attachment` + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/jira_hub/client.py` — `HubClient.get_bytes(path) -> bytes` (ошибки — как у `call`) +- Modify: `src/jira_hub/mcp_server.py` — `fmt_issue`, `fmt_comment`, `fmt_attachment_line`, `safe_name`, инструмент `attachment` +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `GET /issues/{key}` (поля Task 2), маршруты Task 4; `attachments.fmt_size` (разрешить импорт в «Границах» ARCHITECTURE). +- Produces: `safe_name(filename: str, attachment_id: str) -> str` (`/ \ :` и `ord < 32`, `127` → `_`; пусто после `strip()` → `attachment-`); `async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str` (опрос раз в `POLL_SECONDS`/2 с через `asyncio.to_thread`; `wait_seconds` обрезается до `[1, 600]`). + +- [ ] **Step 1: Тесты** (на фейковом клиенте, как прежние тесты `test_mcp.py`): `test_issue_attachments_block` (сценарий «Вложения в карточке» из дельты `mcp`: «вложения (2):», строка #31338 «… — только в Jira», строка #31337 с «в #101, скачано»; у комментария 101 строка «файлы: #31337 скрин 1.png»; без вложений — «вложений нет»); `test_comment_attachments` («вложения:» и «#31337 скрин 1.png (234,4 КБ)»); `test_safe_name` (`"a/b\\c:d\x01.png"` → `"a_b_c_d_.png"`, `" "` → `"attachment-7"`); `test_attachment_by_id_ready` (файл записан в `/downloads/31337/скрин 1.png`, права каталога `0o700`, ответ — путь и строка «скрин 1.png (234,4 КБ), image/png, задача AURORA-1650»); `test_attachment_by_name_ambiguous` (строка «у AURORA-1650 2 вложений «скрин.png», взято самое новое #20»); `test_attachment_not_found` (в хаб после `GET /issues` не ходит); `test_attachment_wait_timeout` (`wait_seconds=1`, всё время `fetching` → «скачивание ещё идёт (команда #N, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится»); `test_attachment_failed`; `test_attachment_413` («… — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»»); `test_attachment_existing_copy_same_size` (не перезаписывает — mtime прежний). +- [ ] **Step 2: Прогон** — `uv run pytest tests/test_mcp.py -q` → FAIL. +- [ ] **Step 3: Реализация.** Каталог данных — `paths.db_path().parent` (в тестах — `JIRA_HUB_HOME` во `tmp_path`); запись — временный файл в том же каталоге + `os.replace`; онлайн агента — `GET /agent` (`online`). +- [ ] **Step 4: Прогон** — `uv run pytest -q` → PASS. +- [ ] **Step 5: ARCHITECTURE.md** — `mcp_server` (инструмент `attachment`, `downloads/`), `client.get_bytes`; описание инструментов в docstring — по-русски, как у соседних. +- [ ] **Step 6: Commit** — `MCP: вложения в issue и comment, инструмент attachment (#83)`. + +### Task 6: Веб — вложения в карточке + +**Модель исполнителя:** sonnet. Перед стилями — скилл `aura-design` (токены `web/src/styles/tokens.css`, компоненты `web/src/ui/`). + +**Files:** +- Modify: `web/src/api/types.ts` — `IssueAttachment`, `AttachmentState`, поля `IssueDetail.attachments?`, `description_attachment_ids?`, `IssueComment.attachment_ids?` +- Modify: `web/src/api/client.ts` — `api.blob(path) -> Promise` (тот же токен и ошибки, что `call`, без разбора JSON при 2xx) +- Create: `web/src/issues/attachments/format.ts` (`fmtSize`, `jiraAttachmentUrl(id, filename)`, `fileKind(mime, filename) -> "image" | "sheet" | "pdf" | "other"`), `web/src/issues/attachments/useAttachmentDownload.ts`, `web/src/issues/attachments/AttachmentList.tsx`, `web/src/issues/attachments/AttachmentChips.tsx`, `web/src/issues/attachments/attachments.css` +- Modify: `web/src/issues/IssueCard.tsx` — раздел «Вложения (N)» после «Описание», плашки под комментарием +- Modify: `web/src/issues/wiki/inline.ts`, `parse.ts`, `WikiText.tsx` — узел `{ t: "attachment"; id: string; children: Inline[] }`; опция `attachments?: { id: string; filename: string; created: string | null }[]` в `WikiOptions`; `WikiText` получает `onAttachment?: (id: string) => void` +- Test: `web/src/issues/attachments/*.test.ts(x)`, `web/src/issues/wiki/inline.test.ts`, `web/src/issues/IssueCard.test.tsx` + +**Interfaces:** +- Consumes: `GET /issues/{key}`, `POST /attachments/{id}/fetch`, `GET /attachments/{id}`, `GET /attachments/{id}/content`. +- Produces: `useAttachmentDownload(issueKey: string) -> { start(a: IssueAttachment): void; status(id: string): { busy: boolean; error: string | null } }` — общий для всех кнопок карточки (одно состояние на вложение); опрос раз в 2000 мс, прекращается при размонтировании или смене `issueKey`; при `ready` — `api.blob` → `URL.createObjectURL` → `` → `revokeObjectURL`; после успеха — инвалидация запроса карточки (отметка «у хаба»). + +- [ ] **Step 1: Тесты** — `format.test.ts` (`fmtSize` как в Global Constraints; `jiraAttachmentUrl("31337", "скрин 1.png")` → `${JIRA_URL}/secure/attachment/31337/%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`); `inline.test.ts` (`!скрин 1.png|thumbnail!` при вложении «скрин 1.png» → узел `attachment` с текстом «скрин 1.png»; `[отчёт|^a.pdf]` → текст «отчёт»; имени нет среди вложений — исходник как есть; без опции `attachments` — как раньше); `useAttachmentDownload.test.tsx` (fake timers: `fetching` → опрос → `ready` → `api.blob` вызван один раз, создана ссылка с `download = "скрин 1.png"`; размонтирование — опросов больше нет; 409 → `error` с текстом хаба); `IssueCard.test.tsx` (сценарии дельты `web-app`: раздел «Вложения (2)» после «Описание», «у хаба» у `ready`, 25 МБ — ссылка «Открыть в Jira» с подсказкой «больше 10 МБ — только в Jira», «размер неизвестен» при `size: null`, плашка под комментарием, «Загружается…» на всех кнопках одного вложения, ошибка + «Повторить»; без поля `attachments` (старый хаб) — раздела нет). +- [ ] **Step 2: Прогон** — `cd web && npm test -- --run` → FAIL. +- [ ] **Step 3: Реализация.** Узкий экран — порядок «заметки, связи, описание, вложения, комментарии, …»; цвета только через токены. +- [ ] **Step 4: Прогон** — `cd web && npm test -- --run && npm run lint && npm run build` → PASS; затем `make check` из корня → PASS. +- [ ] **Step 5: ARCHITECTURE.md** — `web/src/issues/attachments/`; веб-спека не меняется сверх дельты. +- [ ] **Step 6: Commit** — `Веб: вложения в карточке задачи и скачивание (#83)`. diff --git a/openspec/changes/attachment-fetch/.openspec.yaml b/openspec/changes/attachment-fetch/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/attachment-fetch/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/attachment-fetch/design.md b/openspec/changes/attachment-fetch/design.md new file mode 100644 index 0000000..4c2ed18 --- /dev/null +++ b/openspec/changes/attachment-fetch/design.md @@ -0,0 +1,194 @@ +## Context + +`main` da8f5c9, схема v5, агент v13. Устройство, на которое опираются правки: + +- **Зеркало** — `mirror.apply_state`: upsert `issue`, замена `issue_link` и `issue_comment` задачи присланными (поле + `comments` необязательно — без него прежние комментарии остаются). Поля зеркала агент берёт одним `search` + (`state_fields`), `attachment` в них нет. +- **Хранилище файлов** — `files.py`: `<данные>/files/`, `put` (атомарно, предел 10 МБ, повтор обновляет mtime), + `sweep` раз в сутки из `tick` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`). Маршруты + `POST /files` (клиент) и `GET /agent/files/{sha}` (агент, base64). Тело запроса — до 16 МБ (`MAX_BODY`): файл 10 МБ в + base64 ≈ 14 МБ проходит, в том числе через реле папки. +- **Очередь** — пакеты, одна команда в полёте, пауза по `pacing` типа, хвостовой `refresh` по ключам пакета (кроме + пакетов только из `refresh`), довыдача до 3 раз, подтверждение по состоянию. +- **Агент** — `Executor.do_<тип>`; неизвестный тип — `failed` «неизвестный тип команды …». `JiraClient` умеет JSON и + multipart-загрузку (`upload`), считает вызовы и повторяет 429/503. +- **MCP** — ходит в хаб только через `client`; из `files` берёт только `MAX_SIZE`; `HubClient.from_home` знает каталог + данных хаба (`JIRA_HUB_HOME`). +- **Веб** — карточка `IssueCard.tsx`, рендер вики-разметки `web/src/issues/wiki/` (`!картинка!` сейчас — текст как есть), + адрес Jira — константа `JIRA_URL` в `web/src/board/constants.ts`. + +## Goals / Non-Goals + +**Goals:** список вложений задачи в зеркале без содержимого; привязка вложений к описанию и комментариям по ссылкам в +тексте; скачивание одного вложения по запросу из MCP (путь к локальной копии с исходным именем) и из веба (сохранение +файлом); одна команда на вложение при параллельных запросах. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Список вложений — в `refresh`, отдельной таблицей `issue_attachment` (схема v6).** Поле `attachment` в `search` + приходит целиком (без постраничности, в отличие от комментариев), поэтому лишних вызовов Jira нет. Таблица — часть + зеркала: пишет только `apply_state`, присланное `attachments` заменяет список задачи, без поля — список остаётся + (агент ≤ v13). PK — `attachment_id` (id вложения в Jira глобален). `created_at` — UTC для сортировки, как у + комментариев. +2. **Скачанное — отдельная таблица `attachment_file`, не колонка зеркала.** Граница «зеркало пишет только + `apply_state`» сохраняется; скачанный файл переживает замену списка вложений. Строка без файла в хранилище (убран + уборкой) — «не скачано», без отдельной чистки таблицы. +3. **Состояние вложения вычисляется при чтении** (`ready` / `fetching` / `failed` / `none`) из `attachment_file`, + хранилища и последней `fetch_attachment` по этому id. Отдельного статуса нет — нечему рассинхронизироваться. +4. **Команда `fetch_attachment {key, attachment_id}` в общей очереди.** Скачивание — обычная команда: темп, одна + команда в полёте, довыдача, журнал агента и восстановление работают как есть. Пакет из одной команды, без хвостового + `refresh` (в Jira ничего не меняется) и без проекта (вложение соседа тоже можно скачать). Пауза по умолчанию 2 с, как у + `refresh` (только чтение). Подтверждения по состоянию нет: потерянный отчёт закрывает довыдача — повтор безопасен. Код, перебирающий типы + команд, SHALL знать новый тип: `commands.check_confirmed` (иначе `None` и 500 на отчёте после `refresh`), признак + «пакет с записями» в `queue.claim` (иначе копия базы на каждое скачивание), выборка команд задачи в карточке. + *Отвергнуто:* отдельный канал «срочных» запросов мимо очереди — второй цикл в агенте, второй источник темпа к Jira. +5. **Файл от агента к хабу — `POST /agent/files {content}` (base64), в результате команды — только sha.** Результат + команды повторяется каждые 10 с до подтверждения и пишется в журнал агента; 14 МБ base64 там не нужны. Маршрут — + зеркало `POST /files` под агентским токеном, с теми же проверками. Отчёт `done` хаб принимает, только если файл с таким + sha и размером лежит в хранилище. +6. **Адрес содержимого агент собирает сам** — `/secure/attachment//<имя>`, а не берёт `content` из ответа + Jira: Basic-авторизация уходит только на `base_url` (анти-цель). Перед скачиванием — `GET /issue/{key}?fields=attachment`: + проверка, что вложение ещё есть, и размер из Jira до скачивания (больше 10 МБ — не качать). +7. **Одна команда на вложение.** `POST /attachments/{id}/fetch` в одной транзакции записи проверяет `fetching`/`ready` и + ставит пакет. Веб и MCP могут жать сколько угодно — команда одна. +8. **Предел 10 МБ — в трёх местах:** хаб отказывает в постановке по размеру из зеркала (413, без пакета), агент — по + размеру из Jira (на случай, если файл заменили), хранилище — `files.put` (последний рубеж). +9. **Старый агент — 409 `agent_too_old` до постановки.** Агент v13 на `fetch_attachment` ответил бы «неизвестный тип + команды»; хаб проверяет `agent_status.agent_version` и сразу говорит, что нужен v14. Версия неизвестна (агент не + выходил на связь) — ставим: команда дождётся агента. `min_agent_version` не поднимается. +10. **Ссылки на вложения ищет хаб**, а не каждый интерфейс: MCP и веб получают готовые `attachment_ids`. Разбор — + регулярные выражения по описанию и тексту комментариев (`!имя!`, `!имя|…!`, `[^имя]`, `[текст|^имя]`), имя — точное + совпадение, при повторе имени — самое новое вложение (так делает Jira). Веб дополнительно превращает такие ссылки в + отрендеренном тексте в кнопки скачивания по тем же правилам (список вложений у него есть). +11. **MCP кладёт копию с исходным именем в `<данные хаба>/downloads//<имя>`.** Файл хранилища без имени и + расширения Claude не откроет (анти-цель). Каталог по id — нет конфликтов имён; уборка `downloads/` — тем же `tick` + хаба (30 дней). MCP читает файл через `GET /attachments/{id}/content` (граница «MCP — только через `client`»), а не + из `files/` напрямую. +12. **MCP `attachment` ждёт сам** (опрос раз в 2 с, по умолчанию до 120 с, не больше 600): обычно агент успевает за + секунды; не успел — понятный ответ, повторный вызов не ставит вторую команду. +13. **Веб скачивает через `Blob`.** Маршруты хаба требуют токен в заголовке, ссылка `` его не передаст; + `fetch` → `Blob` → временная ссылка `download`. Файл до 10 МБ — память браузера не проблема. Пока агент везёт файл — + опрос раз в 2 с, пока карточка открыта на этой задаче; файл сохраняется сам, без второго нажатия. +14. **Уборка.** Скачанные вложения от уборки не защищены (их можно скачать снова); выдача `GET …/content` обновляет + mtime, так что файлы, которые смотрят, живут дольше. +15. **Порядок выкатки — хаб первым.** Хаб v6 со старым агентом: списков вложений нет (поле не приходит), скачивание + отвечает 409. Агент v14 со старым хабом: поле `attachments` старый хаб игнорирует (`apply_state` берёт известные + колонки), `fetch_attachment` старый хаб не выдаёт. +16. **Скачивания не заслоняют записи.** Пакеты «Скачать вложение …» не делают копию базы (в Jira не пишут) и не входят + в «последние 10 команд по задаче» карточки и MCP `issue`: при разборе ошибки с десятком файлов там остаются правки + моста в Jira. В общем списке пакетов они видны, как есть. +17. **Перенаправления при скачивании.** `urllib` следует 3xx и переносит `Authorization`; агент SHALL не выполнять + перенаправление на другой хост (обработчик перенаправлений или проверка адреса), а на тот же — может. +18. **Неизвестный размер и пустые файлы.** Вложение без размера ставится, агент обрывает чтение после 10 МБ; пустое + вложение принимается от агента (`POST /agent/files`), хотя `POST /files` пустой файл по-прежнему отвергает. + +## Risks / Trade-offs + +- **Ожидание в очереди.** Скачивание стоит за командами, поставленными раньше (полный `refresh` идёт минуты). Принимаем: + MCP отвечает «ещё идёт», веб показывает «Загружается…». +- **Объём через общую папку.** Файл 10 МБ — запрос ≈ 14 МБ JSON через реле. В пределах `MAX_BODY`; если реле окажется + медленным на таких файлах — снижать предел, а не менять транспорт. +- **Имя с запрещёнными символами** (`:` и др. — Jira банка их хранит, см. #43) — в копии MCP заменяются на `_`; в вебе + браузер правит имя сам. +- **Ссылка по имени после переименования** в Jira не находит вложение — показывается исходником. Редко. +- **Удалённое в Jira вложение** остаётся в зеркале до ближайшего `refresh` задачи; скачивание ответит понятной ошибкой. + +## Migration Plan + +1. PR хаба: схема v6, `attachments` в `apply_state`, `POST /agent/files`, `fetch_attachment` в контракте и очереди, + маршруты `/attachments/…`, ссылки в `GET /issues/{key}`, MCP (`issue`, `comment`, `attachment`), веб. После merge — + `git pull`, `make web`, перезапуск хаба владельцем, `/mcp` → reconnect. +2. PR агента: v14 (`attachments` в состоянии, `fetch_attachment`). После merge — `jira-hub agent publish + ../jira-agent/src/agent.py`; `agent_status` показывает v14; `min_agent_version` не поднимать. +3. Живая проверка: полный `refresh` → у задач с вложениями список в `issue`; MCP `attachment` по скриншоту и по Excel + (кириллическое имя с пробелами) → путь, файл открывается; повторный вызов — без новой команды; кнопка «Скачать» в + карточке → файл сохранён; вложение > 10 МБ, если найдётся, → отказ без команды. + +Откат: агент — публикуется v15 без новых возможностей (только вверх); хаб — предыдущий коммит, таблицы v6 остаются и не +мешают, `schema_version = 6` старый хаб не понизит. + +## Open Questions + +Нет. + +## Decisions made without discussion + +1. 2026-10-22 — **привязка вложения к комментарию — только по ссылке в тексте** (`!имя!`, `[^имя]`), без догадок по + автору и времени. Обоснование: Jira сама пишет ссылку при перетаскивании файла в комментарий; эвристика даёт ложные + привязки. Цена ошибки: файл, приложенный без ссылки, виден только в общем списке — добавить эвристику позже. +2. 2026-10-22 — **скачивание — команда общей очереди (`fetch_attachment`), а не отдельный канал.** Обоснование: темп, + довыдача, журнал агента — без нового кода. Цена ошибки: ожидание за длинным `refresh`; если мешает — приоритет + `fetch_attachment` в выдаче. +3. 2026-10-22 — **файл от агента — отдельным маршрутом `POST /agent/files`, в отчёте только sha.** Обоснование: отчёт + повторяется и пишется в журнал агента. Цена ошибки: нет. +4. 2026-10-22 — **адрес содержимого собирает агент из `base_url`.** Обоснование: учётные данные не уходят на чужой адрес. + Цена ошибки: если в Jira банка адрес вложений отличается от `/secure/attachment//<имя>` — выяснится на живой + проверке, поправить сборку адреса. +5. 2026-10-22 — **копия для Claude — `<данные хаба>/downloads//<имя>`, уборка 30 дней.** Обоснование: исходное имя и + расширение нужны, чтобы Claude открыл файл; каталог хаба уже под `0700`. Цена ошибки: Claude в другом репозитории + может спросить разрешение на чтение вне проекта — один раз на сессию. +6. 2026-10-22 — **старый агент — 409 `agent_too_old` до постановки, `min_agent_version` не поднимается.** Обоснование: + понятная ошибка вместо «неизвестный тип команды», старый агент продолжает работать. Цена ошибки: нет. +7. 2026-10-22 — **MCP `attachment` ждёт по умолчанию 120 с, максимум 600.** Обоснование: обычно агент успевает за + секунды, а чат не должен висеть дольше. Цена ошибки: при занятой очереди — повторный вызов. +8. 2026-10-22 — **ссылки на вложения в тексте веба — кнопка со скрепкой, без картинки.** Обоснование: решение владельца + «превью не делаем»; ссылка в тексте ведёт к тому же скачиванию. Цена ошибки: нет. +9. 2026-10-22 — **пауза `fetch_attachment` по умолчанию 2 с.** Обоснование: только чтение, как `refresh`. Цена ошибки: + нет — меняется настройкой `pacing`. +10. 2026-10-22 — **пакеты скачивания не делают копию базы и не входят в «последние 10 команд по задаче».** Обоснование: + находка рецензента — иначе серия скачиваний вытесняет копии перед записями и команды-записи из карточки. Цена + ошибки: скачивания по задаче видны только в списке пакетов. +11. 2026-10-22 — **перенаправление на другой хост при скачивании — `failed`, без перехода.** Обоснование: находка + рецензента — `urllib` унёс бы `Authorization` на чужой хост. Цена ошибки: если Jira банка отдаёт вложения через + другой хост — скачивание не работает до решения владельца, видно на живой проверке. +12. 2026-10-22 — **вложение без размера ставится, предел 10 МБ проверяет агент по прочитанному.** Обоснование: Jira + может не отдать размер; отказывать в таком вложении — терять файл. Цена ошибки: агент читает до 10 МБ лишнего. +13. 2026-10-22 — **пустое вложение принимается от агента.** Обоснование: в Jira бывают пустые файлы; иначе вечный + `failed`. Цена ошибки: нет. +14. 2026-10-22 — **`command_id`/`batch_id` в состоянии вложения: при `fetching` — у живой (`queued`/`claimed`) команды, иначе у + последней `fetch_attachment` по этому id; `ready` требует и строку `attachment_file`, и файл в хранилище.** Обоснование: + повторный `POST …/fetch` должен вернуть ту же команду, что идёт, а не историческую; строка без файла — «не скачано» + (решение 2). Цена ошибки: интерфейс покажет чужой command_id в нейтральных состояниях — правка одной функции. + (R-1, правило исполнителя Task 2.) +15. 2026-10-22 — **при повторе имени «самое новое» вложение — максимум по `(created_at, число id, id)`.** Обоснование: + детерминированный выбор при равных временах (Jira отдаёт id по возрастанию). Цена ошибки: ссылка по имени укажет на + соседнее вложение — исправляется порядком ключа. +16. 2026-10-22 — **порядок отказов в `request_fetch`: сначала `agent_too_old` (409), потом размер (413).** Обоснование: старый + агент не скачает и маленький файл, а размер человек увидит и так; отказ по агенту не зависит от вложения. Цена ошибки: + для большого файла при старом агенте покажем 409 вместо 413 — несущественно. (R-3, T3.1.) +17. 2026-10-22 — **`TooLarge` наследует `CommandError` (в `POST /batches` — 422), в маршруте `…/fetch` ловится раньше и даёт + 413; `attachment_id` в команде — только цифры; оба отказа `done` (нет файла, чужой размер) дают один текст «хаб: файл + не найден в хранилище».** Обоснование: одна проверка размера на постановку из любого входа; Jira-id — число. + Цена ошибки: поменять класс исключения / текст. (R-4, решение исполнителя Task 3.) +18. 2026-10-22 — **`pacing_from_config` и запасной зазор: `fetch_attachment` = 2 с и там, и в `DEFAULT_PACING`.** Обоснование: + иначе `init` из config расходится с умолчанием и сохранённая настройка без нового типа ломает выдачу. Цена ошибки: нет. + (R-5.) +19. 2026-10-22 — **`sweep_downloads` считает возраст каталога вложения по самому новому из mtime каталога и его файлов; сбой + уборки `downloads/` логируется и не мешает уборке `files/`.** Обоснование: копию, которую открывали недавно, не + удалять; побочная уборка не должна ломать основную. Цена ошибки: преждевременное или запоздалое удаление копии + (её MCP создаст заново). (R-6, решение исполнителя Task 4.) +20. 2026-10-22 — **неизвестный id вложения в `GET /attachments/{id}` — 404 `no_attachment`; `Content-Type` из `mime_type` только + при формате тип/подтип, иначе `application/octet-stream`.** Обоснование: единые коды вложений; заголовок не берётся + из недоверенных данных Jira как есть. Цена ошибки: нет. (R-7.) +21. 2026-10-22 — **`safe_name`: имя «.» и «..» заменяется на `attachment-`.** Обоснование: без этого «..» (разделители уже заменены) остаётся именем файла + в каталоге копий; укрепление против обхода пути. Цена ошибки: нет. (R-8, решение исполнителя Task 5.) +22. 2026-10-22 — **опрос MCP `attachment` — раз в `POLL_SECONDS/2` (2,5 с), а не ровно 2 с; строка «взято самое новое» — только в ответе + `ready`.** Обоснование: так сказано в плане, интервал в спеке приблизителен; на таймауте/ошибке важнее их текст. Цена ошибки: нет. (R-9.) +23. 2026-10-22 — **веб, решения исполнителя Task 6:** (а) карточка, открытая с вложением в `fetching`, автоопрос не запускает до + нажатия (кнопка присоединяется к идущей команде); (б) «Загружается…» в тексте и плашках — приписка к имени; (в) ссылки на + вложения не разбираются внутри подписи другой ссылки; (г) предел 10 МБ продублирован во фронте константой (комментарий на + `files.MAX_SIZE`, тест границы); (д) порядок «новые сверху» — по `created`, затем числовому id; (е) плашки под комментарием — в порядке + `attachment_ids`, неизвестный id пропускается; `description_attachment_ids` веб не использует. Обоснование: минимальный код, + спека не требует иного. Цена ошибки: расхождение предела при смене `MAX_SIZE` на хабе (правка константы); автоопрос при + открытии — добавить позже. (R-10.) +24. 2026-10-22 — **`TooLarge` в `POST /batches` и повторе команды — 413 `too_large`, как требует спека `commands`; это + уточняет решение 17 (там 422 для `POST /batches` отменено).** Обоснование: спека — источник истины; одинаковый код + для одной причины отказа во всех входах. Цена ошибки: клиент, ждавший 422, получит 413 — вход новый, клиентов нет. + (R-11, финальное ревью M-2.) +25. 2026-10-22 — **разбор `!имя!` в хабе: перекрывающееся совпадение с условием «после открывающего `!` не пробел» (`(?!\s)`).** + Обоснование: одиночный «!» перед ссылкой («Внимание! см. !скрин.png!») съедал открывающий `!` настоящей ссылки; Jira не + рисует `! x!` с пробелом. Цена ошибки: возможны лишние кандидаты-имена, их отсеивает `resolve` (нет вложения с таким + именем). (R-12, финальное ревью I-1.) diff --git a/openspec/changes/attachment-fetch/proposal.md b/openspec/changes/attachment-fetch/proposal.md new file mode 100644 index 0000000..433efc1 --- /dev/null +++ b/openspec/changes/attachment-fetch/proposal.md @@ -0,0 +1,49 @@ +## Why + +Тестировщики прикладывают к ошибкам AURORA файлы: скриншоты, выгрузки в Excel (ОСВ), иногда PDF и JSON. Владелец +разбирает ошибки в чате с Claude, и Claude должен сам открыть эти файлы. Сейчас мост файлы из Jira не забирает совсем: +зеркало не знает даже, что у задачи есть вложения (агент умеет только загружать файл в Jira — `ensure_attachment`, +change `bug-and-attachments`). + +Решения владельца 22.10.2026 (brainstorming, jira-hub#83, jira-agent#43): +- **при синхронизации — только список вложений** (имя, размер, автор, дата), сами файлы автоматически не тянутся; +- **файл — по запросу**: агент скачивает нужное вложение из Jira и отдаёт хабу; +- **где**: в MCP (Claude получает путь к локальному файлу и открывает его) и в веб-приложении (кнопка «Скачать»); +- **из комментария**: файлы, на которые ссылается комментарий тестировщика, видны и скачиваются прямо у комментария — + в карточке, в MCP `issue` и `comment`, а не только в общем списке задачи; +- **предел 10 МБ** на файл, как у загрузки; крупнее — скачивать в Jira руками; +- **превью скриншотов в карточке не делаем**: для него файлы пришлось бы качать заранее. + +## What Changes + +- **Список вложений в зеркале.** Агент v14 в `refresh` добавляет поле `attachment` к `search` и шлёт у каждой задачи + `attachments` (id, имя, размер, тип, автор, дата) без содержимого. Хаб хранит их в новой таблице `issue_attachment` + (схема v6), заменяя список задачи целиком, как комментарии. +- **Ссылки из текста.** Хаб находит в описании и комментариях вики-ссылки на вложения (`!имя!`, `!имя|…!`, `[^имя]`) и + отдаёт у комментария `attachment_ids`, у задачи — `description_attachment_ids`. +- **Команда `fetch_attachment`.** Хаб ставит её по запросу интерфейса отдельным пакетом без хвостового `refresh`; агент + читает вложение в Jira, скачивает содержимое и отдаёт хабу новым маршрутом `POST /agent/files`; хаб кладёт файл в + хранилище `files/` (то же, что для загрузки; уборка через 30 дней) и запоминает в `attachment_file`. +- **API.** `GET /attachments/{id}` (с состоянием `none` / `fetching` / `ready` / `failed`), `POST /attachments/{id}/fetch` + (одна команда на вложение, сколько бы раз ни просили), `GET /attachments/{id}/content` (файл с исходным именем). + `GET /issues/{key}` отдаёт вложения и ссылки на них. +- **MCP.** `issue` и `comment` показывают вложения; новый инструмент `attachment` скачивает файл (ждёт агента до + `wait_seconds`) и отвечает путём к локальной копии с исходным именем в `<данные хаба>/downloads//`. +- **Веб.** В карточке раздел «Вложения (N)», плашки файлов под комментарием, ссылки на вложения в отрендеренном тексте — + все скачивают файл одной кнопкой; «Загружается…», пока агент везёт файл. + +## Non-Goals + +- Превью картинок в карточке и показ `!картинка!` картинкой. +- Скачивание файлов больше 10 МБ — только ссылка на Jira. +- Автоматическая подкачка файлов (заранее, при синхронизации, по подписке). +- Удаление и переименование вложений в Jira из моста. +- Привязка файла к комментарию по автору и времени — только по ссылке в тексте. + +## Anti-Goals + +- Синхронизация стала тяжелее: при `refresh` агент качает содержимое вложений или делает лишние вызовы Jira на задачу. +- Учётные данные Jira уходят на адрес не из `base_url` агента (адрес `content` из ответа Jira). +- Повторные нажатия и параллельные запросы из веба и MCP ставят несколько одинаковых скачиваний. +- Claude получает файл без имени и расширения и не может его открыть. +- Старый агент (≤ v13) или старый хаб ломают синхронизацию. diff --git a/openspec/changes/attachment-fetch/specs/agent/spec.md b/openspec/changes/attachment-fetch/specs/agent/spec.md new file mode 100644 index 0000000..70dc869 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/agent/spec.md @@ -0,0 +1,71 @@ +## MODIFIED Requirements + +### Requirement: Чтение состояния +`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment`, `attachment` и спринт, +`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет +в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)` +и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в +`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб». +Каждая задача в состоянии (с v10) SHALL нести `comments` — все комментарии задачи в порядке Jira, каждый +`{id, author, created, updated, body}`: `id` — строка; `author` — отображаемое имя автора, иначе его логин, иначе `null`; +`created`, `updated` — время Jira как есть; `body` — текст как есть (пустой — `""`). Если `search` отдал у задачи меньше +комментариев, чем `comment.total`, агент SHALL дочитать комментарии этой задачи через `GET /issue/{key}/comment` +постранично (`maxResults = 100`) и передать полный список. Комментарий без `id` или с пустым `id` агент SHALL отбросить +с записью в лог (ключ задачи), остальные передать. Если дочитывание комментариев задачи вернуло 403 или 404, агент SHALL +передать задачу без поля `comments` (хаб оставит прежние комментарии) с записью в лог; `refresh` продолжается. +Каждая задача в состоянии (с v14) SHALL нести `attachments` — все вложения задачи из поля `attachment` ответа `search` +в порядке Jira, каждое `{id, filename, size, mime_type, author, created}`: `id` — строка; `filename` — имя как есть; +`size` — целое (байт) или `null`; `mime_type` — `mimeType` Jira или `null`; `author` — как у комментария; `created` — +время Jira как есть. Содержимое вложений при `refresh` MUST NOT скачиваться. Вложение без `id` агент SHALL отбросить с +записью в лог (ключ задачи). Нет поля `attachment` в ответе (поле скрыто настройками Jira) — `attachments = []`. + +#### Scenario: Полная синхронизация +- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах +- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи` + +#### Scenario: Хаб не ответил при отправке состояния +- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток +- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через + папку …»; в логе повторов — «обрыв связи с хабом» + +#### Scenario: Комментарии в состоянии +- **WHEN** у AURORA-1620 в Jira два комментария, автор первого — `{name: "petrov", displayName: "Петров Пётр"}`, у автора + второго нет `displayName`, его `name` — `ivanov` +- **THEN** в состоянии AURORA-1620 `comments` из двух элементов в порядке Jira, `author` — «Петров Пётр» и «ivanov»; число + вызовов `search` не изменилось + +#### Scenario: Неполный список комментариев +- **WHEN** `search` вернул у AURORA-1620 20 комментариев при `comment.total = 130` +- **THEN** агент дочитал комментарии двумя вызовами `GET /issue/AURORA-1620/comment`; в состоянии 130 комментариев + +#### Scenario: Комментарий без id +- **WHEN** у задачи два комментария, у второго `id = null` +- **THEN** в состоянии один комментарий; в логе строка с ключом задачи; пачка принята хабом + +#### Scenario: Нет доступа к комментариям +- **WHEN** `GET /issue/AURORA-1620/comment` при дочитывании вернул 403 +- **THEN** AURORA-1620 в состоянии без поля `comments`; остальные задачи пачки с комментариями; `refresh` — `done` + +#### Scenario: Задача без комментариев +- **WHEN** у задачи в Jira нет комментариев +- **THEN** в её состоянии `comments = []` + +#### Scenario: Вложения в состоянии +- **WHEN** у AURORA-1650 в Jira два вложения: «скрин 1.png» (`mimeType = image/png`, 240000 байт, автор + `{name: "potapova", displayName: "Потапова Анна"}`) и «ОСВ 60.xlsx» +- **THEN** в состоянии AURORA-1650 `attachments` из двух элементов в порядке Jira, у первого `filename = "скрин 1.png"`, + `size = 240000`, `mime_type = "image/png"`, `author = "Потапова Анна"`; число вызовов `search` не изменилось, обращений + к содержимому вложений нет + +## ADDED Requirements + +### Requirement: Скачивание вложения +Агент v14 SHALL выполнять `fetch_attachment` по контракту `commands` («Скачивание вложения по запросу»). Метод `JiraClient` +для содержимого SHALL отдавать байты ответа без разбора JSON, с тем же счётом вызовов, повтором 429/503 и учётом времени +последнего вызова, что и прочие вызовы Jira. Отправка хабу — `HubClient` (`POST /agent/files`), работает и через общую +папку. В журнал агента (`journal.jsonl`) и в лог содержимое файла MUST NOT попадать — только id, имя, размер и sha256. + +#### Scenario: Скачивание через общую папку +- **WHEN** агент с `hub_url = folder:…` выполняет `fetch_attachment` вложения 3 МБ +- **THEN** содержимое уходит хабу одним запросом `POST /agent/files` через папку (тело ≈ 4 МБ base64); отчёт `done` с + `file` и `size`; в `journal.jsonl` нет содержимого файла diff --git a/openspec/changes/attachment-fetch/specs/commands/spec.md b/openspec/changes/attachment-fetch/specs/commands/spec.md new file mode 100644 index 0000000..e3bf89c --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/commands/spec.md @@ -0,0 +1,241 @@ +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. `fetch_attachment` в Jira не пишет: она читает файл вложения и передаёт его хабу; повтор безопасен. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); `board_id` хаб дописывает при постановке (от клиента — 422) | `GET /issue/{key}` (поле спринта), список спринтов доски `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `ensure_attachment` | `key`, `file` (sha256 файла в хранилище хаба, 64 hex-символа), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче | `GET /issue/{key}?fields=attachment` | вложения с тем же `filename` и размером нет — файл у хаба (`GET /agent/files/{file}`), затем `POST /issue/{key}/attachments` (multipart); есть — ничего; результат `attachment_id` | +| `refresh` | `keys` (список) или `jql`; `boards` (`[{id, type}]` — доски проектов, `type` — `scrum`, `kanban` или `null`) хаб дописывает при выдаче | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks`; доски с неизвестным типом — `GET /rest/agile/1.0/board/{id}` | ничего в Jira; состояние уходит в хаб; результат — `boards: {"": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал | +| `fetch_attachment` | `key`, `attachment_id` (id вложения Jira, строка из цифр) | `GET /issue/{key}?fields=attachment` (имя и размер вложения) | ничего в Jira; содержимое — `GET /secure/attachment/{id}/{имя}`, затем хабу `POST /agent/files`; результат `{attachment_id, file, size}` | + +Доску для `ensure_sprint` хаб SHALL определять при постановке: проект задачи — проекты, чьи метки есть у задачи в +зеркале (для временного ключа — в `labels` её `ensure_issue` в пакете, после дописывания метки проекта); `board_id` — +доска этого проекта. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта +нет доски; тип доски `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («тип доски проекта P +ещё не известен — дождитесь обновления из Jira»). `boards` у `refresh` хаб SHALL дописывать при каждой выдаче (в базе +не хранится): все доски проектов без повторов. По результату `refresh` хаб SHALL записать присланные типы `scrum` и +`kanban` в проекты с этой доской; `null` тип не меняет. + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira +без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный +в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Описание отличается только переводами строк +- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"` +- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание ошибки +- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках + `issue_types.bug = {name: "Ошибка"}` без `id` +- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом + +#### Scenario: Вложение уже есть +- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того + же размера +- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1` + +#### Scenario: Вложение с тем же именем, другого размера +- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера +- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id` + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +#### Scenario: Спринт задачи проекта +- **WHEN** у AURORA-1538 метка `AWPCA`, проект `AWPCA` с доской 4216 типа `scrum`; `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2"}` +- **THEN** пакет создан, в параметрах команды `board_id = 4216` + +#### Scenario: Спринт на канбане +- **WHEN** у AURORA-1 метка `STOCKS_DRRK`, доска проекта — `kanban`; `ensure_sprint {key: AURORA-1, sprint: "Спринт 2"}` +- **THEN** 422 «у проекта STOCKS_DRRK канбан-доска, спринтов нет» + +#### Scenario: Тип доски определён +- **WHEN** проект `STOCKS_DRRK` с доской 5120 и `board_type = null`; выдаётся `refresh`, агент вернул `boards: {"5120": "kanban"}` +- **THEN** при выдаче в параметрах `boards` есть `{id: 5120, type: null}`; после отчёта у проекта `board_type = kanban` + +### Requirement: Пакет и порядок выполнения +Пакет SHALL создаваться одним вызовом с автором, заголовком и списком команд; хаб SHALL назначить `seq` по порядку списка +и добавить в хвост `refresh` по всем ключам пакета (кроме пакетов, состоящих только из `refresh` и `fetch_attachment`). Хаб SHALL проверять +типы параметров: текстовые параметры и ключи — строки (`null` у необязательного параметра — «не задан»; агент +трактует его по контракту типа, для `ensure_fields` — очистка поля), `target` команды +`ensure_status` — строка или список строк, `labels`, `labels_add`, `labels_remove`, `keys` — списки строк, `replace` — +логическое значение; нарушение — 422 с именем параметра. Пакет с двумя и более `ensure_issue` с одинаковой `summary` +(после обрезки пробелов по краям) SHALL отвергаться (422). Очередь SHALL выдавать команды в порядке +`(batch.created_at, seq)`, одну в полёте. Пакет и его `queued` команды MAY быть отменены; `claimed` команда MUST NOT +отменяться автоматически. + +#### Scenario: Оценка длительности +- **WHEN** создаётся пакет из 3 `ensure_fields` и 2 `ensure_comment` +- **THEN** `estimate_seconds` = сумма пауз по политике для последовательности типов + пауза `refresh` + +#### Scenario: Две задачи с одной темой +- **WHEN** пакет содержит `ensure_issue {temp_key: new:1, summary: "Отчёт"}` и `ensure_issue {temp_key: new:2, summary: " Отчёт "}` +- **THEN** 422 с темой «Отчёт», пакет не создан + +#### Scenario: Параметр не того типа +- **WHEN** пакет содержит `ensure_fields {key: X, labels_add: "AWPCA"}` +- **THEN** 422 с указанием `labels_add`, пакет не создан + +### Requirement: Политика темпа и пауза на команде +Хаб SHALL хранить `pacing` как минимальные зазоры в секундах по типу команды (по умолчанию: `ensure_issue` и +`ensure_fields` 120, `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30, `refresh` и `fetch_attachment` 2; тип, которого нет в +сохранённой настройке `pacing`, получает зазор по умолчанию). При выдаче команды +хаб SHALL сообщить `pause_seconds` = зазор по типу выдаваемой команды; агент SHALL выдержать его от времени своего +последнего вызова Jira. Внутри одной команды с несколькими записями (`ensure_link` с `replace`, путь переходов) агент +SHALL выдерживать зазор типа команды между записями. + +#### Scenario: Нет следующей команды — нет паузы +- **WHEN** очередь пуста +- **THEN** `claim` возвращает пустой ответ, агент не спит сверх долгого запроса + +### Requirement: Ошибки и подтверждение по состоянию +Ошибка команды SHALL записываться в `error` (HTTP-код, текст Jira); хаб SHALL хранить не больше 1500 символов `error`, +обрезая присланное. Пакет после ошибки продолжается. + +**Повторная выдача.** Агент запрашивает новую команду, только когда у него нет незавершённой (см. `agent`), поэтому +команда в `claimed` к моменту следующего `claim` считается не доставленной или потерянной агентом. При `claim` хаб SHALL +сначала выдать снова самую старую (по `claimed_at`, затем `id`) команду в `claimed`: тот же `id` и параметры, +`pause_seconds` по текущей политике темпа, новый `claimed_at`, `deliveries + 1`. Если у неё уже `deliveries >= 3`, хаб SHALL +перевести её в `failed` с `error = "агент не отчитался после 3 выдач"` и перейти к следующей (довыдача другой `claimed` +или следующая `queued`). Довыдача — доставка той же выдачи, а не повтор команды: новых команд хаб MUST NOT создавать +сам, повтор после `failed` — новая команда с теми же параметрами по запросу интерфейса. Если агент всё же отчитался +`done` с ключом по `ensure_issue`, переведённой в `failed` по пределу, хаб SHALL записать ключ в результат и перенести +заметку, не меняя статус (как для отменённой, см. `hub-api`). + +**Подтверждение по состоянию.** Если после любого `refresh` желаемый результат команды в `claimed` виден в зеркале, хаб +SHALL перевести её в `confirmed_by_state`: `ensure_fields` — все поля совпадают (строки сравниваются без различия +`\r\n` и `\n`; `null` и пустая строка равны); `ensure_status` — статус равен последнему в пути; `ensure_link` — связь +есть и конфликтов нет; `ensure_sprint` — спринт совпадает. `ensure_issue`, `ensure_comment`, `ensure_attachment`, `fetch_attachment` и `refresh` по +состоянию не подтверждаются (зеркало не показывает ни загрузку вложения, ни скачанный файл): потерянный отчёт по ним закрывается повторной выдачей (агент находит созданную задачу поиском дубля) +или отменой человеком. + +#### Scenario: Отчёт потерян, факт на месте +- **WHEN** `ensure_sprint {key: X, sprint: "Спринт 2"}` в `claimed`, после выполненного `refresh` зеркало показывает X в «Спринт 2» +- **THEN** команда `confirmed_by_state` + +#### Scenario: Описание с CRLF в зеркале +- **WHEN** `ensure_fields {key: X, description: "a\nb"}` в `claimed`, после `refresh` в зеркале у X `description = "a\r\nb"` +- **THEN** команда `confirmed_by_state` + +#### Scenario: Ответ на claim потерян +- **WHEN** команда 40 выдана (`deliveries = 1`), ответ до агента не дошёл, агент снова прислал `claim` +- **THEN** `claim` отдаёт команду 40, `deliveries = 2`; в очереди есть `queued` команды, но выдаётся 40 + +#### Scenario: Предел выдач +- **WHEN** команда 41 в `claimed` с `deliveries = 3`, агент снова прислал `claim`, в очереди есть команда 42 +- **THEN** 41 — `failed` «агент не отчитался после 3 выдач»; `claim` отдаёт 42 + +#### Scenario: Потерянный отчёт о создании задачи +- **WHEN** `ensure_issue {temp_key: new:5, summary: "Отчёт"}` выполнена агентом, отчёт не дошёл, агент прислал `claim` +- **THEN** команда выдаётся снова; агент находит созданную задачу поиском дубля и отчитывается `done` с её ключом; второй + задачи нет, заметка `new:5` перенесена на ключ + +### Requirement: Пакет проекта +Пакет, поставленный с `project = P` (см. `hub-api`), SHALL проходить при постановке, до записи, проверки проекта; +любая неудача — 422 с ключом задачи и причиной, пакет не создаётся: +- `P` нет среди проектов — 422; +- `ensure_issue`: если в `labels` нет P, хаб SHALL дописать P в конец `labels` (в базе параметры хранятся уже с ней; + без `labels` — `labels = [P]`); метка, совпадающая с P без учёта регистра, но не точно, SHALL заменяться на P; метка + другого проекта в `labels` — 422; +- `ensure_fields`: P в `labels_remove` или метка другого проекта в `labels_add` — 422; +- `ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment`: `key` SHALL быть временным + ключом `ensure_issue` этого пакета или задачей зеркала с меткой P, иначе 422 « не в проекте P»; +- `ensure_link`: хотя бы один из `source`, `target` — временный ключ этого пакета или задача зеркала с меткой P; +- `refresh` и `fetch_attachment` — без проверок проекта. +«Метка другого проекта» и «P в `labels_remove`» SHALL сравниваться без учёта регистра; принадлежность задачи проекту +(метка P у задачи зеркала) — точным совпадением, как в разрезе проекта. Пакет без `project` эти проверки не проходит, +метки хаб в нём не дописывает. + +#### Scenario: Метка проекта дописана +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {temp_key: new:1, summary: "Сверка остатков", labels: ["kind-dev"]}` +- **THEN** пакет создан; в параметрах команды `labels = ["kind-dev", "STOCKS_DRRK"]` + +#### Scenario: Чужая метка проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {…, labels: ["awpca"]}`, есть проект `AWPCA` +- **THEN** 422 «метка другого проекта: awpca», пакет не создан + +#### Scenario: Задача другого проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_fields {key: AURORA-1538, duedate: …}`, у AURORA-1538 метка только `AWPCA` +- **THEN** 422 «AURORA-1538 не в проекте STOCKS_DRRK» + +#### Scenario: Связь с соседом +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_link {source: AURORA-1 (метка STOCKS_DRRK), target: AURORA-1538 (метка AWPCA), …}` +- **THEN** пакет создан + +## ADDED Requirements + +### Requirement: Скачивание вложения по запросу +`fetch_attachment {key, attachment_id}` SHALL ставиться только хабом по запросу интерфейса (`POST /attachments/{id}/fetch`, +см. `hub-api`) — отдельным пакетом из одной команды, без хвостового `refresh` и без проекта (`batch.project = NULL`), +заголовок «Скачать вложение ()»; через `POST /batches` её MAY поставить и клиент, с теми же проверками. +При постановке хаб SHALL проверять: `attachment_id` — непустая строка из цифр; вложение с этим id есть в зеркале у задачи +`key` (иначе 422 «у нет вложения # в зеркале — обновите задачу»); его размер в зеркале не больше 10 МБ +(10 485 760 байт) — иначе 413 `too_large` «файл больше 10 МБ (<размер>) — скачайте в Jira»; размер неизвестен (`NULL`) — +ставится, предел проверяет агент. Агент SHALL выполнять её так: +1. `GET /issue/{key}?fields=attachment`; вложения с этим id нет — `failed` «вложение # удалено из в Jira»; +2. размер по Jira больше 10 МБ — `failed` «файл больше 10 МБ (<размер>) — скачайте в Jira», содержимое не качается; + размер неизвестен — качается, но чтение ответа SHALL обрываться на 10 485 761-м байте с тем же `failed`; +3. содержимое — `GET` по адресу, собранному из `base_url` агента: `/secure/attachment//<имя в URL-кодировке>` + (адрес `content` из ответа Jira MUST NOT использоваться: учётные данные уходят только на `base_url`), таймаут не + меньше 120 с; ответ Jira 429/503 — повтор по политике агента; перенаправление (3xx) агент MUST NOT выполнять с + учётными данными на другой хост (схема, хост и порт не как у `base_url`) — `failed` «Jira перенаправила скачивание на + <хост>»; длина полученного не равна известному размеру из шага 1 — `failed` «файл пришёл не целиком (<получено> из + <размер>)»; пустое вложение (0 байт) — допустимо; +4. `POST /agent/files {content}` (base64) хабу; ответ `{file, size}`; ошибка хаба — с префиксом «хаб: », недоступность + хаба повторяется, как при отправке состояния; +5. результат `done` `{attachment_id, file, size}`. +По отчёту `done` хаб SHALL записать скачанный файл вложения (см. `data-model`, «Скачанные вложения»). + +#### Scenario: Скачивание скриншота +- **WHEN** у AURORA-1650 в зеркале вложение #31337 «скрин 1.png» 240 000 байт; поставлена `fetch_attachment {key: + AURORA-1650, attachment_id: "31337"}` +- **THEN** агент читает задачу, качает `/secure/attachment/31337/%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`, + отправляет содержимое хабу; отчёт `done {attachment_id: "31337", file: , size: 240000}`; в хвосте пакета + `refresh` нет + +#### Scenario: Вложение удалили в Jira +- **WHEN** вложение #31337 в зеркале есть, в Jira его уже нет +- **THEN** команда `failed` «вложение #31337 удалено из AURORA-1650 в Jira»; хаб файл не записывает + +#### Scenario: Слишком большой файл +- **WHEN** в зеркале у вложения #40000 размер 25 000 000 байт; интерфейс просит его скачать +- **THEN** 413 `too_large` «файл больше 10 МБ (23,8 МБ) — скачайте в Jira», пакет не создан + +#### Scenario: Размер неизвестен +- **WHEN** у вложения #50 в зеркале `size = NULL`, в Jira размера тоже нет, содержимое — 12 МБ +- **THEN** постановка проходит; агент обрывает чтение после 10 МБ, команда `failed` «файл больше 10 МБ (больше 10,0 МБ) — + скачайте в Jira»; хабу ничего не отправлено + +#### Scenario: Перенаправление на другой хост +- **WHEN** Jira на `GET /secure/attachment/31337/…` ответила 302 на `https://files.example/…` +- **THEN** агент туда не ходит, команда `failed` «Jira перенаправила скачивание на files.example» diff --git a/openspec/changes/attachment-fetch/specs/data-model/spec.md b/openspec/changes/attachment-fetch/specs/data-model/spec.md new file mode 100644 index 0000000..f6349e8 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/data-model/spec.md @@ -0,0 +1,141 @@ +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_attachment`, +`attachment_file`, `issue_note`, `batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при +старте по номеру версии в `setting.schema_version` (текущая — 6). Миграция существующей базы SHALL выполняться в одной +транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени +Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`, `issue_attachment.created`) и значений полей журнала. + +Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю +настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта +`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе +никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в +`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи +`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем +перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с +прежним значением. + +Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и +`sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не +разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`. + +Миграция v5 → v6 SHALL: создать пустые `issue_attachment` (с индексом по `key`) и `attachment_file`. Списки вложений +появляются с первым `refresh` агента v14. + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` + +#### Scenario: Миграция базы v1 +- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая + `issue_comment`; данные на месте; `schema_version = 6` + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 6` + +#### Scenario: Миграция базы v3 +- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, + в `sync_run` полный прогон с этой `query` +- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` + и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки, + пакеты на месте; `schema_version = 6` + +#### Scenario: Миграция базы v4 +- **WHEN** хаб стартует с базой `schema_version = 4`, у задачи X комментарий с `created = "2026-10-22T13:05:11.000+0300"`, + в `sync_run` полный прогон +- **THEN** в `backups/` копия базы v4; есть пустая `issue_change`; у комментария `created_at = "2026-10-22T10:05:11+00:00"`, + `created` прежний; у прогона `history_at = NULL`; затем миграция v5 → v6; `schema_version = 6` + +#### Scenario: Миграция базы v5 +- **WHEN** хаб стартует с базой `schema_version = 5` +- **THEN** в `backups/` копия базы v5; есть пустые `issue_attachment` и `attachment_file`; прочие таблицы не тронуты; + `schema_version = 6` + +#### Scenario: refresh в очереди при миграции +- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается + полным прогоном + +#### Scenario: Два соединения к базе v1 +- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения +- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` + +#### Scenario: Открытый каталог данных +- **WHEN** хаб стартует, каталог данных существует с правами `0755` +- **THEN** права каталога и `backups/` — `0700` + +### Requirement: Зеркало задачи +Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki), +`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список), +`sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link` +SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается +на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть: +`key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Таблица `issue_attachment` SHALL +хранить список вложений задачи без содержимого: `attachment_id` (PK, id вложения Jira), `key`, `filename`, `size` (байт +или `NULL`), `mime_type`, `author`, `created` (время Jira как есть), `created_at` (UTC, приведённое из `created`; не +разобралось — `NULL`). Обновлять `issue`, `issue_link`, `issue_comment` и +`issue_attachment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные +комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии +SHALL сохранять. Так же со вложениями: присланное поле `attachments` заменяет все прежние вложения задачи, задача без +поля (агент до v14) свои вложения сохраняет. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не +обновлённые им, — только если прогон загрузил хотя бы одну задачу. + +#### Scenario: Загрузка состояния заменяет связи +- **WHEN** агент прислал состояние задачи X с двумя связями +- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные + со стороны X, не трогаются до загрузки их источника + +#### Scenario: Соседняя задача +- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL +- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0` + +#### Scenario: Пустой полный прогон +- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач +- **THEN** признак `neighbor` задач зеркала не меняется + +#### Scenario: Комментарий удалён в Jira +- **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]` +- **THEN** у X в `issue_comment` только 102 + +#### Scenario: Состояние от агента до v10 +- **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments` +- **THEN** поля X обновлены, три комментария на месте + +#### Scenario: Вложения в состоянии +- **WHEN** у X в зеркале вложения #1 и #2, агент v14 прислал X с `attachments = [{id: "2", …}, {id: "3", …}]` +- **THEN** у X в `issue_attachment` #2 (поля из состояния) и #3; #1 удалено; `attachment_file` не тронута + +#### Scenario: Состояние от агента до v14 +- **WHEN** у X в зеркале два вложения, агент v13 прислал X без поля `attachments` +- **THEN** поля X обновлены, два вложения на месте + +## ADDED Requirements + +### Requirement: Скачанные вложения +Таблица `attachment_file` SHALL хранить, какие вложения уже скачаны в хранилище файлов хаба: `attachment_id` (PK), +`file` (sha256 содержимого), `size`, `fetched_at` (UTC). Строку SHALL записывать (вставка или замена) только отчёт +`done` по `fetch_attachment`; загрузка состояния её MUST NOT трогать, поэтому скачанный файл переживает обновление +списка вложений. Строка без файла в хранилище (файл убран уборкой) SHALL считаться «не скачано». + +Состояние вложения для интерфейсов (`state`) SHALL вычисляться при чтении: +- `ready` — есть строка `attachment_file` и файл `file` есть в хранилище; +- `fetching` — нет `ready`, и есть `fetch_attachment` с этим `attachment_id` в `queued` или `claimed`; +- `failed` — нет ни того, ни другого, а последняя (по `id`) `fetch_attachment` с этим `attachment_id` — `failed` или + `cancelled`; её `error` отдаётся вместе с состоянием; +- `none` — вложение ни разу не запрашивали (или запрос был до уборки файла). + +#### Scenario: Файл убран уборкой +- **WHEN** у вложения #31337 строка `attachment_file`, файла `file` в `files/` нет; команд по нему в очереди нет, последняя + `fetch_attachment` по нему — `done` +- **THEN** `state = none`; новый запрос на скачивание ставит новую `fetch_attachment` + +#### Scenario: Список вложений обновился +- **WHEN** вложение #31337 скачано (`state = ready`), затем пришло состояние задачи с тем же вложением +- **THEN** `state` по-прежнему `ready` diff --git a/openspec/changes/attachment-fetch/specs/hub-api/spec.md b/openspec/changes/attachment-fetch/specs/hub-api/spec.md new file mode 100644 index 0000000..912e836 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/hub-api/spec.md @@ -0,0 +1,291 @@ +## MODIFIED Requirements + +### Requirement: Маршруты агента +Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном): +- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий + запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command: + null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или + `null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или + `null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить + последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в + `claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из очереди. +- `GET /agent/settings` — настройки для агента (без токенов). +- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`. +- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный + отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по + `ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N` + переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до + 1500 символов. +- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния + пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая + пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. Поле задачи `comments` + необязательно; если оно есть, хаб SHALL до записи проверить его форму: список объектов, `id` — непустая строка, `body` — + строка, `author`, `created`, `updated` — строка или `null`; нарушение — 422 `bad_state` с ключом задачи и именем поля, + пачка не применяется. Поле задачи `attachments` (агент v14) необязательно и проверяется так же: список объектов, `id` — + непустая строка, `filename` — непустая строка, `size` — неотрицательное целое или `null`, `mime_type`, `author`, + `created` — строка или `null`. +- `GET /agent/known-keys` — ключи зеркала (для определения соседей). +- `GET /agent/release` — опубликованная версия агента `{version, sha256, content}` (`content` — текст `agent.py`); + нет публикации или файл не сходится с манифестом по sha256 — 404 `no_release`. +- `GET /agent/files/{sha256}` — файл вложения из хранилища хаба `{file, size, content}` (`content` — base64 содержимого); + файла нет — 404 `no_file`. +- `POST /agent/files` `{content}` — файл вложения, скачанный агентом из Jira, в хранилище хаба; ответ `{file, size}`; + проверки и ошибки — как у `POST /files` (см. «Хранилище файлов вложений»), кроме пустого файла: от агента он + принимается (в Jira бывают пустые вложения). + +#### Scenario: Выдача с подстановкой ключа +- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590` +- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах + +#### Scenario: Повторный отчёт +- **WHEN** агент дважды прислал `result` для команды 15 +- **THEN** второй ответ 200, статус и результат не изменились + +#### Scenario: Отчёт по невыданной команде +- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued` +- **THEN** 409 `not_claimed`, команда осталась `queued` + +#### Scenario: Длинная ошибка +- **WHEN** агент прислал `failed` с `error` длиной 5000 символов +- **THEN** в `command.error` первые 1500 символов + +#### Scenario: Поздний отчёт после предела выдач +- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент + прислал `done` с `result.key = AURORA-1630` +- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630` + +#### Scenario: Публикация в ответе claim +- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет +- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}` + +#### Scenario: Состояние обновления от агента +- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}` + и `launcher_version = 1` +- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с» + +#### Scenario: Скачивание версии +- **WHEN** агентским токеном `GET /agent/release`, опубликована v6 +- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401; + без публикации — 404 `no_release` + +#### Scenario: Скачивание файла вложения +- **WHEN** агентским токеном `GET /agent/files/`, файл загружен +- **THEN** 200 `{file: , size, content}`, sha256 от декодированного `content` равен ``; клиентским токеном — 401; + файла нет — 404 `no_file` + +#### Scenario: Неверный комментарий в пачке +- **WHEN** `POST /agent/state` с задачами X и Y, у Y комментарий `{id: "7", body: 42}` +- **THEN** 422 `bad_state` с ключом Y и полем `body`; ни X, ни Y в зеркале не изменились + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, веб-приложение) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `project` (разрез по проекту, см. `data-model`, «Проекты»: свои и соседи, `neighbor` + относительно проекта; неизвестный проект — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, + `epic`, `neighbor`, `q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку + `closed_statuses`; задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` + и `work_kind` из заметок; сокращение до карточки — дело интерфейса. Без `project` — всё зеркало, `neighbor` — признак + зеркала (вне базовой JQL). +- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны + (`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где + `summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней (без `fetch_attachment`), + `comments: [{comment_id, author, created, updated, body, attachment_ids}]` — все комментарии, новые сверху (по `created`, затем + `comment_id`, по убыванию); `attachments: [{attachment_id, filename, size, mime_type, author, created, state, error}]` — + все вложения задачи, новые сверху (по `created_at`, затем `attachment_id`, по убыванию), `state` и `error` — см. + `data-model`, «Скачанные вложения» (`error` — только при `failed`, иначе `null`); `description_attachment_ids` и + `attachment_ids` комментария — id вложений, на которые ссылается текст (см. «Ссылки на вложения в тексте»), в порядке + первого упоминания, без повторов. +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, project?, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, + неверных параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более + `ensure_issue` с одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся + темы), по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен, доска для + `ensure_sprint`), а также по проверкам пакета проекта (см. `commands`), если передан `project`. +- `GET /attachments/{id}`, `POST /attachments/{id}/fetch`, `GET /attachments/{id}/content` — вложения задач (см. + «Скачивание вложений»). +- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого + (64 hex-символа, нижний регистр); см. «Хранилище файлов вложений». +- `GET /batches?limit=&project=` (с `project` — только пакеты этого проекта), `GET /batches/{id}` (с командами и + `project`), `POST /batches/{id}/cancel` — отменяет `queued` команды пакета; ответ — вид пакета с полем `still_claimed`: + список `{id, type}` команд пакета в `claimed` (они будут выполнены агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` `{author?, project?}` — новая команда с теми же параметрами в новом пакете «Повтор + команды N»; хаб снимает с параметров то, что дописывает сам (`board_id` у `ensure_sprint`), и проверяет команду + заново. `project` передан и у исходного пакета другой проект (или его нет) — 422 «команда #N не из проекта P»; не + передан — новый пакет получает проект исходного и проходит его проверки. +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`; `full` — по базовой JQL из проектов, без + проектов — 422 «нет проектов». +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Список проекта +- **WHEN** свои задачи `STOCKS_DRRK` — AURORA-1 и AURORA-4, сосед — AURORA-2; `GET /issues?project=STOCKS_DRRK` +- **THEN** три задачи, у AURORA-2 `neighbor = true`; с `neighbor=1` — только AURORA-2; задач только с меткой `AWPCA` нет + +#### Scenario: Неизвестный проект +- **WHEN** `GET /issues?project=NOPE` +- **THEN** 404 + +#### Scenario: Пакеты проекта +- **WHEN** пакет 10 поставлен с `project = STOCKS_DRRK`, пакет 11 — без проекта; `GET /batches?project=STOCKS_DRRK` +- **THEN** в ответе только пакет 10 + +#### Scenario: Повтор чужой команды +- **WHEN** команда 40 из пакета проекта `AWPCA`; `POST /commands/40/retry {project: "STOCKS_DRRK"}` +- **THEN** 422 «команда #40 не из проекта STOCKS_DRRK», пакет не создан + +#### Scenario: Повтор ensure_sprint +- **WHEN** `ensure_sprint` 41 failed, в её `params` `board_id = 4216`; `POST /commands/41/retry` +- **THEN** новый пакет создан, `board_id` в новой команде вычислен хабом заново + +#### Scenario: Полный прогон без проектов +- **WHEN** проектов нет, `POST /refresh {full: true}` +- **THEN** 422 «нет проектов», пакет не поставлен + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +#### Scenario: Карточка с комментариями и связями +- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на + AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик» +- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 — + `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`; + `epic_summary = "Тестовый эпик"` + +#### Scenario: Без закрытых +- **WHEN** `closed_statuses = ["Готово", "Снято"]`, в зеркале задачи в статусах «В работе», «Готово», «Снято» и без + статуса; `GET /issues?hide_closed=1` +- **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре + +### Requirement: Хранилище файлов вложений +Хаб SHALL хранить файлы вложений в каталоге `files/` внутри каталога данных, по одному файлу на содержимое с именем +sha256 (без расширения и имени вложения: имя задаёт команда). Каталог SHALL иметь права `0700` (выставляются при создании +и при каждом старте, как у `backups/`). `POST /files`: +- `content` не строка или не base64 — 400 `bad_request`; пустой файл — 422 `empty_file`; +- файл больше 10 МБ (10 485 760 байт) после декодирования — 413 `too_large` (тело запроса до 16 МБ проходит); +- запись атомарная: временный файл в `files/`, затем переименование; повторная загрузка того же содержимого отвечает тем + же `file` и не создаёт второй копии. +Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки в пределах работы процесса (после старта — на первом тике) удалять файлы старше 30 дней (по +времени изменения), на которые не ссылается ни одна команда `ensure_attachment` в `queued` или `claimed` (скачанные +вложения `attachment_file` от уборки не защищены: их можно скачать снова), и подкаталоги `downloads//` +(копии MCP, см. `mcp`) старше 30 дней по времени изменения; повтор команды +после уборки отвергается при постановке 422 «файл не загружен: POST /files». Повторная загрузка того же +содержимого SHALL обновлять время изменения файла; выдача файла `GET /attachments/{id}/content` — тоже. + +#### Scenario: Загрузка файла +- **WHEN** `POST /files {content: base64("abc")}` +- **THEN** 200 `{file: "ba7816bf…", size: 3}`; в `files/` файл `ba7816bf…` с содержимым `abc` + +#### Scenario: Повторная загрузка +- **WHEN** тот же `POST /files` второй раз +- **THEN** 200 с тем же `file`; в `files/` по-прежнему один файл + +#### Scenario: Слишком большой файл +- **WHEN** `POST /files` с файлом 11 МБ +- **THEN** 413 `too_large`, в `files/` ничего не появилось + +#### Scenario: Уборка старых файлов +- **WHEN** в `files/` файл A старше 30 дней без ссылок, файл B старше 30 дней, на него ссылается `ensure_attachment` в + `queued`, файл C загружен вчера +- **THEN** после уборки удалён только A + +#### Scenario: Скачанное вложение и уборка +- **WHEN** в `files/` файл D скачан агентом (`attachment_file`) 40 дней назад и с тех пор не выдавался; в `downloads/` + каталог `31337/` создан 40 дней назад +- **THEN** после уборки D и `downloads/31337/` удалены; `state` вложения — `none` + +### Requirement: Резервные копии +Перед выдачей первой команды пакета, содержащего записи в Jira (любая команда, кроме `refresh` и `fetch_attachment`), хаб +SHALL скопировать файл базы в `backups/<дата-время>-.sqlite` и хранить последние `backup_keep` копий. + +#### Scenario: Ротация +- **WHEN** копий больше `backup_keep` +- **THEN** самые старые удаляются до `backup_keep` + +#### Scenario: Скачивание без копии +- **WHEN** выдаётся `fetch_attachment` пакета «Скачать вложение …» +- **THEN** копия базы не создаётся + +## ADDED Requirements + +### Requirement: Скачивание вложений +Хаб SHALL предоставлять интерфейсам под клиентским токеном: +- `GET /attachments/{id}` — вложение из зеркала `{attachment_id, key, filename, size, mime_type, author, created, state, + error, command_id, batch_id}`, где `command_id`/`batch_id` — последней `fetch_attachment` по нему (или `null`); нет в + зеркале — 404 `no_attachment`. +- `POST /attachments/{id}/fetch` `{author?}` — запросить скачивание. Нет в зеркале — 404 `no_attachment`; `state = + ready` или `fetching` — ответ как у `GET /attachments/{id}`, новой команды нет; размер больше 10 МБ — 413 `too_large`; + последний известный хабу агент (`agent_status.agent_version`) старше v14 — 409 `agent_too_old` «агент v не умеет + скачивать вложения — нужен v14» (версия неизвестна — проверка не делается); иначе хаб SHALL поставить пакет из одной + `fetch_attachment` (см. `commands`, автор — из тела, иначе `api`) и ответить как `GET /attachments/{id}` с `state = + fetching`. Проверка «уже ставится» и постановка SHALL выполняться в одной транзакции записи: два одновременных запроса + ставят одну команду. +- `GET /attachments/{id}/content` — содержимое при `state = ready`: 200, тело — байты файла, `Content-Type` — `mime_type` + вложения (нет или не вида `тип/подтип` — `application/octet-stream`), `Content-Disposition: attachment; + filename*=UTF-8''<имя в URL-кодировке>`, `X-Content-Type-Options: nosniff`, `Content-Length`; иначе 404 `not_fetched`. +Отчёт `done` по `fetch_attachment` (`POST /agent/commands/{id}/result`) SHALL записывать `attachment_file` из +`result.file` и `result.size`, если файл `file` есть в хранилище и его размер равен `size`; иначе команда SHALL +становиться `failed` «хаб: файл не найден в хранилище» и `attachment_file` не меняется. + +#### Scenario: Первый запрос +- **WHEN** вложение #31337 в зеркале, `state = none`, агент v14; `POST /attachments/31337/fetch {author: "web"}` +- **THEN** создан пакет «Скачать вложение скрин 1.png (AURORA-1650)» из одной `fetch_attachment`, без хвостового + `refresh`; ответ `state = fetching` с `command_id` и `batch_id` + +#### Scenario: Повторный запрос во время скачивания +- **WHEN** по #31337 команда в `queued`; MCP и веб одновременно вызывают `POST /attachments/31337/fetch` +- **THEN** оба ответа — `state = fetching` с той же `command_id`; новых пакетов нет + +#### Scenario: Файл уже у хаба +- **WHEN** #31337 в `state = ready`; `GET /attachments/31337/content` +- **THEN** 200, `Content-Type: image/png`, `Content-Disposition: attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`, + тело — файл; время изменения файла в `files/` обновлено + +#### Scenario: Старый агент +- **WHEN** `agent_status.agent_version = 13`; `POST /attachments/31337/fetch` +- **THEN** 409 `agent_too_old` «агент v13 не умеет скачивать вложения — нужен v14», пакета нет + +#### Scenario: Отчёт без файла в хранилище +- **WHEN** агент отчитался `done {file: , size: 100}`, а файла `` в `files/` нет +- **THEN** команда `failed` «хаб: файл не найден в хранилище»; `state` вложения — `failed` + +### Requirement: Ссылки на вложения в тексте +Хаб SHALL находить в описании задачи и в тексте каждого комментария ссылки на вложения задачи по вики-разметке Jira: +`!<имя>!` и `!<имя>|<параметры>!` (картинка; имя без `!`, `|` и переводов строк, не содержит `://`), `[^<имя>]` и +`[<текст>|^<имя>]` (ссылка на вложение; имя без `]` и переводов строк). Имя SHALL сравниваться с `filename` вложений +задачи точно (с учётом регистра); несколько вложений с одним именем — ссылка указывает на самое новое по `created_at` +(как в Jira); имени нет среди вложений — ссылка не учитывается. Ссылки внутри `{code}`/`{noformat}` тоже учитываются. + +#### Scenario: Скриншот в комментарии +- **WHEN** у задачи вложения «скрин 1.png» (#31337) и «ОСВ 60.xlsx» (#31338); комментарий 101 — «см. + !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!»; описание ссылается на «старый.png», которого нет +- **THEN** у комментария 101 `attachment_ids = ["31337", "31338"]`; `description_attachment_ids = []` + +#### Scenario: Два вложения с одним именем +- **WHEN** у задачи два вложения «скрин.png» (#10 от 14.10, #20 от 20.10); комментарий — «!скрин.png!» +- **THEN** у комментария `attachment_ids = ["20"]` diff --git a/openspec/changes/attachment-fetch/specs/mcp/spec.md b/openspec/changes/attachment-fetch/specs/mcp/spec.md new file mode 100644 index 0000000..c680ec5 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/mcp/spec.md @@ -0,0 +1,144 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | +| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: # <имя>, …»; затем вложения (см. «Вложения в MCP») | +| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка « # <автор>, создан » (и «, изменён », если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у нет комментария #»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «# <имя> (<размер>)» | +| `attachment` | `key`, `attachment` (номер вложения «#» или «», иначе — точное имя файла), `wait_seconds` (по умолчанию 120, не больше 600) | скачивает вложение и отдаёт путь к локальной копии (см. «Вложения в MCP») | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту». + +Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в +`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную. + +`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на +машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на +`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента +с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file». + +Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки +других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб. + +Ответ `project_create` SHALL содержать фрагмент `.mcp.json` +`{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "jira-hub", "mcp", +"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP +(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить +с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта). + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Проект из чата +- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}` +- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"` + +#### Scenario: Ошибка с файлом из чата +- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …}, + ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}` +- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: , filename: "ОСВ сч. 60 за 2 кв. + 2026.xlsx"}` и хвостовым `refresh` + +#### Scenario: Файла нет +- **WHEN** тот же вызов, файла по `path` нет +- **THEN** ответ «файл не найден: », пакет не создан, в хаб ничего не загружено + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» + +#### Scenario: Комментарий целиком +- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}` +- **THEN** ответ — «AURORA-9001 #101 <автор>, создан » и полный текст комментария без обрезки, переносы строк сохранены + +#### Scenario: Нет такого комментария +- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999 +- **THEN** ответ «у AURORA-9001 нет комментария #999» + +## ADDED Requirements + +### Requirement: Вложения в MCP +`issue` SHALL после комментариев выводить «вложения (N):» и по строке на каждое вложение (новые сверху): «# <имя> +(<размер>) — <автор>, », дальше через запятую: «в описании», если на вложение ссылается описание, «в #» +для каждого ссылающегося комментария, «скачано», если `state = ready`, «больше 10 МБ — только в Jira» для таких файлов; +без вложений — «вложений нет». Размер — в байтах, КБ или МБ с одним знаком после запятой («512 Б», «234,4 КБ», «2,3 МБ»); +неизвестный — «размер неизвестен». + +`attachment` SHALL: +1. найти вложение по `GET /issues/{key}`: по номеру, иначе по точному имени (несколько с одним именем — самое новое, в + ответ строка «у N вложений «<имя>», взято самое новое #»); не найдено — «у нет вложения «<значение>»; + список — issue », в хаб больше не ходить; +2. `POST /attachments/{id}/fetch` с автором `claude`; 413 — «файл больше 10 МБ (<размер>) — скачайте в Jira: + задача , вложение «<имя>»»; 409 и прочие ошибки хаба — их текст; +3. пока `state = fetching` — опрашивать `GET /attachments/{id}` раз в 2 с, не дольше `wait_seconds`; не дождался — + «скачивание ещё идёт (команда #, агент <онлайн/офлайн>); вызовите attachment ещё раз — вторая команда не + поставится»; `failed` — «не удалось скачать: »; +4. при `ready` — `GET /attachments/{id}/content` и записать файл в `<каталог данных хаба>/downloads//<имя>`, где имя — + `filename` с заменой `/`, `\`, `:` и управляющих символов на `_` (пустое — `attachment-`); каталог с правами + `0700`; запись через временный файл и переименование; если там уже лежит файл того же размера — не перезаписывать; +5. ответить «<абсолютный путь>» отдельной строкой и под ним «<имя> (<размер>), , задача ». + +#### Scenario: Скриншот из комментария +- **WHEN** `comment {key: "AURORA-1650", comment_id: "101"}` показал «вложения: #31337 скрин 1.png (234,4 КБ)»; вызван + `attachment {key: "AURORA-1650", attachment: "#31337"}`, агент онлайн, файла у хаба нет +- **THEN** поставлена `fetch_attachment`; через несколько секунд ответ — путь + `…/jira-hub/downloads/31337/скрин 1.png` и строка «скрин 1.png (234,4 КБ), image/png, задача AURORA-1650»; файл по пути + читается + +#### Scenario: Повторный вызов +- **WHEN** тот же вызов второй раз, файл у хаба (`ready`) и в `downloads/31337/` +- **THEN** ответ сразу, команд в очереди не прибавилось + +#### Scenario: Агент не успел +- **WHEN** `attachment {…, wait_seconds: 30}`, агент занят долгим `refresh` +- **THEN** через 30 с ответ «скачивание ещё идёт (команда #N, агент онлайн); вызовите attachment ещё раз — вторая + команда не поставится» + +#### Scenario: По имени +- **WHEN** `attachment {key: "AURORA-1650", attachment: "ОСВ 60.xlsx"}`, вложение с таким именем одно +- **THEN** скачивается оно; путь оканчивается на `/ОСВ 60.xlsx` + +#### Scenario: Вложения в карточке +- **WHEN** `issue {key: "AURORA-1650"}`, у задачи вложения #31337 «скрин 1.png» (на него ссылается комментарий 101, + скачано) и #31338 «запись.mp4» на 25 МБ +- **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у + комментария 101 строка «файлы: #31337 скрин 1.png» diff --git a/openspec/changes/attachment-fetch/specs/web-app/spec.md b/openspec/changes/attachment-fetch/specs/web-app/spec.md new file mode 100644 index 0000000..a634744 --- /dev/null +++ b/openspec/changes/attachment-fetch/specs/web-app/spec.md @@ -0,0 +1,180 @@ +## MODIFIED Requirements + +### Requirement: Карточка задачи +Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с +которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры, +загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую +адрес (новая вкладка, закладка) SHALL +показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста, +отпущенное за краем окна, окно не закрывает) и +возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после +закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад» +браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не +прокручивается. + +Содержимое окна: +- шапка: ключ, статус Jira, «Открыть в Jira», ✕, тема; строка + «исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено», + если он раньше сегодняшней даты и статус Jira не входит в `closed_statuses`; эпик — темой + (`epic_summary`), иначе ключом; +- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует + завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками), + «Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»); + в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в + зеркале»; пустые группы не показываются, без связей — «связей нет»; +- «Описание» — текст, отрендеренный по требованию «Разметка Jira в карточке», с кнопкой «исходник» / «текст» в + заголовке раздела; длиннее 8 строк — свёрнуто с кнопкой «развернуть» / «свернуть» в обоих режимах; без описания + раздела нет; +- «Вложения (N)» — новые сверху: значок по типу файла (картинка, таблица, PDF, прочее), имя, размер, «автор · дата» и + кнопка скачивания (требование «Скачивание вложений в карточке»); без вложений раздела нет; +- «Комментарии (N)» — новые сверху, автор, время, кнопка «исходник» / «текст» у каждого комментария, текст, + отрендеренный по требованию «Разметка Jira в карточке», под текстом — вложения, на которые ссылается комментарий + (`attachment_ids`): строка «плашек» с именем и размером, щелчок — скачивание; кнопка «Скрыть» / «Показать» сворачивает раздел, выбор SHALL + запоминаться в браузере для всех карточек; без комментариев — «комментариев нет»; +- «Служебные заметки» (требование «Служебные заметки»); +- «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям; +- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут + при открытии; если команд не было — текст «команд по задаче не было». +На ширине от 900 px «Связи», «Описание», «Вложения», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой; +уже — одна колонка: заметки, связи, описание, вложения, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно +SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные +остаются. + +#### Scenario: Связанная задача +- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе» +- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу + меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620 + +#### Scenario: Входящий блокер +- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500 +- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от» + +#### Scenario: Задача без команд +- **WHEN** по AURORA-1620 не было команд +- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет + +#### Scenario: Закрытие после переходов +- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc +- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку + +#### Scenario: Прямой адрес +- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕ +- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку + +#### Scenario: Скрытые комментарии +- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка +- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев + +#### Scenario: Просрочка закрытой задачи +- **WHEN** у AURORA-1620 срок 2026-10-01, сегодня 2026-10-17, статус Jira «Готово» +- **THEN** в строке под темой «срок 01.10» без пометки «просрочено»; при статусе «В работе» — с пометкой + +### Requirement: Разметка Jira в карточке +Описание задачи и текст каждого комментария в карточке SHALL показываться отрендеренной вики-разметкой Jira (хаб +хранит их как есть, в wiki) так, как их показывает Jira. Разбираются: +- блоки: заголовки `h1.`–`h6.` в начале строки; списки — строки, начинающиеся (после необязательных пробелов) с + маркеров `*`, `#` и пробела (уровень вложенности — числом маркеров, `*` — маркированный, `#` — нумерованный, + смешанная вложенность `*#` — по последнему маркеру) или с одного `-` и пробела (маркированный, первый уровень); + `--`, `*-` и прочие сочетания с `-` — не список; таблицы — строки, начинающиеся с + `|`, ячейки `||заголовок||` и `|ячейка|`; `{code}` / `{code:язык}` … `{code}` и `{noformat}` … `{noformat}` — + моноширинный блок без разбора разметки внутри; `{quote}` … `{quote}` и строка `bq. ` — цитата; открывающий тег + блока — в начале строки, закрывающий — в любом месте той же или последующей строки, текст после закрывающего тега + разбирается дальше как обычные строки; тег не в начале строки — текст как есть; строка `----` — + горизонтальная черта; прочие строки — абзацы, разделённые пустыми строками, одиночный перенос внутри абзаца — + перенос строки; +- внутри строки: `*жирный*`, `_курсив_`, `-зачёркнутый-`, `+подчёркнутый+`, `^верхний^`, `~нижний~`, `{{моно}}` + (`{{…}}` — без разбора внутри); маркер выделения действует, только если открывающий стоит в начале текста или после + пробела либо знака препинания и за ним не пробел и не тот же символ (`**x**` — текст как есть), а закрывающий стоит после не-пробела и за ним не буква и не + цифра (`data_type`, `2026-10-15`, `счёт-фактура`, `a*b*c` — обычный текст); `\\` — перенос строки; `\` перед + символом разметки — сам символ; +- ссылки: `[текст|адрес]`, `[адрес]` и голые адреса `http://`, `https://` — ссылка в новой вкладке, только для схем + `http`, `https`, `mailto`, иначе текст как есть; `[ключ задачи]`, `[текст|ключ задачи]` и ключ задачи в тексте вида + `ПРОЕКТ-123`, где `ПРОЕКТ` — `project_key` из настроек хаба, — ссылка, открывающая карточку этой задачи в окне + (`UTF-8`, `SHA-256` и ключи других проектов — текст); + `[~логин]` — «@логин»; +- ссылки на вложения задачи — `!имя!`, `!имя|параметры!`, `[^имя]`, `[текст|^имя]` с тем же разбором имени, что у хаба + (`hub-api`, «Ссылки на вложения в тексте»), имя есть среди вложений задачи: ссылка-кнопка со значком скрепки и текстом + — именем файла (у `[текст|^имя]` — текстом), щелчок — скачивание; картинка в тексте не показывается; имени нет среди + вложений — исходник как есть. +Всё, что разборщик не распознал (`{panel}`, `{color}`, `!картинка!` и `[^имя]` без такого вложения у задачи, незакрытые маркеры и блоки и прочее), SHALL +показываться как текст исходника без потерь. Разметка MUST NOT превращаться в HTML строкой (`innerHTML`, +`dangerouslySetInnerHTML`): текст задачи не может добавить на страницу элементы, атрибуты или скрипты. Если рендер +блока завершился ошибкой, блок SHALL показываться исходником. + +Кнопка «исходник» у описания и у каждого комментария SHALL переключать этот блок на исходник — текст с сохранёнными +переносами строк, как без разбора разметки, — и обратно кнопкой «текст»; остальные блоки не переключаются. При каждом +открытии карточки (в том числе при переходе по связи) все блоки показываются отрендеренными, выбор не запоминается. + +#### Scenario: Описание со структурой +- **WHEN** описание AURORA-1620 — `h3. Контекст`, строка `Шаги:`, затем `# Загрузить ОСВ` и `# Открыть документ`, + затем пустая строка и `*Итог:* ошибка в {{data_type}}` +- **THEN** в «Описании» заголовок «Контекст», абзац «Шаги:», нумерованный список из двух пунктов и абзац, где «Итог:» — + жирным, «data_type» — моноширинным; символов `h3.`, `#`, `*`, `{{` не видно + +#### Scenario: Переключение на исходник +- **WHEN** в карточке AURORA-1620 у описания нажато «исходник» +- **THEN** описание показано исходником с переносами строк (видны `h3. Контекст` и `# Загрузить ОСВ`), кнопка — «текст»; + комментарии остаются отрендеренными; после «текст» описание снова отрендерено + +#### Scenario: Исходник у одного комментария +- **WHEN** у второго комментария нажато «исходник», затем открыта связанная задача и возврат «назад» +- **THEN** сразу после нажатия исходником показан только второй комментарий; после возврата все комментарии + отрендерены + +#### Scenario: Ключ задачи в тексте +- **WHEN** комментарий — `П. 1 ведётся в AURORA-1445; AURORA-1645 снята как дубль.` +- **THEN** «AURORA-1445» и «AURORA-1645» — ссылки; щелчок по первой меняет содержимое окна на AURORA-1445 + +#### Scenario: Неизвестная разметка и опасный адрес +- **WHEN** описание — `{color:red}важно{color}`, `!схема.png!` и `[нажми|javascript:alert(1)]` +- **THEN** все три показаны текстом исходника как есть, ссылки и картинки нет, скрипт не выполняется + +#### Scenario: Списки с отступом и пустыми строками +- **WHEN** описание — `Шаги воспроизведения:`, ` * Загрузить ОСВ.`, пустая строка, ` * Дождаться обработки.` +- **THEN** абзац «Шаги воспроизведения:» и маркированные пункты «Загрузить ОСВ.» и «Дождаться обработки.» без + звёздочек + +#### Scenario: Обычный текст не портится +- **WHEN** описание — `Поле data_type = null, дата 2026-10-15, оборот 18 803 903,65 - 9 351 102,89, a*b*c, UTF-8, + **важно**` +- **THEN** текст показан как есть, без курсива, зачёркивания, жирного и ссылок + +#### Scenario: Скриншот в тексте комментария +- **WHEN** комментарий — «см. !скрин 1.png|thumbnail!», у задачи есть вложение «скрин 1.png» +- **THEN** вместо `!скрин 1.png|thumbnail!` — кнопка со скрепкой «скрин 1.png»; под комментарием плашка «скрин 1.png · + 234,4 КБ»; щелчок по любой из них скачивает файл + +## ADDED Requirements + +### Requirement: Скачивание вложений в карточке +Кнопка скачивания вложения (в разделе «Вложения», плашка под комментарием, ссылка в тексте) SHALL работать одинаково: +1. `POST /attachments/{id}/fetch {author: "web"}`; +2. `state = ready` — `GET /attachments/{id}/content` с клиентским токеном, браузер сохраняет файл под именем `filename` + (через `Blob` и временную ссылку `download`; адрес с токеном в строке браузера MUST NOT появляться); +3. `state = fetching` — у всех кнопок этого вложения в карточке вместо «Скачать» — «Загружается…» (неактивна), опрос + `GET /attachments/{id}` раз в 2 с, пока окно открыто на этой задаче; `ready` — шаг 2 сам, без второго нажатия; закрыли + окно или ушли на другую задачу — опрос прекращается, команда остаётся в очереди (файл ляжет в хаб); +4. `failed` — под вложением в разделе «Вложения» текст ошибки и кнопка «Повторить» (новый запрос); 409 `agent_too_old` + и прочие ошибки хаба — их текст там же. +Вложение с `state = ready` в разделе «Вложения» помечается «у хаба» и скачивается сразу. Размер неизвестен — «размер неизвестен» и обычная кнопка. +Вложение больше 10 МБ — +вместо кнопки ссылка «Открыть в Jira» (`<адрес Jira>/secure/attachment//<имя в URL-кодировке>`, новая вкладка) с +подсказкой «больше 10 МБ — только в Jira»; плашка и ссылка в тексте ведут туда же. + +#### Scenario: Скачать скриншот +- **WHEN** в карточке AURORA-1650 нажато «Скачать» у «скрин 1.png», файла у хаба нет, агент онлайн +- **THEN** кнопка — «Загружается…», через несколько секунд браузер сохраняет «скрин 1.png», кнопка снова «Скачать», у + вложения отметка «у хаба» + +#### Scenario: Окно закрыли во время скачивания +- **WHEN** нажато «Скачать», затем окно карточки закрыто +- **THEN** опросов `GET /attachments/{id}` больше нет; при следующем открытии карточки, если файл уже у хаба, вложение с + отметкой «у хаба» + +#### Scenario: Большой файл +- **WHEN** у вложения «запись.mp4» размер 25 МБ +- **THEN** вместо «Скачать» — ссылка «Открыть в Jira» с подсказкой «больше 10 МБ — только в Jira» + +#### Scenario: Агент старой версии +- **WHEN** нажато «Скачать», хаб ответил 409 «агент v13 не умеет скачивать вложения — нужен v14» +- **THEN** под вложением этот текст и «Повторить» diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 18b102a..3e5644f 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -13,9 +13,9 @@ from datetime import timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import NamedTuple -from urllib.parse import parse_qs, unquote, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse -from . import backup, db, files, history, mirror, projects, queue, relay, release, settings +from . import attachments, backup, db, files, history, mirror, projects, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -31,6 +31,7 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им NOT_BUILT = ("Хаб Jira

Веб-приложение не собрано: выполните " "make web в каталоге jira-hub и перезапустите хаб.

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит +MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») @@ -54,6 +55,7 @@ class Hub: self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" + self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # неизвестен, а не null (hub-hygiene-d, решение 7) @@ -96,6 +98,12 @@ class Hub: removed = files.sweep(conn, self.files_dir, now) if removed: log.info("уборка файлов: удалено %d", len(removed)) + try: + gone = files.sweep_downloads(self.downloads_dir, now) + if gone: + log.info("уборка копий вложений: удалено %d", len(gone)) + except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке + log.exception("уборка копий вложений: сбой") return removed def note_agent_update(self, body): @@ -187,7 +195,7 @@ def h_agent_command(hub, conn, m, q, body): def h_agent_result(hub, conn, m, q, body): try: cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"), - body.get("jira_calls", 0), body.get("rate_limited", 0)) + body.get("jira_calls", 0), body.get("rate_limited", 0), files_dir=hub.files_dir) except KeyError: raise ApiError(404, "not_found", "command") except queue.NotClaimedError as e: @@ -201,6 +209,7 @@ def h_agent_state(hub, conn, m, q, body): issues = body.get("issues") or [] try: mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком + mirror.check_attachments(issues) except ValueError as e: raise ApiError(422, "bad_state", str(e)) with db.transaction(conn): @@ -248,7 +257,7 @@ def h_issues(hub, conn, m, q, body): def h_issue(hub, conn, m, q, body): - return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") + return 200, _not_found(mirror.get_issue(conn, m.group(1), hub.files_dir), "issue") _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") @@ -335,6 +344,8 @@ def h_batches_post(hub, conn, m, q, body): try: return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir, project=body.get("project")) + except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) + raise ApiError(413, "too_large", str(e)) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) @@ -354,9 +365,9 @@ def h_batch_cancel(hub, conn, m, q, body): raise ApiError(404, "not_found", "batch") -def h_files_post(hub, conn, m, q, body): - """Загрузка файла вложения: {content: base64} → {file: sha256, size}. Пробельные символы (переносы строк base64) - отбрасываются до строгой проверки (решение 14).""" +def _put_file(hub, body, allow_empty=False): + """Общая часть POST /files и POST /agent/files: {content: base64} → {file: sha256, size}. Пробельные символы + (переносы строк base64) отбрасываются до строгой проверки (решение 14).""" content = body.get("content") if not isinstance(content, str): raise ApiError(400, "bad_request", "content — строка base64") @@ -365,7 +376,7 @@ def h_files_post(hub, conn, m, q, body): except ValueError: # binascii.Error — подкласс ValueError raise ApiError(400, "bad_request", "content — не base64") from None try: - sha, size = files.put(hub.files_dir, data) + sha, size = files.put(hub.files_dir, data, allow_empty=allow_empty) except files.EmptyFile: raise ApiError(422, "empty_file", "пустой файл") from None except files.TooLarge: @@ -373,6 +384,57 @@ def h_files_post(hub, conn, m, q, body): return 200, {"file": sha, "size": size} +def h_files_post(hub, conn, m, q, body): + return _put_file(hub, body) + + +def h_agent_files_post(hub, conn, m, q, body): + """Файл от агента (fetch_attachment, решение 5): как POST /files, но пустой файл принимается (решение 13).""" + return _put_file(hub, body, allow_empty=True) + + +def _attachment(hub, conn, m): + att = attachments.get(conn, hub.files_dir, m.group(1)) + if att is None: + raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") + return att + + +def h_attachment_get(hub, conn, m, q, body): + return 200, _attachment(hub, conn, m) + + +def h_attachment_fetch(hub, conn, m, q, body): + author = body.get("author") or "api" + if not isinstance(author, str): + raise ApiError(422, "bad_request", "author — строка") + try: + return 200, queue.request_fetch(conn, hub.files_dir, m.group(1), author) + except KeyError: + raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None + except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17) + raise ApiError(413, "too_large", str(e)) from None + except queue.AgentTooOld as e: + raise ApiError(409, "agent_too_old", str(e)) from None + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) from None + + +def h_attachment_content(hub, conn, m, q, body): + """Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987).""" + att = _attachment(hub, conn, m) + # state == ready означает, что запись attachment_file есть и файл в хранилище на месте (attachments.state) + row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() \ + if att["state"] == "ready" else None + data = files.read(hub.files_dir, row["file"]) if row else None + if data is None: + raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание") + files.touch(hub.files_dir, row["file"]) + mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream" + return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""), + "X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"}) + + def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") @@ -385,6 +447,8 @@ def h_command_retry(hub, conn, m, q, body): project=body.get("project")) except KeyError: raise ApiError(404, "not_found", "command") + except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) + raise ApiError(413, "too_large", str(e)) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) @@ -495,6 +559,7 @@ ROUTES = [ ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), + ("POST", r"^/agent/files$", h_agent_files_post, "agent"), ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), @@ -505,6 +570,9 @@ ROUTES = [ ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/files$", h_files_post, "client"), + ("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"), + ("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"), + ("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), ("POST", r"^/refresh$", h_refresh, "client"), diff --git a/src/jira_hub/attachments.py b/src/jira_hub/attachments.py new file mode 100644 index 0000000..a9cd322 --- /dev/null +++ b/src/jira_hub/attachments.py @@ -0,0 +1,83 @@ +"""Вложения задач для интерфейсов: ссылки на них в вики-разметке текста, формат размера и состояние (data-model +«Скачанные вложения», hub-api «Ссылки на вложения в тексте»). Состояние вычисляется при чтении; модуль только читает +базу и хранилище файлов, очередь и HTTP не знает.""" +import re + +from . import files + +# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки; +# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя +_IMAGE = re.compile(r"(?=!(?!\s)([^!|\n]+)(?:\|[^!\n]*)?!)") +_LINK = re.compile(r"\[(?:[^\]|\n]*\|)?\^([^\]\n]+)\]") + + +def fmt_size(size: int | None) -> str: + if size is None: + return "размер неизвестен" + if size < 1024: + return f"{size} Б" + # десятые — в целых, округление half up (как в вебе, fmtSize); единица — по округлённому значению: + # 1048575 Б — «1,0 МБ», а не «1024,0 КБ» + kb = (size * 10 + 512) // 1024 + if kb < 10240: + return f"{kb // 10},{kb % 10} КБ" + mb = (size * 10 + 524288) // 1048576 + return f"{mb // 10},{mb % 10} МБ" + + +def ref_names(text: str | None) -> list[str]: + """Имена вложений из текста в порядке появления, без повторов; внешние картинки (`://`) пропускаются.""" + if not text: + return [] + found = [(m.start(), m.group(1)) for m in _IMAGE.finditer(text) if "://" not in m.group(1)] + found += [(m.start(), m.group(1)) for m in _LINK.finditer(text)] + names: list[str] = [] + for _, name in sorted(found, key=lambda f: f[0]): + if name not in names: + names.append(name) + return names + + +def _newest_key(item: dict): + aid = str(item["attachment_id"]) + return item.get("created_at") or "", int(aid) if aid.isdigit() else -1, aid + + +def resolve(names: list[str], items: list[dict]) -> list[str]: + """attachment_id по точному имени; при повторе имени — самое новое; имени нет среди вложений — пропуск.""" + out: list[str] = [] + for name in names: + same = [i for i in items if i["filename"] == name] + if same: + aid = str(max(same, key=_newest_key)["attachment_id"]) + if aid not in out: + out.append(aid) + return out + + +def state(conn, files_dir, attachment_id: str) -> dict: + """Состояние вложения: ready / fetching / failed / none. files_dir=None — файла в хранилище нет.""" + cmds = conn.execute( + "SELECT id, batch_id, status, error FROM command WHERE type = 'fetch_attachment' " + "AND json_extract(params, '$.attachment_id') = ? ORDER BY id DESC", (attachment_id,)).fetchall() + last = cmds[0] if cmds else None + row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (attachment_id,)).fetchone() + if row is not None and files_dir is not None and files.exists(files_dir, row["file"]): + return {"state": "ready", "error": None, "command_id": last["id"] if last else None, + "batch_id": last["batch_id"] if last else None} + live = next((c for c in cmds if c["status"] in ("queued", "claimed")), None) + if live is not None: + return {"state": "fetching", "error": None, "command_id": live["id"], "batch_id": live["batch_id"]} + if last is not None and last["status"] in ("failed", "cancelled"): + return {"state": "failed", "error": last["error"], "command_id": last["id"], "batch_id": last["batch_id"]} + return {"state": "none", "error": None, "command_id": last["id"] if last else None, + "batch_id": last["batch_id"] if last else None} + + +def get(conn, files_dir, attachment_id) -> dict | None: + row = conn.execute( + "SELECT attachment_id, key, filename, size, mime_type, author, created FROM issue_attachment WHERE attachment_id = ?", + (attachment_id,)).fetchone() + if row is None: + return None + return {**dict(row), **state(conn, files_dir, attachment_id)} diff --git a/src/jira_hub/client.py b/src/jira_hub/client.py index 55f356f..bdd6f60 100644 --- a/src/jira_hub/client.py +++ b/src/jira_hub/client.py @@ -31,19 +31,26 @@ class HubClient: conn.close() def call(self, method: str, path: str, body=None, params=None): + raw = self._raw(method, path, body, params, "application/json") + return json.loads(raw.decode("utf-8")) if raw else {} + + def get_bytes(self, path: str) -> bytes: + """Тело ответа как есть (содержимое вложения); ошибки — как у call.""" + return self._raw("GET", path, None, None, "*/*") + + def _raw(self, method, path, body, params, accept) -> bytes: url = self.base + path if params: url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", "Bearer " + self.token) - req.add_header("Accept", "application/json") + req.add_header("Accept", accept) if data is not None: req.add_header("Content-Type", "application/json; charset=utf-8") try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: - raw = resp.read() - return json.loads(raw.decode("utf-8")) if raw else {} + return resp.read() except urllib.error.HTTPError as e: try: payload = json.loads(e.read().decode("utf-8")) diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index f864ce7..f6d3c87 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -34,10 +34,12 @@ SPEC = { "ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"], "hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу", "filename": "имя вложения"}}, + "fetch_attachment": {"required": ["key", "attachment_id"], "optional": [], "keys": ["key"], + "hints": {"attachment_id": "id вложения из issue (цифры)"}}, "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"}, "note": "keys или jql"}, } -WRITE_TYPES = [t for t in SPEC if t != "refresh"] +WRITE_TYPES = [t for t in SPEC if t not in ("refresh", "fetch_attachment")] LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} BOOL_PARAMS = {"replace"} @@ -190,7 +192,7 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: (bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в зеркале нет (bug-and-attachments).""" ctype, p = cmd["type"], cmd["params"] - if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "refresh"): + if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "fetch_attachment", "refresh"): return False, None issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 8647a0b..dcb089b 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -53,6 +53,23 @@ CREATE TABLE IF NOT EXISTS issue_comment ( created_at TEXT, PRIMARY KEY (key, comment_id) ); +CREATE TABLE IF NOT EXISTS issue_attachment ( + attachment_id TEXT PRIMARY KEY, + key TEXT NOT NULL, + filename TEXT NOT NULL, + size INTEGER, + mime_type TEXT, + author TEXT, + created TEXT, + created_at TEXT +); +CREATE INDEX IF NOT EXISTS issue_attachment_key ON issue_attachment(key); +CREATE TABLE IF NOT EXISTS attachment_file ( + attachment_id TEXT PRIMARY KEY, + file TEXT NOT NULL, + size INTEGER NOT NULL, + fetched_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS issue_change ( history_id TEXT NOT NULL, seq INTEGER NOT NULL, @@ -166,6 +183,7 @@ MIGRATIONS = { 3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки 4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений _fill_comment_utc], + 5: [], # v5 → v6: issue_attachment и attachment_file создаются SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно } diff --git a/src/jira_hub/files.py b/src/jira_hub/files.py index abb81c7..e553181 100644 --- a/src/jira_hub/files.py +++ b/src/jira_hub/files.py @@ -4,6 +4,7 @@ import hashlib import os import re +import shutil import tempfile import time from pathlib import Path @@ -65,12 +66,12 @@ def _intact(target, sha) -> bool: return False -def put(root, data: bytes) -> tuple[str, int]: +def put(root, data: bytes, allow_empty: bool = False) -> tuple[str, int]: """Сохранить содержимое, вернуть (sha256, размер). Целая копия уже есть (тот же sha256 содержимого) — только обновить mtime (уборка считает возраст от последней загрузки); оборванная или испорченная — переписать (решение 13). Запись атомарная: временный файл mkstemp в том же каталоге, fsync, os.replace; при сбое временный файл удаляется - (решение 11).""" - if not data: + (решение 11). allow_empty — пустое содержимое принимается (файл от агента: в Jira бывают пустые вложения, решение 13).""" + if not data and not allow_empty: raise EmptyFile("пустой файл") if len(data) > MAX_SIZE: raise TooLarge(f"файл больше {MAX_SIZE} байт ({len(data)})") @@ -97,6 +98,37 @@ def put(root, data: bytes) -> tuple[str, int]: return sha, len(data) +def touch(root, sha) -> None: + """Обновить mtime файла (выдача содержимого продлевает ему жизнь, решение 14); файла нет или sha негоден — ничего.""" + if not is_sha(sha): + return + try: + os.utime(path(root, sha)) + except FileNotFoundError: + pass + + +def sweep_downloads(root, now: float | None = None) -> list[str]: + """Удалить каталоги копий MCP //, в которых ничего не менялось KEEP_DAYS (новее из mtime каталога + и его файлов); вернуть имена. Файлы прямо в корне и сбой удаления одного каталога уборку не прерывают.""" + root = Path(root) + if not root.is_dir(): + return [] + cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400 + removed = [] + for d in sorted(root.iterdir()): + try: + if d.is_symlink() or not d.is_dir(): + continue + newest = max([d.stat().st_mtime] + [f.stat().st_mtime for f in d.iterdir() if f.is_file()]) + if newest < cutoff: + shutil.rmtree(d) + removed.append(d.name) + except OSError: + continue + return removed + + def sweep(conn, root, now: float | None = None) -> list[str]: """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 879d747..de4b6aa 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -5,13 +5,17 @@ import base64 import functools import inspect import json +import os import stat +import tempfile import time from datetime import datetime, timedelta from pathlib import Path from mcp.server.fastmcp import FastMCP +from . import paths +from .attachments import fmt_size from .client import HubClient, HubError, HubUnavailable from .commands import describe, is_temp from .files import MAX_SIZE @@ -125,6 +129,48 @@ def fmt_issues(issues) -> str: return "\n".join(lines) +def fmt_attachment_line(a: dict, d: dict) -> str: + """Строка вложения в issue: «#id имя (размер) — автор, создано» и через запятую, где оно упомянуто и скачано.""" + aid = str(a["attachment_id"]) + line = f" #{aid} {a['filename']} ({fmt_size(a.get('size'))}) — {_v(a.get('author'))}, {_v(a.get('created'))}" + marks = [] + if aid in (d.get("description_attachment_ids") or []): + marks.append("в описании") + marks += [f"в #{c.get('comment_id')}" for c in d.get("comments") or [] if aid in (c.get("attachment_ids") or [])] + if a.get("state") == "ready": + marks.append("скачано") + if a.get("size") is not None and a["size"] > MAX_SIZE: + marks.append("больше 10 МБ — только в Jira") + return line + "".join(", " + m for m in marks) + + +def fmt_attachments(d: dict, atts: list) -> list[str]: + if not atts: + return ["вложений нет"] + return [f"вложения ({len(atts)}):"] + [fmt_attachment_line(a, d) for a in atts] + + +def _files_line(c: dict, atts: list) -> str: + """«файлы: #id имя, …» для комментария; пусто, если ссылок на вложения нет.""" + by_id = {str(a["attachment_id"]): a for a in atts} + parts = [f"#{i} {by_id[i]['filename']}" for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id] + return "файлы: " + ", ".join(parts) if parts else "" + + +def safe_name(filename: str, attachment_id: str) -> str: + """Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; не длиннее 255 байт UTF-8; пусто, «.», «..» → attachment-.""" + name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "") + if len(name.encode()) > 255: # предел имени в ФС — 255 байт: режем основу, расширение сохраняем + stem, dot, ext = name.rpartition(".") + if not dot or len(ext.encode()) > 32: # «расширение» из одного длинного хвоста — просто режем всё имя + stem, ext = name, "" + else: + ext = "." + ext + stem = stem.encode()[:255 - len(ext.encode())].decode("utf-8", "ignore") + name = stem + ext + return name if name.strip() and name.strip() not in (".", "..") else f"attachment-{attachment_id}" + + def fmt_issue(d) -> str: head = [f"{d['key']} — {d.get('summary')}", f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; " @@ -138,11 +184,17 @@ def fmt_issue(d) -> str: cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] comments = d.get("comments") or [] if comments: - shown = [f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" - for c in comments[:5]] + shown = [] + for c in comments[:5]: + shown.append(f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}") + files_line = _files_line(c, d.get("attachments") or []) + if files_line: + shown.append(" " + files_line) cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else []) else: cmts = ["комментариев нет"] + atts = d.get("attachments") or [] + cmts += fmt_attachments(d, atts) changes = d.get("changes") or [] if changes: cmts += [f"изменения ({len(changes)}):"] + [" " + fmt_event(e, None) for e in changes[:5]] + \ @@ -161,12 +213,18 @@ def _day(value, text) -> str: return _v(text) -def fmt_comment(key, c) -> str: - """Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили.""" +def fmt_comment(key, c, atts=None) -> str: + """Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили. + atts — вложения задачи: ссылки комментария на них выводятся блоком «вложения:».""" head = f"{key} #{c.get('comment_id')} {_v(c.get('author'))}, создан {_v(c.get('created'))}" if c.get("updated") and c.get("updated") != c.get("created"): head += f", изменён {c['updated']}" - return head + "\n" + _v(c.get("body")) + out = head + "\n" + _v(c.get("body")) + by_id = {str(a["attachment_id"]): a for a in atts or []} + refs = [by_id[i] for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id] + if refs: + out += "\nвложения:" + "".join(f"\n #{a['attachment_id']} {a['filename']} ({fmt_size(a.get('size'))})" for a in refs) + return out def fmt_event(e: dict, today) -> str: @@ -267,7 +325,8 @@ def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_statu @scoped(True) def issue(key: str) -> str: """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5 - последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру.""" + последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком + (номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment.""" return fmt_issue(_client().get(f"/issues/{key}")) @@ -277,12 +336,81 @@ def issue(key: str) -> str: def comment(key: str, comment_id: str) -> str: """Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»).""" cid = comment_id.lstrip("#") - for c in _client().get(f"/issues/{key}").get("comments") or []: + d = _client().get(f"/issues/{key}") + for c in d.get("comments") or []: if c.get("comment_id") == cid: - return fmt_comment(key, c) + return fmt_comment(key, c, d.get("attachments")) return f"у {key} нет комментария #{cid}" +def _store_copy(attachment_id: str, name: str, data: bytes) -> Path: + """Копия вложения в <каталог данных>/downloads//<имя>: каталоги 0700, запись через временный файл и + os.replace; файл того же размера уже на месте — не трогаем.""" + root = paths.db_path().parent / "downloads" + folder = root / attachment_id + for d in (root, folder): + d.mkdir(mode=0o700, parents=True, exist_ok=True) + d.chmod(0o700) + target = folder / name + if target.is_file() and target.stat().st_size == len(data): + os.utime(folder) # копия снова нужна — уборка по mtime папки её не заденет + return target + fd, tmp = tempfile.mkstemp(dir=folder, prefix=".part-") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(tmp, target) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return target + + +@mcp.tool() +@guard +@scoped(True) +async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: + """Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из + issue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое. + Хаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600, + по умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ + скачиваются только в Jira.""" + client = _client() + wait = min(max(int(wait_seconds), 1), 600) + d = await asyncio.to_thread(client.get, f"/issues/{key}") + atts = d.get("attachments") or [] + ref = attachment.strip().lstrip("#") + found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else [] + prefix = "" + if not found: + found = [a for a in atts if a["filename"] == attachment.strip()] # хаб отдаёт вложения новыми сверху + if len(found) > 1: + prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n" + if not found: + return f"у {key} нет вложения «{attachment}»; список — issue {key}" + att = found[0] + aid = str(att["attachment_id"]) + try: + st = await asyncio.to_thread(client.post, f"/attachments/{aid}/fetch", {"author": AUTHOR}) + except HubError as e: + if e.status == 413: + return f"файл больше 10 МБ ({fmt_size(att.get('size'))}) — скачайте в Jira: задача {key}, вложение «{att['filename']}»" + raise + deadline = time.monotonic() + wait + while st.get("state") == "fetching" and time.monotonic() < deadline: + await asyncio.sleep(POLL_SECONDS / 2) + st = await asyncio.to_thread(client.get, f"/attachments/{aid}") + if st.get("state") == "fetching": + online = (await asyncio.to_thread(client.get, "/agent")).get("online") + return (f"скачивание ещё идёт (команда #{st.get('command_id')}, агент {'онлайн' if online else 'офлайн'}); " + "вызовите attachment ещё раз — вторая команда не поставится") + if st.get("state") != "ready": + return f"не удалось скачать: {_v(st.get('error'))}" + data = await asyncio.to_thread(client.get_bytes, f"/attachments/{aid}/content") + path = await asyncio.to_thread(_store_copy, aid, safe_name(att["filename"], aid), data) + return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {_v(att.get('mime_type'))}, задача {key}") + + def _local_if_naive(value: str) -> str: """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" if not value: diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index 0fb3184..2ebcafe 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -1,8 +1,8 @@ -"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note). +"""Зеркало задач Jira (issue, issue_link, issue_comment, issue_attachment) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json -from . import db, history +from . import attachments, db, history ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] @@ -28,6 +28,30 @@ def check_comments(issues) -> None: if c.get(f) is not None and not isinstance(c[f], str): raise ValueError(f"{where}.{f} — строка или null") +def check_attachments(issues) -> None: + """Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем.""" + for it in issues: + if "attachments" not in it: + continue + key, lst = it.get("key"), it["attachments"] + if not isinstance(lst, list): + raise ValueError(f"{key}: attachments — список вложений") + for n, a in enumerate(lst): + where = f"{key}: attachments[{n}]" + if not isinstance(a, dict): + raise ValueError(f"{where} — объект вложения") + if not isinstance(a.get("id"), str) or not a["id"].isdigit(): + raise ValueError(f"{where}.id — строка из цифр") + if not isinstance(a.get("filename"), str) or not a["filename"]: + raise ValueError(f"{where}.filename — непустая строка") + size = a.get("size") + if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size < 0): + raise ValueError(f"{where}.size — неотрицательное целое или null") + for f in ("mime_type", "author", "created"): + if a.get(f) is not None and not isinstance(a[f], str): + raise ValueError(f"{where}.{f} — строка или null") + + _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} @@ -62,6 +86,13 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit "VALUES (?, ?, ?, ?, ?, ?, ?)", [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "", db.jira_utc(c.get("created"))) for c in it["comments"]]) + if isinstance(it.get("attachments"), list): # без поля (агент до v14) вложения не трогаем; attachment_file не трогаем никогда + conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],)) + conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается + "INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [(a["id"], it["key"], a["filename"], a.get("size"), a.get("mime_type"), a.get("author"), a.get("created"), + db.jira_utc(a.get("created"))) for a in it["attachments"]]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", @@ -138,7 +169,7 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l return out -def get_issue(conn, key) -> dict | None: +def get_issue(conn, key, files_dir=None) -> dict | None: row = conn.execute("SELECT * FROM issue WHERE key = ?", (key,)).fetchone() if row is None: return None @@ -154,10 +185,21 @@ def get_issue(conn, key) -> dict | None: d["comments"] = [dict(r) for r in conn.execute( "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))] + rows = [dict(r) for r in conn.execute( + "SELECT attachment_id, filename, size, mime_type, author, created, created_at FROM issue_attachment WHERE key = ? " + "ORDER BY created_at DESC, CAST(attachment_id AS INTEGER) DESC, attachment_id DESC", (key,))] + d["attachments"] = [] + for r in rows: + st = attachments.state(conn, files_dir, r["attachment_id"]) + d["attachments"].append({**{k: v for k, v in r.items() if k != "created_at"}, "state": st["state"], "error": st["error"]}) + d["description_attachment_ids"] = attachments.resolve(attachments.ref_names(d.get("description")), rows) + for c in d["comments"]: + c["attachment_ids"] = attachments.resolve(attachments.ref_names(c["body"]), rows) d["changes"] = history.issue_changes(conn, key) d["note"] = get_note(conn, key) d["commands"] = [dict(r) for r in conn.execute( - "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", + "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? AND type != 'fetch_attachment' " + "ORDER BY id DESC LIMIT 10", (f'%"{key}"%',))] return d diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 70a62b8..b6a4c5a 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -7,7 +7,7 @@ from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import commands as C -from . import db, files, history, mirror, projects, settings +from . import attachments, db, files, history, mirror, projects, settings log = logging.getLogger(__name__) @@ -15,6 +15,7 @@ TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"} MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2) ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands) ONLINE_MINUTES = 5 +READ_TYPES = {"refresh", "fetch_attachment"} # не пишут в Jira: без хвостового refresh и без резервной копии class ClaimedError(Exception): @@ -23,6 +24,17 @@ class ClaimedError(Exception): self.claimed_at = claimed_at +class TooLarge(C.CommandError): + """Вложение больше предела хаба (fetch_attachment) — API отвечает 413 too_large; для POST /batches это 422.""" + + +class AgentTooOld(ValueError): + """Агент ещё не умеет fetch_attachment — API отвечает 409 agent_too_old.""" + + +FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment + + class NotClaimedError(Exception): """Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409.""" @@ -67,6 +79,15 @@ def _check_setup(conn, cmds, files_dir) -> None: if '"' in p["filename"] or any(ord(ch) < 32 or ord(ch) == 127 for ch in p["filename"]): # кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2) raise C.CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") + if c["type"] == "fetch_attachment": + if not p["attachment_id"].isdigit(): + raise C.CommandError(f"команда {i}: attachment_id — цифры id вложения из issue") + row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?", + (p["attachment_id"], p["key"])).fetchone() + if row is None: + raise C.CommandError(f"у {p['key']} нет вложения #{p['attachment_id']} в зеркале — обновите задачу") + if row["size"] is not None and row["size"] > files.MAX_SIZE: # size NULL — размер неизвестен, ставим + raise TooLarge(f"файл больше 10 МБ ({attachments.fmt_size(row['size'])}) — скачайте в Jira") _assign_boards(conn, cmds) @@ -123,7 +144,7 @@ def _check_project(conn, cmds, project) -> list[dict]: elif t == "ensure_link": if not (own(p["source"]) or own(p["target"])): raise C.CommandError(f"команда {i}: связь {p['source']} → {p['target']} не касается проекта {project}") - elif t != "refresh": + elif t not in ("refresh", "fetch_attachment"): # fetch_attachment — не запись в Jira, проекта не касается if t == "ensure_fields": if project.casefold() in {l.casefold() for l in p.get("labels_remove") or []}: raise C.CommandError(f"команда {i}: снять метку своего проекта {project} нельзя") @@ -150,8 +171,13 @@ def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dic keys = [] for c in cmds: keys += [k for k in C.referenced_keys(c) if k not in keys] - if any(c["type"] != "refresh" for c in cmds) and keys: + if any(c["type"] not in READ_TYPES for c in cmds) and keys: cmds.append({"type": "refresh", "params": {"keys": keys}}) + return _insert_batch(conn, title, author, cmds, project) + + +def _insert_batch(conn, title, author, cmds, project=None) -> dict: + """Вставка пакета без проверок и хвоста; транзакция своя (вложенная идёт через SAVEPOINT).""" est = C.estimate([c["type"] for c in cmds], settings.pacing(conn)) with db.transaction(conn): cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES (?, ?, ?, ?, ?)", @@ -167,6 +193,27 @@ def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dic return {"id": batch_id, "estimate_seconds": est, "count": len(cmds)} +def request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> dict: + """Поставить скачивание вложения. Проверка «уже качается/скачано» и вставка пакета — в одной транзакции с + блокировкой записи (BEGIN IMMEDIATE): два одновременных запроса дают одну команду (решение 7). + Ответ — как attachments.get плюс command_id, batch_id. KeyError — вложения нет в зеркале; TooLarge, AgentTooOld.""" + attachment_id = str(attachment_id) + with db.transaction(conn): + info = attachments.get(conn, files_dir, attachment_id) + if info is None: + raise KeyError(attachment_id) + if info["state"] in ("ready", "fetching"): + return info + agent = conn.execute("SELECT agent_version FROM agent_status WHERE id = 1").fetchone() + v = agent["agent_version"] if agent else None + if v is not None and str(v).isdigit() and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем + raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}") + cmds = C.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}]) + _check_setup(conn, cmds, files_dir) + _insert_batch(conn, f"Скачать вложение {info['filename']} ({info['key']})", author, cmds) + return attachments.get(conn, files_dir, attachment_id) + + def _status_of(cmds, cancelled_at=None) -> str: sts = [c["status"] for c in cmds] if not sts: @@ -304,7 +351,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non pause = int(settings.pacing(conn).get(cmd["type"], 0)) first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", (cmd["batch_id"],)).fetchone()["n"] == 0 - has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type != 'refresh' LIMIT 1", + has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type NOT IN ('refresh', 'fetch_attachment') LIMIT 1", (cmd["batch_id"],)).fetchone() is not None conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ?, deliveries = 1 " "WHERE id = ?", (now, pause, _dump(cmd["params"]), cmd["id"])) @@ -340,7 +387,7 @@ def record_state(conn, command_id, issues_count, links_count) -> None: (int(issues_count), int(links_count), command_id)) -def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0) -> dict: +def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0, files_dir=None) -> dict: if status not in ("done", "failed"): raise ValueError("status — done или failed") error = None if error is None else str(error)[:ERROR_LIMIT] @@ -363,12 +410,24 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= return get_command(conn, command_id) return cmd # идемпотентно; терминальный статус (cancelled/failed) не затирается now = db.now() + fetched = None + if cmd["type"] == "fetch_attachment" and status == "done": + # файл должен лежать в хранилище с заявленным размером; иначе отчёт не принимаем (уборка успела раньше, чужой размер) + sha = result.get("file") if isinstance(result, dict) else None + real = files.size(files_dir, sha) if files_dir is not None else None + if real is None or real != result.get("size"): + status, error = "failed", f"хаб: файл {sha} не найден в хранилище"[:ERROR_LIMIT] + else: + fetched = (cmd["params"]["attachment_id"], sha, real) conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", (status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id)) conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?", (now, command_id)) if cmd["type"] == "ensure_issue" and status == "done" and result and result.get("key"): mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + if fetched: + conn.execute("INSERT OR REPLACE INTO attachment_file(attachment_id, file, size, fetched_at) VALUES (?, ?, ?, ?)", + (*fetched, now)) if cmd["type"] == "refresh": conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index c278a7c..5cca068 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -5,7 +5,7 @@ import os import secrets DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, - "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2} + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2} DEFAULTS = { "project_key": "AURORA", @@ -136,7 +136,7 @@ def pacing_from_config(p: dict) -> dict: """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, - "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "refresh": 2} + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2} def _issue_types(cfg: dict) -> dict: diff --git a/tests/test_api.py b/tests/test_api.py index 3ad6f5e..66e6497 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -632,6 +632,19 @@ def test_agent_state_comments_validated_whole_batch(hub): assert e.value.status == 404 +def test_agent_state_bad_attachment_422(hub): + a, c = hub["agent"], hub["client"] + with pytest.raises(HubError) as e: + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}, + {"key": "Y-1", "summary": "Игрек", "labels": [], + "attachments": [{"id": "7", "filename": "a", "size": -1}]}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" + assert "Y-1" in e.value.detail and "attachments[0].size" in e.value.detail + with pytest.raises(HubError) as e: + c.get("/issues/X-1") + assert e.value.status == 404 # пачка не применена целиком + + def test_issue_detail_with_comments(hub): hub["agent"].post("/agent/state", {"issues": [ {"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [ @@ -1005,3 +1018,181 @@ def test_changes_route_bad_params(hub, params, status, name): assert e.value.status == status if name: assert e.value.error == "bad_request" and name in e.value.detail + + +def test_issue_has_attachments(hub): + a, c = hub["agent"], hub["client"] + c.post("/batches", {"title": "x", "author": "claude", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]}) + cmd = a.post("/agent/claim", {})["command"] + a.post("/agent/state", {"command_id": cmd["id"], "page": 1, "last_page": True, "links": [], "issues": [ + {"key": "A-1", "summary": "Т", "labels": [], "description": "!a.png!", "created": "2026-10-10T10:00:00.000+0300", + "attachments": [{"id": "7", "filename": "a.png", "size": 10, "created": "2026-10-10T10:00:00.000+0300"}]}]}) + got = c.get("/issues/A-1") + assert got["attachments"][0]["attachment_id"] == "7" and got["attachments"][0]["state"] == "none" + assert got["description_attachment_ids"] == ["7"] + + +# --- вложения из Jira по запросу (#83) ---------------------------------------------- + +ATT_NAME = "скрин 1.png" + + +def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME): + conn = hub["hub"].conn() + try: + from jira_hub import mirror + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], []) + finally: + conn.close() + + +def _content(hub, path): + req = urllib.request.Request(hub["base"] + path, headers={"Authorization": "Bearer " + hub["client"].token}) + return urllib.request.urlopen(req) + + +def _fetch_done(hub, data=b"abc"): + """Запрос скачивания, claim агентом, загрузка файла и отчёт done.""" + out = hub["client"].post("/attachments/31337/fetch", {"author": "web"}) + cmd = hub["agent"].post("/agent/claim", {})["command"] + up = hub["agent"].post("/agent/files", {"content": _b64(data)}) + hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": up}) + return out, cmd, up + + +def test_agent_files_post(hub): + a = hub["agent"] + out = a.post("/agent/files", {"content": _b64(b"abc")}) + assert out == {"file": hashlib.sha256(b"abc").hexdigest(), "size": 3} + with pytest.raises(HubError) as e: + hub["client"].post("/agent/files", {"content": _b64(b"abc")}) + assert e.value.status == 401 + assert a.post("/agent/files", {"content": ""}) == {"file": hashlib.sha256(b"").hexdigest(), "size": 0} + with pytest.raises(HubError) as e: + a.post("/agent/files", {"content": _b64(b"\0" * (11 * 1024 * 1024))}) + assert e.value.status == 413 and e.value.error == "too_large" + with pytest.raises(HubError) as e: + a.post("/agent/files", {"content": "***"}) + assert e.value.status == 400 + + +def test_attachment_get_404(hub): + for method, path in (("get", "/attachments/1"), ("post", "/attachments/1/fetch"), ("get", "/attachments/1/content")): + with pytest.raises(HubError) as e: + getattr(hub["client"], method)(path) + assert e.value.status == 404 and e.value.error == "no_attachment" + + +def test_attachment_fetch_flow(hub): + _seed_attachment(hub) + out, cmd, up = _fetch_done(hub) + assert out["state"] == "fetching" and out["command_id"] and out["batch_id"] + assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2 + got = hub["client"].get("/attachments/31337") + assert got["state"] == "ready" and got["filename"] == ATT_NAME and got["key"] == "A-1" + resp = _content(hub, "/attachments/31337/content") + assert resp.status == 200 and resp.read() == b"abc" + assert resp.headers["Content-Type"] == "image/png" + assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png" + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["Content-Length"] == "3" + + +def test_attachment_content_updates_mtime(hub): + _seed_attachment(hub) + _, _, up = _fetch_done(hub) + p = hub["hub"].files_dir / up["file"] + old = time.time() - 10 * 86400 + os.utime(p, (old, old)) + _content(hub, "/attachments/31337/content").read() + assert p.stat().st_mtime > time.time() - 60 + + +def test_attachment_content_special_name(hub): + name = "a:b/c d\u00e9.txt" + _seed_attachment(hub, name=name, mime="text/plain") + _fetch_done(hub) + resp = _content(hub, "/attachments/31337/content") + assert resp.headers["Content-Disposition"] == "attachment; filename*=UTF-8''a%3Ab%2Fc%20d%C3%A9.txt" + + +def test_attachment_content_not_fetched(hub): + _seed_attachment(hub) + with pytest.raises(HubError) as e: + hub["client"].get("/attachments/31337/content") + assert e.value.status == 404 and e.value.error == "not_fetched" + + +def test_attachment_content_bad_mime(hub): + _seed_attachment(hub, mime="weird") + _fetch_done(hub) + assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream" + + +def test_attachment_content_non_ascii_mime(hub): + """Кириллический mime_type не должен ронять send_header (UnicodeEncodeError → 500): \w в MIME_RE — только ASCII.""" + _seed_attachment(hub, mime="текст/простой") + _fetch_done(hub) + assert _content(hub, "/attachments/31337/content").headers["Content-Type"] == "application/octet-stream" + + +def test_attachment_fetch_twice_same_command(hub): + _seed_attachment(hub) + a = hub["client"].post("/attachments/31337/fetch", {}) + b = hub["client"].post("/attachments/31337/fetch", {}) + assert a["command_id"] == b["command_id"] and b["state"] == "fetching" + + +def test_attachment_fetch_too_large_413(hub): + _seed_attachment(hub, size=25_000_000) + with pytest.raises(HubError) as e: + hub["client"].post("/attachments/31337/fetch", {}) + assert e.value.status == 413 and e.value.error == "too_large" + assert e.value.detail == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira" + assert hub["client"].get("/batches")["batches"] == [] + + +def test_batches_post_fetch_too_large_413(hub): + """M-2: POST /batches с fetch_attachment > 10 МБ — 413 too_large, не 422.""" + _seed_attachment(hub, size=25_000_000) + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": "31337"}}]}) + assert e.value.status == 413 and e.value.error == "too_large" + assert hub["client"].get("/batches")["batches"] == [] + + +def test_command_retry_fetch_too_large_413(hub): + """M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large.""" + _seed_attachment(hub) + st = hub["client"].post("/attachments/31337/fetch", {}) + _seed_attachment(hub, size=25_000_000) + with pytest.raises(HubError) as e: + hub["client"].post(f"/commands/{st['command_id']}/retry", {"author": "claude"}) + assert e.value.status == 413 and e.value.error == "too_large" + + +def test_attachment_fetch_agent_too_old_409(hub): + _seed_attachment(hub) + hub["agent"].post("/agent/claim", {"agent_version": 13}) + with pytest.raises(HubError) as e: + hub["client"].post("/attachments/31337/fetch", {}) + assert e.value.status == 409 and e.value.error == "agent_too_old" + assert e.value.detail == "агент v13 не умеет скачивать вложения — нужен v14" + + +def test_sweep_files_downloads(hub): + h = hub["hub"] + d = h.downloads_dir / "31337" + d.mkdir(parents=True) + (d / "a.png").write_bytes(b"x") + old = time.time() - 31 * 86400 + os.utime(d, (old, old)) + os.utime(d / "a.png", (old, old)) + conn = h.conn() + try: + h.sweep_files(conn) + finally: + conn.close() + assert not d.exists() diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 0000000..e1be1ed --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,94 @@ +import json + +import pytest + +from jira_hub import attachments, db, files, mirror + + +def test_fmt_size(): + assert attachments.fmt_size(512) == "512 Б" + assert attachments.fmt_size(240000) == "234,4 КБ" + assert attachments.fmt_size(25_000_000) == "23,8 МБ" + assert attachments.fmt_size(None) == "размер неизвестен" + # M-1: десятые считаются в целых, округление half up; единица — по округлённому значению + assert attachments.fmt_size(1280) == "1,3 КБ" + assert attachments.fmt_size(1048575) == "1,0 МБ" + assert attachments.fmt_size(1310720) == "1,3 МБ" + + +def test_ref_names(): + text = "см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!" + assert attachments.ref_names(text) == ["скрин 1.png", "ОСВ 60.xlsx"] + assert attachments.ref_names("[отчёт|^a.pdf]") == ["a.pdf"] + assert attachments.ref_names("!http://x/y.png!") == [] + assert attachments.ref_names(None) == [] + + +def test_ref_names_lone_exclamation(): + """I-1: одиночный «!» перед настоящей ссылкой не съедает её открывающий «!».""" + assert "скрин.png" in attachments.ref_names("Внимание! см. !скрин.png!") + names = attachments.ref_names("Важно! [^b.xlsx] и !скрин.png!") + assert names.index("b.xlsx") < names.index("скрин.png") + assert attachments.ref_names("{code}!a.png!{code} !b.png!")[-1] == "b.png" + assert attachments.ref_names("!http://x/y.png! !c.png!")[-1] == "c.png" + + +def test_resolve_newest(): + items = [{"attachment_id": "10", "filename": "скрин.png", "created_at": "2026-10-14T10:00:00+00:00"}, + {"attachment_id": "20", "filename": "скрин.png", "created_at": "2026-10-20T10:00:00+00:00"}] + assert attachments.resolve(["скрин.png", "нет.png", "скрин.png"], items) == ["20"] + + +@pytest.fixture +def conn(open_db): + c = open_db() + mirror.apply_state(c, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "31337", "filename": "a.png", "size": 3, "created": "2026-10-20T10:00:00.000+0300"}]}], []) + return c + + +def add_cmd(conn, status, error=None, att="31337"): + conn.execute("INSERT OR IGNORE INTO batch(id, title, author, created_at) VALUES (1, 't', 'a', ?)", (db.now(),)) + cur = conn.execute("INSERT INTO command(batch_id, seq, type, params, status, error) VALUES (1, (SELECT COUNT(*) FROM command), " + "'fetch_attachment', ?, ?, ?)", (json.dumps({"key": "A-1", "attachment_id": att}), status, error)) + return cur.lastrowid + + +def test_state_none(conn, tmp_path): + assert attachments.state(conn, tmp_path, "31337") == {"state": "none", "error": None, "command_id": None, "batch_id": None} + + +@pytest.mark.parametrize("status", ["queued", "claimed"]) +def test_state_fetching(conn, tmp_path, status): + cid = add_cmd(conn, status) + assert attachments.state(conn, tmp_path, "31337") == {"state": "fetching", "error": None, "command_id": cid, "batch_id": 1} + + +def put_ready(conn, root): + sha, size = files.put(root, b"abc") + conn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('31337', ?, ?, ?)", + (sha, size, db.now())) + return sha + + +def test_state_ready_and_removed(conn, tmp_path): + sha = put_ready(conn, tmp_path) + add_cmd(conn, "done") + assert attachments.state(conn, tmp_path, "31337")["state"] == "ready" + assert attachments.get(conn, tmp_path, "31337")["filename"] == "a.png" + (tmp_path / sha).unlink() + assert attachments.state(conn, tmp_path, "31337")["state"] == "none" + assert attachments.state(conn, None, "31337")["state"] == "none" + + +def test_state_failed_and_cancelled(conn, tmp_path): + add_cmd(conn, "failed", "нет доступа") + assert attachments.state(conn, tmp_path, "31337")["state"] == "failed" + assert attachments.state(conn, tmp_path, "31337")["error"] == "нет доступа" + add_cmd(conn, "cancelled", "отменена") + got = attachments.state(conn, tmp_path, "31337") + assert got["state"] == "failed" and got["error"] == "отменена" + + +def test_get_missing(conn, tmp_path): + assert attachments.get(conn, tmp_path, "999") is None diff --git a/tests/test_commands.py b/tests/test_commands.py index 9d52d63..be46d17 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -260,3 +260,10 @@ def test_validate_empty_list_params(): commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}]) with pytest.raises(CommandError, match="refresh без keys и jql"): commands.validate([{"type": "refresh", "params": {"keys": []}}]) + + +def test_fetch_not_confirmed_by_state(conn): + ok, result = commands.check_confirmed(conn, {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": "5"}}, LT) + assert (ok, result) == (False, None) + assert "fetch_attachment" not in commands.WRITE_TYPES + assert commands.SPEC["fetch_attachment"]["required"] == ["key", "attachment_id"] diff --git a/tests/test_db.py b/tests/test_db.py index c4d562f..904b4cf 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -18,14 +18,21 @@ def test_connect_creates_schema(open_db): assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")} assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")} assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} assert "deliveries" in cols +def _drop_v6(conn): + """Снять с базы текущей схемы то, что добавила v6 (вложения).""" + conn.execute("DROP TABLE attachment_file") + conn.execute("DROP TABLE issue_attachment") + + def _drop_v5(conn): - """Снять с базы текущей схемы то, что добавила v5: так строятся базы прежних версий.""" + """Снять с базы текущей схемы то, что добавили v5 и v6: так строятся базы прежних версий.""" + _drop_v6(conn) conn.execute("DROP TABLE issue_change") conn.execute("ALTER TABLE issue_comment DROP COLUMN created_at") conn.execute("ALTER TABLE sync_run DROP COLUMN history_at") @@ -51,7 +58,7 @@ def test_migrates_v1_with_backup(tmp_path): path = tmp_path / "hub.sqlite" _v1_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -198,7 +205,7 @@ def test_parallel_connect_makes_one_backup(tmp_path): assert errors == [] assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" conn.close() @@ -215,7 +222,7 @@ def test_migrates_v2_with_backup(tmp_path): conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0 assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -283,7 +290,7 @@ def test_migrates_v3_creates_awpca(tmp_path): queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] assert sorted(queued) == sorted([new, "key = A-1"]) assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 conn.close() @@ -302,7 +309,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): conn = db.connect(tmp_path / "hub.sqlite") assert projects.list_projects(conn) == [] assert "ORDER BY key" in caplog.text - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" conn.close() @@ -336,10 +343,24 @@ def test_migrates_v4(tmp_path): path = tmp_path / "hub.sqlite" _v4_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" rows = {r["comment_id"]: (r["created"], r["created_at"]) for r in conn.execute("SELECT * FROM issue_comment")} assert rows == {"1": ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), "2": ("мусор", None)} assert conn.execute("SELECT history_at FROM sync_run").fetchone()["history_at"] is None assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 0 assert len(list((tmp_path / "backups").glob("*-schema-v4.sqlite"))) == 1 conn.close() + + +def test_migrate_v5_to_v6(tmp_path): + path = tmp_path / "hub.sqlite" + conn = db.connect(path) + _drop_v6(conn) + conn.execute("UPDATE setting SET value = '5' WHERE key = 'schema_version'") + conn.close() + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT COUNT(*) AS n FROM issue_attachment").fetchone()["n"] == 0 + assert conn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 + assert len(list((tmp_path / "backups").glob("*-schema-v5.sqlite"))) == 1 + conn.close() diff --git a/tests/test_files.py b/tests/test_files.py index 470412d..c7c978d 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -155,3 +155,37 @@ def test_put_rewrites_corrupted_copy_of_same_size(root): (root / ABC).write_bytes(b"xyz") # тот же размер, другое содержимое assert files.put(root, b"abc") == (ABC, 3) assert (root / ABC).read_bytes() == b"abc" + + +# --- allow_empty, touch, sweep_downloads (#83) --------------------------------- + +def test_put_allow_empty(root): + assert files.put(root, b"", allow_empty=True) == (hashlib.sha256(b"").hexdigest(), 0) + with pytest.raises(files.EmptyFile): + files.put(root, b"") + + +def test_touch_updates_mtime(root): + sha, _ = files.put(root, b"abc") + old = time.time() - 5 * DAY + os.utime(root / sha, (old, old)) + files.touch(root, sha) + assert (root / sha).stat().st_mtime > time.time() - 60 + files.touch(root, "0" * 64) # файла нет — не ошибка + files.touch(root, "../x") # негодный sha — не ошибка + + +def test_sweep_downloads(tmp_path): + root = tmp_path / "downloads" + old, fresh = root / "31337", root / "31338" + for d in (old, fresh): + d.mkdir(parents=True) + (d / "a.png").write_bytes(b"x") + (root / "loose.txt").write_text("x") + t = time.time() - 31 * DAY + os.utime(old / "a.png", (t, t)) + os.utime(old, (t, t)) + os.utime(root / "loose.txt", (t, t)) + assert files.sweep_downloads(root) == ["31337"] + assert not old.exists() and fresh.exists() and (root / "loose.txt").exists() + assert files.sweep_downloads(tmp_path / "нет") == [] diff --git a/tests/test_mcp.py b/tests/test_mcp.py index a2a4ad8..60fcb33 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -4,6 +4,7 @@ import json import os import re import threading +from pathlib import Path from types import SimpleNamespace import pytest @@ -647,3 +648,193 @@ def test_sync_log_history(live): live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}}) assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", mcp_server.sync_log()) + + +# --- вложения (#83) ----------------------------------------------------------------- + +ATT_KEY = "AURORA-1650" + + +def _att(aid, name, size, state="none", **over): + d = {"attachment_id": aid, "filename": name, "size": size, "mime_type": "image/png", "author": "golov", + "created": "2026-10-20T10:00:00.000+0300", "state": state, "error": None} + d.update(over) + return d + + +class FakeHub: + """Фейковый HubClient: журнал вызовов и сценарий ответов по маршрутам.""" + + def __init__(self, issue, fetch=None, polls=None, data=b"x" * 10, online=True): + self.issue, self.fetch, self.polls, self.data, self.online = issue, fetch, polls or [], data, online + self.calls = [] + + def get(self, path, **params): + self.calls.append(("GET", path)) + if path == f"/issues/{ATT_KEY}": + return self.issue + if path == "/agent": + return {"online": self.online} + if path.startswith("/attachments/"): + return self.polls.pop(0) if len(self.polls) > 1 else self.polls[0] + raise AssertionError(path) + + def post(self, path, body=None): + self.calls.append(("POST", path, body)) + if isinstance(self.fetch, Exception): + raise self.fetch + return self.fetch + + def get_bytes(self, path): + self.calls.append(("BYTES", path)) + return self.data + + +@pytest.fixture +def downloads(tmp_path, monkeypatch): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") + monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) + return tmp_path / "home" / "downloads" + + +def _use(monkeypatch, hub): + monkeypatch.setattr(mcp_server, "_client", lambda: hub) + monkeypatch.setattr(mcp_server, "_unbound_or_unknown", lambda required: None) + + +def _issue_with_att(): + return _detail(key=ATT_KEY, description="см. !скрин 1.png!", description_attachment_ids=["31337"], + comments=[{"comment_id": "101", "author": "golov", "created": "2026-10-20T10:00:00.000+0300", + "updated": None, "body": "!скрин 1.png!", "attachment_ids": ["31337"]}], + attachments=[_att("31338", "запись.mp4", 26214400, mime_type="video/mp4"), + _att("31337", "скрин 1.png", 239821, state="ready")]) + + +def test_issue_attachments_block(): + text = mcp_server.fmt_issue(_issue_with_att()) + assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):") + lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")} + assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"] + assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"] + assert "файлы: #31337 скрин 1.png" in text + assert "вложений нет" in mcp_server.fmt_issue(_detail(attachments=[])) + assert "вложений нет" in mcp_server.fmt_issue(_detail()) + + +def test_comment_attachments(): + d = _issue_with_att() + text = mcp_server.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"]) + assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)") + assert "вложения" not in mcp_server.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"]) + + +def test_safe_name_limits_255_bytes(): + """M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется.""" + for stem in ("я" * 200, "ы" * 127 + "a"): + name = mcp_server.safe_name(stem + ".xlsx", "7") + assert len(name.encode()) <= 255 and name.endswith(".xlsx") + name.encode("utf-8") + assert name.startswith("я") or name.startswith("ы") + assert len(mcp_server.safe_name("ф" * 300, "7").encode()) <= 255 + assert mcp_server.safe_name("короткое.png", "7") == "короткое.png" + + +def test_attachment_long_cyrillic_name_written(downloads, monkeypatch): + name = "я" * 200 + ".png" + att = _att("31337", name, 3, state="ready") + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc") + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) + path = Path(out.splitlines()[0]) + assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255 + + +def test_safe_name(): + assert mcp_server.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png" + assert mcp_server.safe_name(" ", "7") == "attachment-7" + assert mcp_server.safe_name("..", "7") == "attachment-7" + assert mcp_server.safe_name("a\x7f", "7") == "a_" + + +def _ready_hub(size=239821, data=b"PNG" * 3): + att = _att("31337", "скрин 1.png", size, state="ready") + return FakeHub(_detail(key=ATT_KEY, attachments=[att]), + fetch={"state": "ready", "command_id": 5}, polls=[att], data=data) + + +def test_attachment_by_id_ready(downloads, monkeypatch): + hub = _ready_hub() + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "#31337")) + path = downloads / "31337" / "скрин 1.png" + lines = out.splitlines() + assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650" + assert path.read_bytes() == b"PNG" * 3 + assert (downloads.stat().st_mode & 0o777) == 0o700 and ((downloads / "31337").stat().st_mode & 0o777) == 0o700 + assert ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls + + +def test_attachment_by_name_ambiguous(downloads, monkeypatch): + a20, a10 = _att("20", "скрин.png", 3), _att("10", "скрин.png", 3) # новые сверху, как отдаёт хаб + hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1}, + polls=[{**a20, "state": "ready"}], data=b"abc") + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "скрин.png")) + assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out + assert str(downloads / "20" / "скрин.png") in out + + +def test_attachment_not_found(downloads, monkeypatch): + hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)])) + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "нет.png")) + assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650" + assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls) + + +def test_attachment_wait_timeout(downloads, monkeypatch): + att = _att("1", "a.png", 5) + fetching = {**att, "state": "fetching", "command_id": 9} + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching]) + _use(monkeypatch, hub) + out = asyncio.run(mcp_server.attachment(ATT_KEY, "1", wait_seconds=1)) + assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится" + + +def test_attachment_failed(downloads, monkeypatch): + att = _att("1", "a.png", 5) + failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3} + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed]) + _use(monkeypatch, hub) + assert asyncio.run(mcp_server.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404" + + +def test_attachment_413(downloads, monkeypatch): + from jira_hub.client import HubError + att = _att("5", "запись.mp4", 26214400) + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) + _use(monkeypatch, hub) + assert asyncio.run(mcp_server.attachment(ATT_KEY, "#5")) == \ + "файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»" + + +def test_attachment_409_passes_hub_text(downloads, monkeypatch): + from jira_hub.client import HubError + hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]), + fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14")) + _use(monkeypatch, hub) + assert "агент v9 не умеет" in asyncio.run(mcp_server.attachment(ATT_KEY, "5")) + + +def test_attachment_existing_copy_same_size(downloads, monkeypatch): + hub = _ready_hub(data=b"PNG" * 3) + _use(monkeypatch, hub) + path = downloads / "31337" / "скрин 1.png" + path.parent.mkdir(parents=True) + path.write_bytes(b"OLD" * 3) + os.utime(path, (1_000_000, 1_000_000)) + os.utime(path.parent, (1_000_000, 1_000_000)) + asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) + assert path.read_bytes() == b"OLD" * 3 + assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет diff --git a/tests/test_mirror.py b/tests/test_mirror.py index aa093dd..fa74ae0 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -213,7 +213,7 @@ def test_comments_newest_first_and_shape(conn): got = mirror.get_issue(conn, "A-1")["comments"] assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300", - "updated": "2026-10-15T10:00:00.000+0300", "body": ""} + "updated": "2026-10-15T10:00:00.000+0300", "body": "", "attachment_ids": []} def test_issue_epic_summary_and_absent_link(conn): @@ -319,3 +319,87 @@ def test_apply_state_comment_created_at(conn): {"id": "2", "body": "b", "created": "мусор"}]}], []) rows = {r["comment_id"]: r["created_at"] for r in conn.execute("SELECT * FROM issue_comment")} assert rows == {"1": "2026-10-22T10:05:11+00:00", "2": None} + + +def attachment(aid, **over): + base = {"id": aid, "filename": f"файл{aid}.png", "size": 1000, "mime_type": "image/png", "author": "Петров Пётр", + "created": "2026-10-14T12:00:00.000+0300"} + base.update(over) + return base + + +def _atts(conn, key="A-1"): + return {r["attachment_id"]: dict(r) for r in conn.execute("SELECT * FROM issue_attachment WHERE key = ?", (key,))} + + +def test_attachments_replace(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1"), attachment("2")])], []) + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("2", filename="новое.pdf", size=5), attachment("3")])], []) + got = _atts(conn) + assert sorted(got) == ["2", "3"] + assert got["2"]["filename"] == "новое.pdf" and got["2"]["size"] == 5 + assert got["2"]["created_at"] == "2026-10-14T09:00:00+00:00" + + +def test_attachments_absent_kept(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], []) + mirror.apply_state(conn, [issue("A-1", summary="Без поля")], []) + assert sorted(_atts(conn)) == ["1"] + + +def test_attachment_file_untouched(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], []) + conn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('1', 'abc', 1000, '2026-10-22T10:00:00+00:00')") + mirror.apply_state(conn, [issue("A-1", attachments=[])], []) + assert _atts(conn) == {} + assert conn.execute("SELECT file FROM attachment_file WHERE attachment_id = '1'").fetchone()["file"] == "abc" + + +def test_attachment_moves_to_other_key(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], []) + mirror.apply_state(conn, [issue("A-2", attachments=[attachment("1")])], []) + assert _atts(conn, "A-1") == {} and sorted(_atts(conn, "A-2")) == ["1"] + + +@pytest.mark.parametrize("bad, field", [ + ("x", "attachments"), (["x"], "attachments[0]"), + ([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"), + ([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"), + ([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"), + ([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"), + ([{"id": "5", "filename": "a", "size": "1"}], "attachments[0].size"), + ([{"id": "5", "filename": "a", "size": True}], "attachments[0].size"), + ([{"id": "5", "filename": "a", "mime_type": 5}], "attachments[0].mime_type"), +]) +def test_check_attachments_errors(bad, field): + with pytest.raises(ValueError) as e: + mirror.check_attachments([issue("A-1"), issue("A-2", attachments=bad)]) + assert str(e.value).startswith(f"A-2: {field}") + + +def test_check_attachments_accepts_missing_and_valid(): + mirror.check_attachments([issue("A-1"), issue("A-2", attachments=[attachment("1"), attachment("2", size=None, mime_type=None)])]) + + +def test_get_issue_attachments(conn): + mirror.apply_state(conn, [issue( + "A-1", description="см. !старый.png!", + attachments=[attachment("31337", filename="скрин 1.png", created="2026-10-14T12:00:00.000+0300"), + attachment("31338", filename="ОСВ 60.xlsx", created="2026-10-15T12:00:00.000+0300")], + comments=[comment("101", body="см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!"), + comment("102", body="без ссылок")])], []) + got = mirror.get_issue(conn, "A-1") + assert [a["attachment_id"] for a in got["attachments"]] == ["31338", "31337"] + assert got["attachments"][0]["state"] == "none" and got["attachments"][0]["error"] is None + assert got["description_attachment_ids"] == [] + by_id = {c["comment_id"]: c for c in got["comments"]} + assert by_id["101"]["attachment_ids"] == ["31337", "31338"] and by_id["102"]["attachment_ids"] == [] + + +def test_get_issue_commands_skip_fetch(conn): + mirror.apply_state(conn, [issue("A-1")], []) + conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 't', 'a', ?)", (db.now(),)) + for seq, typ in enumerate(["fetch_attachment", "ensure_fields"]): + conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (1, ?, ?, ?)", + (seq, typ, '{"key": "A-1", "attachment_id": "1"}')) + assert [c["type"] for c in mirror.get_issue(conn, "A-1")["commands"]] == ["ensure_fields"] diff --git a/tests/test_queue.py b/tests/test_queue.py index 1fd3ebd..2d663dd 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -2,7 +2,7 @@ import hashlib import pytest -from jira_hub import db, files, mirror, projects, queue, settings +from jira_hub import attachments, db, files, mirror, projects, queue, settings from jira_hub.commands import CommandError from jira_hub.queue import ClaimedError @@ -1028,3 +1028,177 @@ def test_report_sets_history_at(conn, add_project, result, status, marked): c = _full_run(conn, result, status=status) run = conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone() assert (run["history_at"] is not None) == marked + + +# --- fetch_attachment (#83) --------------------------------------------------- + +import threading + +FSHA = "cd" * 32 + + +@pytest.fixture +def fconn(conn): + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "31337", "filename": "ОСВ 60.xlsx", "size": 3, "created": "2026-10-20T10:00:00.000+0300"}, + {"id": "31338", "filename": "big.bin", "size": 25_000_000, "created": "2026-10-20T10:00:00.000+0300"}, + {"id": "31339", "filename": "unk.bin", "created": "2026-10-20T10:00:00.000+0300"}]}, + {"key": "B-1", "summary": "т", "labels": []}], []) + return conn + + +def fetch(att="31337", key="A-1"): + return {"type": "fetch_attachment", "params": {"key": key, "attachment_id": att}} + + +def test_fetch_batch_no_tail_refresh(fconn): + b = queue.create_batch(fconn, "x", "claude", [fetch()]) + assert b["count"] == 1 + assert [c["type"] for c in queue.batch_view(fconn, b["id"])["commands"]] == ["fetch_attachment"] + assert b["estimate_seconds"] == 2 + + +def test_fetch_checks(fconn): + with pytest.raises(CommandError): + queue.create_batch(fconn, "x", "claude", [fetch("1a")]) + with pytest.raises(CommandError) as e: + queue.create_batch(fconn, "x", "claude", [fetch(key="B-1")]) + assert str(e.value) == "у B-1 нет вложения #31337 в зеркале — обновите задачу" + with pytest.raises(queue.TooLarge) as e: + queue.create_batch(fconn, "x", "claude", [fetch("31338")]) + assert str(e.value) == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira" + assert queue.create_batch(fconn, "x", "claude", [fetch("31339")])["count"] == 1 # size NULL — ставится + + +def test_fetch_size_limit_boundary(fconn): + """T3.4: ровно MAX_SIZE принимается, MAX_SIZE+1 — TooLarge.""" + mirror.apply_state(fconn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "41", "filename": "eq.bin", "size": files.MAX_SIZE, "created": "2026-10-20T10:00:00.000+0300"}, + {"id": "42", "filename": "over.bin", "size": files.MAX_SIZE + 1, "created": "2026-10-20T10:00:00.000+0300"}]}], []) + assert queue.request_fetch(fconn, None, "41", "claude")["state"] == "fetching" + with pytest.raises(queue.TooLarge): + queue.request_fetch(fconn, None, "42", "claude") + + +def test_fetch_report_done_non_dict_result_fails(fconn, tmp_path): + """T3.4: done с result не-словарём — failed, вложение не отмечено скачанным.""" + queue.create_batch(fconn, "x", "claude", [fetch()]) + c = queue.claim(fconn) + r = queue.report_result(fconn, c["id"], "done", result="ok", files_dir=tmp_path) + assert r["status"] == "failed" + assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 + + +def test_fetch_no_backup(fconn): + queue.create_batch(fconn, "x", "claude", [fetch()]) + calls = [] + cmd = queue.claim(fconn, backup=calls.append) + assert cmd["type"] == "fetch_attachment" and cmd["pause_seconds"] == 2 and calls == [] + + +def test_fetch_project_batch_no_checks(fconn, add_project): + add_project(fconn, "AWPCA") + b = queue.create_batch(fconn, "x", "claude", [fetch(key="A-1")], project="AWPCA") # A-1 не в проекте + assert b["count"] == 1 + + +def test_request_fetch_once(open_db, tmp_path): + path = tmp_path / "hub.sqlite" + c1 = open_db(path) + settings.ensure_defaults(c1) + mirror.apply_state(c1, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ + {"id": "31337", "filename": "a.png", "size": 3, "created": "2026-10-20T10:00:00.000+0300"}]}], []) + c2 = open_db(path) + res, errs = [], [] + barrier = threading.Barrier(2) + + def run(c): + try: + barrier.wait() + res.append(queue.request_fetch(c, tmp_path / "files", "31337", "x")) + except Exception as e: # noqa: BLE001 + errs.append(e) + + ts = [threading.Thread(target=run, args=(c,)) for c in (c1, c2)] + [t.start() for t in ts] + [t.join() for t in ts] + assert not errs + assert res[0]["command_id"] == res[1]["command_id"] and res[0]["batch_id"] == res[1]["batch_id"] + assert {r["state"] for r in res} <= {"fetching"} + assert c1.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 1 + + +def put_file(tmp_path, data=b"abc"): + root = tmp_path / "files" + sha, _ = files.put(root, data) + return root, sha + + +def test_request_fetch_ready(fconn, tmp_path): + root, sha = put_file(tmp_path) + fconn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('31337', ?, 3, ?)", (sha, db.now())) + r = queue.request_fetch(fconn, root, "31337", "x") + assert r["state"] == "ready" and r["filename"] == "ОСВ 60.xlsx" + assert fconn.execute("SELECT COUNT(*) AS n FROM command").fetchone()["n"] == 0 + with pytest.raises(KeyError): + queue.request_fetch(fconn, root, "999", "x") + + +def test_request_fetch_agent_too_old(fconn, tmp_path): + fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE id = 1") + with pytest.raises(queue.AgentTooOld) as e: + queue.request_fetch(fconn, tmp_path / "files", "31337", "x") + assert str(e.value) == "агент v13 не умеет скачивать вложения — нужен v14" + fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE id = 1") + assert queue.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" + fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE id = 1") + assert queue.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" + + +def test_request_fetch_after_failed(fconn, tmp_path): + root = tmp_path / "files" + first = queue.request_fetch(fconn, root, "31337", "x") + cmd = queue.claim(fconn) + queue.report_result(fconn, cmd["id"], "failed", error="нет сети", files_dir=root) + again = queue.request_fetch(fconn, root, "31337", "x") + assert again["state"] == "fetching" and again["batch_id"] != first["batch_id"] + + +def report_done(conn, root, result): + queue.create_batch(conn, "x", "claude", [fetch()]) + cmd = queue.claim(conn) + return queue.report_result(conn, cmd["id"], "done", result=result, files_dir=root) + + +def test_report_fetch_done_records_file(fconn, tmp_path): + root, sha = put_file(tmp_path) + cmd = report_done(fconn, root, {"file": sha, "size": 3}) + assert cmd["status"] == "done" + row = fconn.execute("SELECT * FROM attachment_file WHERE attachment_id = '31337'").fetchone() + assert row["file"] == sha and row["size"] == 3 + assert attachments.state(fconn, root, "31337")["state"] == "ready" + + +def test_report_fetch_done_missing_file(fconn, tmp_path): + root = tmp_path / "files" + cmd = report_done(fconn, root, {"file": FSHA, "size": 3}) + assert cmd["status"] == "failed" and cmd["error"] == f"хаб: файл {FSHA} не найден в хранилище" + assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 + # после уборки файла (done → файла нет) и повторной постановки — новый пакет + assert queue.request_fetch(fconn, root, "31337", "x")["state"] == "fetching" + + +def test_report_fetch_wrong_size(fconn, tmp_path): + root, sha = put_file(tmp_path) + cmd = report_done(fconn, root, {"file": sha, "size": 99}) + assert cmd["status"] == "failed" and cmd["error"] == f"хаб: файл {sha} не найден в хранилище" + assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 + + +def test_request_fetch_after_done_file_swept(fconn, tmp_path): + root, sha = put_file(tmp_path) + report_done(fconn, root, {"file": sha, "size": 3}) + files.path(root, sha).unlink() # уборка файла + r = queue.request_fetch(fconn, root, "31337", "x") + assert r["state"] == "fetching" + assert fconn.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 2 diff --git a/tests/test_settings.py b/tests/test_settings.py index 6a47edb..eb6d757 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -119,3 +119,9 @@ def test_closed_statuses_must_be_list(): with pytest.raises(ValueError): settings.validate("closed_statuses", "Готово") settings.validate("closed_statuses", ["Готово"]) + + +def test_pacing_fallback_for_new_type(conn): + settings.set_(conn, "pacing", {"ensure_fields": 5}) + assert settings.pacing(conn)["fetch_attachment"] == 2 + settings.validate("pacing", {"fetch_attachment": 3}) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index d24f776..74a7e33 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -67,4 +67,16 @@ describe("api", () => { expect((fetch.mock.calls[0][1]!.headers as Record).Authorization).toBe("Bearer candidate"); expect(fetch.mock.calls[0][0]).toBe("/settings"); }); + + it("blob: тот же токен, тело как есть без разбора JSON; ошибки как у call", async () => { + const fetch = mockFetch((_m, url) => url.pathname === "/ok" ? new Response("не JSON", { status: 200 }) : json(404, { error: "not_fetched", detail: "нет файла" })); + const blob = await api.blob("/ok"); + expect(await blob.text()).toBe("не JSON"); + expect((fetch.mock.calls[0][1]!.headers as Record).Authorization).toBe("Bearer tok"); + const e = await api.blob("/no").catch((err: unknown) => err) as ApiError; + expect(e).toBeInstanceOf(ApiError); + expect(e.detail).toBe("нет файла"); + mockFetch(() => json(401, {})); + await expect(api.blob("/x")).rejects.toBeInstanceOf(AuthError); + }); }); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 41e74e1..2910491 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -40,11 +40,12 @@ function withParams(path: string, params?: Params): string { return s ? `${path}?${s}` : path; } -async function call(method: string, path: string, body?: unknown, token = getToken()): Promise { +// общая часть запросов: токен, сеть, 401/5xx/4xx; успешный Response возвращается нетронутым +async function send(method: string, path: string, accept: string, body?: unknown, token = getToken()): Promise { if (!token) throw new AuthError("нет токена", token); // токен с недопустимыми символами (например, оставленный прежней доской) — сразу на экран входа, а не «хаб не отвечает» if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене", token); - const headers: Record = { Authorization: "Bearer " + token, Accept: "application/json" }; + const headers: Record = { Authorization: "Bearer " + token, Accept: accept }; if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8"; let resp: Response; try { @@ -53,13 +54,18 @@ async function call(method: string, path: string, body?: unknown, token = get throw new HubDownError("хаб не отвечает"); } if (resp.status === 401) throw new AuthError("токен не подошёл", token); + if (resp.ok) return resp; let data: unknown = null; try { data = await resp.json(); } catch { /* тело не JSON */ } if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`); - if (!resp.ok) { - const d = (data ?? {}) as { error?: string; detail?: unknown }; - throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail)); - } + const d = (data ?? {}) as { error?: string; detail?: unknown }; + throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail)); +} + +async function call(method: string, path: string, body?: unknown, token = getToken()): Promise { + const resp = await send(method, path, "application/json", body, token); + let data: unknown = null; + try { data = await resp.json(); } catch { /* тело не JSON */ } return data as T; } @@ -67,6 +73,8 @@ export const api = { get: (path: string, params?: Params) => call("GET", withParams(path, params)), post: (path: string, body: unknown = {}) => call("POST", path, body), put: (path: string, body: unknown) => call("PUT", path, body), + // тело ответа как есть (содержимое вложения): те же токен и ошибки, JSON не разбирается + blob: async (path: string) => (await send("GET", path, "*/*")).blob(), }; // проверка токена до сохранения: экран входа diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 6920881..23cf8ac 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -40,7 +40,18 @@ export interface IssueCommand { export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null } export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null } // комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira -export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string } +export interface IssueComment { + comment_id: string; author: string | null; created: string | null; updated: string | null; body: string; + attachment_ids?: string[]; // id вложений, на которые ссылается текст (хаб с вложениями, схема v6) +} +// вложение задачи (хаб со схемой v6): state — ready (файл у хаба) / fetching / failed / none; error — причина при failed +export type AttachmentStateName = "ready" | "fetching" | "failed" | "none"; +export interface IssueAttachment { + attachment_id: string; filename: string; size: number | null; mime_type: string | null; author: string | null; + created: string | null; state: AttachmentStateName; error: string | null; +} +// ответ GET /attachments/{id} и POST /attachments/{id}/fetch +export interface AttachmentState extends IssueAttachment { key: string; command_id: number | null; batch_id: number | null } // событие журнала изменений (GET /changes, GET /issues/{key}.changes; в истории задачи нет key/summary/status) export type ChangeKind = "status" | "description" | "dates" | "comment" | "other"; export interface ChangeEvent { @@ -56,6 +67,8 @@ export interface IssueDetail extends IssueRow { note: Note | null; commands: IssueCommand[]; comments: IssueComment[]; + attachments?: IssueAttachment[]; // хаб до вложений поля не шлёт + description_attachment_ids?: string[]; changes?: ChangeEvent[]; // хаб до журнала изменений (#70) поля не шлёт } diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx index 8455af3..f3cb9af 100644 --- a/web/src/issues/IssueCard.test.tsx +++ b/web/src/issues/IssueCard.test.tsx @@ -5,6 +5,7 @@ import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "reac import { beforeEach, describe, expect, it, vi } from "vitest"; import { qk } from "../api/queries.ts"; import { makeQueryClient } from "../api/queryClient.ts"; +import type { IssueAttachment, IssueDetail } from "../api/types.ts"; import { setToken } from "../auth/token.ts"; import baseSettings from "../board/__fixtures__/settings.json"; import { json, mockFetch } from "../test/fetchMock.ts"; @@ -482,3 +483,94 @@ describe("история в карточке", () => { expect(screen.queryByTestId("history")).toBeNull(); }); }); + +describe("вложения в карточке", () => { + beforeEach(() => setToken("tok")); + const att = (id: string, filename: string, over: Partial = {}): IssueAttachment => ({ + attachment_id: id, filename, size: 240000, mime_type: "image/png", author: "Иванов Иван", created: "2026-10-15T12:00:00.000+0300", + state: "none", error: null, ...over, + }); + const comment = { comment_id: "101", author: "Петров", created: null, updated: null, body: "см. !скрин 1.png|thumbnail!", attachment_ids: ["31337"] }; + const base = { description: "текст", comments: [comment], attachments: [att("31337", "скрин 1.png"), att("31338", "ОСВ 60.xlsx", { mime_type: null, size: 1024, created: "2026-10-16T12:00:00.000+0300" })] }; + function hub(over: Partial, extra?: (m: string, url: URL) => Response | undefined) { + return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over })))); + } + + it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => { + hub({}); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + expect(sec.getByRole("heading", { level: 3, name: "Вложения (2)" })).toBeInTheDocument(); + const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); + expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1); + const rows = sec.getAllByRole("listitem"); + expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx"); + expect(rows[0]).toHaveTextContent("1,0 КБ"); + expect(rows[1]).toHaveTextContent("скрин 1.png"); + expect(rows[1]).toHaveTextContent("234,4 КБ"); + expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10"); + expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument(); + }); + + it("без поля attachments (старый хаб) раздела нет", async () => { + hub({ attachments: undefined, comments: [] }); + open("/issues/AURORA-1650"); + await screen.findByText("Описание"); + expect(screen.queryByTestId("attachments")).toBeNull(); + }); + + it("состояние ready — отметка «у хаба»", async () => { + hub({ attachments: [att("31337", "скрин 1.png", { state: "ready" })], comments: [] }); + open("/issues/AURORA-1650"); + expect(within(await screen.findByTestId("attachments")).getByText("у хаба")).toBeInTheDocument(); + }); + + it("25 МБ — ссылка «Открыть в Jira» с подсказкой вместо кнопки", async () => { + hub({ attachments: [att("77", "запись.mp4", { size: 25 * 1024 ** 2, mime_type: "video/mp4" })], comments: [] }); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + const a = sec.getByRole("link", { name: "Открыть в Jira" }); + expect(a).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/secure/attachment/77/%D0%B7%D0%B0%D0%BF%D0%B8%D1%81%D1%8C.mp4"); + expect(a).toHaveAttribute("target", "_blank"); + expect(a).toHaveAttribute("title", "больше 10 МБ — только в Jira"); + expect(sec.queryByRole("button", { name: "Скачать" })).toBeNull(); + }); + + it("размер неизвестен — подпись и обычная кнопка", async () => { + hub({ attachments: [att("5", "x.bin", { size: null })], comments: [] }); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + expect(sec.getByText(/размер неизвестен/)).toBeInTheDocument(); + expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument(); + }); + + it("плашка под комментарием и кнопка в тексте", async () => { + hub({}); + open("/issues/AURORA-1650"); + const c = within(await screen.findByTestId("comments")); + expect(c.getByRole("button", { name: "скрин 1.png · 234,4 КБ" })).toBeInTheDocument(); + expect(c.getByRole("button", { name: "скрин 1.png" })).toBeInTheDocument(); + expect(c.queryByText(/thumbnail/)).toBeNull(); + }); + + it("«Загружается…» на всех кнопках одного вложения; чужие не затронуты", async () => { + hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" || url.pathname === "/attachments/31337" + ? json(200, { attachment_id: "31337", state: "fetching", error: null, command_id: 1, batch_id: 1 }) : undefined); + open("/issues/AURORA-1650"); + await userEvent.click(within(await screen.findByTestId("comments")).getByRole("button", { name: "скрин 1.png · 234,4 КБ" })); + expect(await screen.findAllByText("Загружается…")).toHaveLength(3); + for (const b of screen.getAllByText("Загружается…")) expect(b.closest("button")).toBeDisabled(); + expect(within(screen.getByTestId("attachments")).getByRole("button", { name: "Скачать" })).toBeEnabled(); // ОСВ 60.xlsx + }); + + it("ошибка хаба — текст и «Повторить» под вложением", async () => { + const f = hub({}, (_m, url) => url.pathname === "/attachments/31337/fetch" + ? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined); + open("/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]); + expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument(); + await userEvent.click(sec.getByRole("button", { name: "Повторить" })); + expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2); + }); +}); diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx index 1827131..673c81a 100644 --- a/web/src/issues/IssueCard.tsx +++ b/web/src/issues/IssueCard.tsx @@ -2,7 +2,7 @@ import { type RefObject, useLayoutEffect, useRef, useState } from "react"; import { ApiError } from "../api/client.ts"; import { useClosedStatuses } from "../api/closed.ts"; import { useIssue, useSettings } from "../api/queries.ts"; -import type { ChangeEvent, IssueComment, IssueDetail } from "../api/types.ts"; +import type { ChangeEvent, IssueAttachment, IssueComment, IssueDetail } from "../api/types.ts"; import { JIRA_URL } from "../board/constants.ts"; import { fmtDate, fmtStamp } from "../board/model/util.ts"; import { EventList } from "../feed/EventList.tsx"; @@ -12,6 +12,10 @@ import { IssueLink } from "./IssueLink.tsx"; import { Badge, Button, IconButton, IconClose, IconExternal, Notice, Panel } from "../ui/index.ts"; import { Notes } from "./Notes.tsx"; import { dueOf } from "./options.ts"; +import { AttachmentChips } from "./attachments/AttachmentChips.tsx"; +import { AttachmentList, type Download } from "./attachments/AttachmentList.tsx"; +import { isTooBig, jiraAttachmentUrl } from "./attachments/format.ts"; +import { useAttachmentDownload } from "./attachments/useAttachmentDownload.ts"; import { WikiText } from "./wiki/WikiText.tsx"; import "./issues.css"; @@ -21,6 +25,7 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos const issue = useIssue(key); const settings = useSettings(); const closed = useClosedStatuses(); + const dl = useAttachmentDownload(key); // одно состояние на вложение для всех кнопок карточки const close = } onClick={onClose} />; if (issue.isPending) { return
{key}{close}

загрузка {key}…

; @@ -49,6 +54,8 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos i.sprint && {i.sprint}, i.epic_key && эпик: {i.epic_summary ?? i.epic_key}, ].filter(Boolean); + const atts = i.attachments; + const wiki = wikiAttachments(atts, dl); return (
@@ -68,8 +75,9 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
} /> - {i.description && } - + {i.description && } + {atts && atts.length > 0 && } + {i.changes && }
@@ -82,6 +90,22 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos ); } +// ссылки на вложения в тексте: список для разбора, щелчок и вид кнопки (занята / ведёт в Jira) +interface WikiAtt { attachments?: { id: string; filename: string; created: string | null }[]; onAttachment?: (id: string) => void; attachmentView?: (id: string) => { busy: boolean; href?: string } } +function wikiAttachments(atts: IssueAttachment[] | undefined, dl: Download): WikiAtt { + if (!atts?.length) return {}; + const byId = new Map(atts.map((a) => [a.attachment_id, a])); + return { + attachments: atts.map((a) => ({ id: a.attachment_id, filename: a.filename, created: a.created })), + onAttachment: (id) => { const a = byId.get(id); if (a) dl.start(a); }, + attachmentView: (id) => { + const a = byId.get(id); + if (a && isTooBig(a.size)) return { busy: false, href: jiraAttachmentUrl(a.attachment_id, a.filename) }; + return { busy: dl.status(id).busy }; + }, + }; +} + function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) { const groups = groupLinks(issue, linkTypes); return ( @@ -113,7 +137,7 @@ function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => v return ; } -function Description({ text, projectKey }: { text: string; projectKey?: string }) { +function Description({ text, projectKey, wiki }: { text: string; projectKey?: string; wiki: WikiAtt }) { const ref = useRef(null); const [open, setOpen] = useState(false); const [source, setSource] = useState(false); @@ -141,13 +165,13 @@ function Description({ text, projectKey }: { text: string; projectKey?: string }
{source ?
} className={cls}>{text}
- :
} className={cls}>
} + :
} className={cls}>
} {(long || open) && } ); } -function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) { +function Comment({ c, projectKey, wiki, atts, dl }: { c: IssueComment; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) { const [source, setSource] = useState(false); return (
  • @@ -157,12 +181,13 @@ function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
  • {source ?
    {c.body}
    - :
    } + :
    } + atts.filter((a) => a.attachment_id === id))} dl={dl} /> ); } -function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: string }) { +function Comments({ items, projectKey, wiki, atts, dl }: { items: IssueComment[]; projectKey?: string; wiki: WikiAtt; atts: IssueAttachment[]; dl: Download }) { const [hidden, setHidden] = useCommentsHidden(); return (
    @@ -173,7 +198,7 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s {!items.length &&

    комментариев нет

    } {items.length > 0 && !hidden && (
      - {items.map((c) => )} + {items.map((c) => )}
    )}
    diff --git a/web/src/issues/attachments/AttachmentChips.tsx b/web/src/issues/attachments/AttachmentChips.tsx new file mode 100644 index 0000000..6b74128 --- /dev/null +++ b/web/src/issues/attachments/AttachmentChips.tsx @@ -0,0 +1,33 @@ +import type { IssueAttachment } from "../../api/types.ts"; +import { IconExternal, IconPaperclip } from "../../ui/index.ts"; +import type { Download } from "./AttachmentList.tsx"; +import { fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts"; + +// плашки под комментарием: «имя · размер», щелчок — скачивание (большой файл — ссылка в Jira) +export function AttachmentChips({ items, dl }: { items: IssueAttachment[]; dl: Download }) { + if (!items.length) return null; + return ( +
    + ); +} diff --git a/web/src/issues/attachments/AttachmentList.tsx b/web/src/issues/attachments/AttachmentList.tsx new file mode 100644 index 0000000..bd74627 --- /dev/null +++ b/web/src/issues/attachments/AttachmentList.tsx @@ -0,0 +1,56 @@ +import type { IssueAttachment } from "../../api/types.ts"; +import { fmtStamp } from "../../board/model/util.ts"; +import { Badge, Button, IconDownload, IconExternal, IconFile, IconImage, IconSheet } from "../../ui/index.ts"; +import { fileKind, fmtSize, isTooBig, jiraAttachmentUrl, TOO_BIG_HINT } from "./format.ts"; +import type { useAttachmentDownload } from "./useAttachmentDownload.ts"; +import "./attachments.css"; + +export type Download = ReturnType; + +const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile }; +const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" }; + +// новые сверху: по времени Jira, затем по числовому id +export function newestFirst(items: IssueAttachment[]): IssueAttachment[] { + const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0); + return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0)); +} + +// раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания +export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) { + return ( +
    +

    Вложения ({items.length})

    +
      + {newestFirst(items).map((a) => )} +
    +
    + ); +} + +function Row({ a, dl }: { a: IssueAttachment; dl: Download }) { + const kind = fileKind(a.mime_type, a.filename); + const Icon = KIND_ICON[kind]; + const { busy, error: local } = dl.status(a.attachment_id); + // ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки + const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null); + const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · "); + return ( +
  • + +
    + {a.filename} + + {fmtSize(a.size)}{who && ` · ${who}`} + {a.state === "ready" && <>{" "}у хаба} + + {error &&

    {error}

    } +
    + {isTooBig(a.size) + ? Открыть в Jira + : } +
  • + ); +} diff --git a/web/src/issues/attachments/attachments.css b/web/src/issues/attachments/attachments.css new file mode 100644 index 0000000..e739288 --- /dev/null +++ b/web/src/issues/attachments/attachments.css @@ -0,0 +1,24 @@ +/* вложения в карточке (web-app «Скачивание вложений в карточке»): цвета только через токены */ +.issue .att-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +.issue .att-row{display:flex;align-items:flex-start;gap:var(--space-3);padding:var(--space-3) 0;border-top:1px solid var(--color-border-default)} +.issue .att-row:first-child{border-top:0;padding-top:0} +.issue .att-icon{flex:none;display:flex;align-items:center;justify-content:center;width:var(--control-h);height:var(--control-h);color:var(--color-text-secondary)} +.issue .att-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:var(--space-1)} +.issue .att-name{font-weight:600;overflow-wrap:anywhere} +.issue .att-meta{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--color-text-secondary)} +.issue .att-error{margin:0;color:var(--color-status-error)} +.issue .att-row>.ui-btn{flex:none} +/* плашки под комментарием и ссылка-кнопка в тексте */ +.issue .att-chips{list-style:none;margin:var(--space-2) 0 0;padding:0;display:flex;flex-wrap:wrap;gap:var(--space-2)} +.issue .att-chip,.issue .att-ref{display:inline-flex;align-items:center;gap:var(--space-1);font:inherit;color:var(--color-action-primary);cursor:pointer;text-decoration:none;overflow-wrap:anywhere} +.issue .att-chip{min-height:var(--control-h);padding:0 var(--space-3);background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-round)} +.issue .att-chip:hover:not(:disabled){background:var(--color-bg-hover)} +.issue .att-ref{padding:0;background:none;border:0;text-align:left} +.issue .att-ref:hover:not(:disabled){text-decoration:underline} +.issue .att-chip:disabled,.issue .att-ref:disabled{cursor:default;color:var(--color-text-secondary)} +.issue .att-chip:focus-visible,.issue .att-ref:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px} +.issue .att-busy{color:var(--color-text-secondary)} +@media (max-width:700px){ + .issue .att-row{flex-wrap:wrap} + .issue .att-row>.ui-btn{margin-left:calc(var(--control-h) + var(--space-3))} +} diff --git a/web/src/issues/attachments/format.test.ts b/web/src/issues/attachments/format.test.ts new file mode 100644 index 0000000..a930153 --- /dev/null +++ b/web/src/issues/attachments/format.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { JIRA_URL } from "../../board/constants.ts"; +import { fileKind, fmtSize, isTooBig, jiraAttachmentUrl } from "./format.ts"; + +describe("fmtSize", () => { + it("как в хабе: Б, КБ, МБ с запятой", () => { + expect(fmtSize(500)).toBe("500 Б"); + expect(fmtSize(1023)).toBe("1023 Б"); + expect(fmtSize(1024)).toBe("1,0 КБ"); + expect(fmtSize(240000)).toBe("234,4 КБ"); + expect(fmtSize(1024 ** 2)).toBe("1,0 МБ"); + expect(fmtSize(25 * 1024 ** 2)).toBe("25,0 МБ"); + expect(fmtSize(1280)).toBe("1,3 КБ"); + expect(fmtSize(1048575)).toBe("1,0 МБ"); + expect(fmtSize(1310720)).toBe("1,3 МБ"); + expect(fmtSize(25_000_000)).toBe("23,8 МБ"); + expect(fmtSize(null)).toBe("размер неизвестен"); + }); +}); + +describe("jiraAttachmentUrl", () => { + it("имя в URL-кодировке", () => { + expect(jiraAttachmentUrl("31337", "скрин 1.png")).toBe(`${JIRA_URL}/secure/attachment/31337/%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`); + }); +}); + +describe("isTooBig", () => { + it("больше 10 МБ — только в Jira; ровно 10 МБ и неизвестный размер — нет", () => { + expect(isTooBig(10 * 1024 * 1024)).toBe(false); + expect(isTooBig(10 * 1024 * 1024 + 1)).toBe(true); + expect(isTooBig(null)).toBe(false); + }); +}); + +describe("fileKind", () => { + it("тип по mime, иначе по расширению", () => { + expect(fileKind("image/png", "a.bin")).toBe("image"); + expect(fileKind(null, "Скрин.PNG")).toBe("image"); + expect(fileKind("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "x")).toBe("sheet"); + expect(fileKind(null, "ОСВ 60.xlsx")).toBe("sheet"); + expect(fileKind("text/csv", "d.txt")).toBe("sheet"); + expect(fileKind("application/pdf", "x")).toBe("pdf"); + expect(fileKind(null, "отчёт.pdf")).toBe("pdf"); + expect(fileKind("video/mp4", "запись.mp4")).toBe("other"); + expect(fileKind(null, "без расширения")).toBe("other"); + }); +}); diff --git a/web/src/issues/attachments/format.ts b/web/src/issues/attachments/format.ts new file mode 100644 index 0000000..be71ef5 --- /dev/null +++ b/web/src/issues/attachments/format.ts @@ -0,0 +1,34 @@ +import { JIRA_URL } from "../../board/constants.ts"; + +// предел скачивания через хаб — files.MAX_SIZE (10 485 760 байт); больше — только в Jira +export const MAX_SIZE = 10 * 1024 * 1024; +export const TOO_BIG_HINT = "больше 10 МБ — только в Jira"; + +// формат размера как в хабе (attachments.fmt_size): «234,4 КБ», «23,8 МБ»; null — «размер неизвестен» +export function fmtSize(size: number | null): string { + if (size === null) return "размер неизвестен"; + if (size < 1024) return `${size} Б`; + // десятые — в целых, округление half up (как в хабе); единица — по округлённому значению: 1048575 → «1,0 МБ» + const kb = Math.floor((size * 10 + 512) / 1024); + if (kb < 10240) return `${Math.floor(kb / 10)},${kb % 10} КБ`; + const mb = Math.floor((size * 10 + 524288) / 1048576); + return `${Math.floor(mb / 10)},${mb % 10} МБ`; +} + +export const isTooBig = (size: number | null): boolean => size !== null && size > MAX_SIZE; + +export function jiraAttachmentUrl(id: string, filename: string): string { + return `${JIRA_URL}/secure/attachment/${encodeURIComponent(id)}/${encodeURIComponent(filename)}`; +} + +export type FileKind = "image" | "sheet" | "pdf" | "other"; + +// значок по типу файла: сначала mime, затем расширение +export function fileKind(mime: string | null, filename: string): FileKind { + const m = (mime ?? "").toLowerCase(); + const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : ""; + if (m.startsWith("image/") || ["png", "jpg", "jpeg", "gif", "bmp", "webp", "svg"].includes(ext)) return "image"; + if (m.includes("spreadsheet") || m.includes("ms-excel") || m === "text/csv" || ["xls", "xlsx", "xlsm", "ods", "csv"].includes(ext)) return "sheet"; + if (m === "application/pdf" || ext === "pdf") return "pdf"; + return "other"; +} diff --git a/web/src/issues/attachments/useAttachmentDownload.test.tsx b/web/src/issues/attachments/useAttachmentDownload.test.tsx new file mode 100644 index 0000000..68e9ee0 --- /dev/null +++ b/web/src/issues/attachments/useAttachmentDownload.test.tsx @@ -0,0 +1,111 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "../../api/queryClient.ts"; +import type { IssueAttachment } from "../../api/types.ts"; +import { setToken } from "../../auth/token.ts"; +import { json, mockFetch } from "../../test/fetchMock.ts"; +import { useAttachmentDownload } from "./useAttachmentDownload.ts"; + +const att: IssueAttachment = { + attachment_id: "31337", filename: "скрин 1.png", size: 240000, mime_type: "image/png", author: "Иванов", created: null, state: "none", error: null, +}; +const st = (state: string, extra: object = {}) => ({ attachment_id: "31337", state, error: null, command_id: 7, batch_id: 3, ...extra }); +const wrapper = ({ children }: { children: ReactNode }) => {children}; +const flush = () => act(async () => { await vi.advanceTimersByTimeAsync(0); }); + +describe("useAttachmentDownload", () => { + let clicked: { download: string; href: string }[]; + beforeEach(() => { + setToken("tok"); + vi.useFakeTimers(); + clicked = []; + URL.createObjectURL = vi.fn(() => "blob:fake"); + URL.revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { clicked.push({ download: this.download, href: this.href }); }); + }); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + + it("fetching → опрос раз в 2 с → ready → файл скачан один раз под своим именем", async () => { + let polls = 0; + const f = mockFetch((m, url) => { + if (m === "POST") return json(200, st("fetching")); + if (url.pathname.endsWith("/content")) return new Response("PNG", { status: 200 }); + polls++; + return json(200, st(polls < 2 ? "fetching" : "ready")); + }); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + expect(result.current.status("31337")).toEqual({ busy: true, error: null }); + expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(polls).toBe(1); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(polls).toBe(2); + expect(clicked).toEqual([{ download: "скрин 1.png", href: "blob:fake" }]); + expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake"); + expect(result.current.status("31337")).toEqual({ busy: false, error: null }); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(polls).toBe(2); + expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1); + }); + + it("ready сразу в ответе на POST — без опроса", async () => { + const f = mockFetch((m, url) => m === "POST" ? json(200, st("ready")) : url.pathname.endsWith("/content") ? new Response("x") : json(500, {})); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + expect(clicked).toHaveLength(1); + expect(f.mock.calls.map(([u]) => String(u))).toEqual(["/attachments/31337/fetch", "/attachments/31337/content"]); + }); + + it("размонтирование прекращает опрос", async () => { + const f = mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, st("fetching"))); + const { result, unmount } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + const n = f.mock.calls.length; + unmount(); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(f.mock.calls.length).toBe(n); + }); + + it("смена задачи прекращает опрос", async () => { + const f = mockFetch(() => json(200, st("fetching"))); + const { result, rerender } = renderHook(({ k }) => useAttachmentDownload(k), { wrapper, initialProps: { k: "AURORA-1650" } }); + act(() => result.current.start(att)); + await flush(); + rerender({ k: "AURORA-1651" }); + const n = f.mock.calls.length; + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(f.mock.calls.length).toBe(n); + expect(result.current.status("31337")).toEqual({ busy: false, error: null }); + }); + + it("409 — ошибка с текстом хаба", async () => { + mockFetch(() => json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" })); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" }); + expect(clicked).toHaveLength(0); + }); + + it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => { + let n = 0; + mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching"))); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" }); + act(() => result.current.start(att)); + await flush(); + expect(result.current.status("31337")).toEqual({ busy: true, error: null }); + }); +}); diff --git a/web/src/issues/attachments/useAttachmentDownload.ts b/web/src/issues/attachments/useAttachmentDownload.ts new file mode 100644 index 0000000..0831d63 --- /dev/null +++ b/web/src/issues/attachments/useAttachmentDownload.ts @@ -0,0 +1,89 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError } from "../../api/client.ts"; +import { api } from "../../api/client.ts"; +import { qk } from "../../api/queries.ts"; +import type { AttachmentState, IssueAttachment } from "../../api/types.ts"; + +export const POLL_MS = 2000; +export interface DownloadStatus { busy: boolean; error: string | null } +const IDLE: DownloadStatus = { busy: false, error: null }; + +// браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется) +function save(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); // Firefox не кликает по ссылке вне документа + a.click(); + a.remove(); + // ссылку отзываем не сразу: браузер может начать чтение Blob уже после click(); таймер не в `timers` — + // размонтирование карточки не должно оставлять URL неотозванным + setTimeout(() => URL.revokeObjectURL(url), 60_000); +} + +const errorText = (e: unknown): string => e instanceof ApiError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e); + +// скачивание вложений карточки: одно состояние на вложение, общее для всех её кнопок (раздел, плашки, ссылки в тексте); +// опрос прекращается при размонтировании и смене задачи, команда при этом остаётся в очереди хаба +export function useAttachmentDownload(issueKey: string) { + const qc = useQueryClient(); + const [map, setMap] = useState>({}); + const mapRef = useRef(map); + const gen = useRef(0); + const timers = useRef(new Set>()); + + const put = useCallback((id: string, s: DownloadStatus) => { + mapRef.current = { ...mapRef.current, [id]: s }; + setMap(mapRef.current); + }, []); + + useEffect(() => { + const pending = timers.current; + const counter = gen; + return () => { + counter.current++; + for (const t of pending) clearTimeout(t); + pending.clear(); + mapRef.current = {}; + setMap({}); + }; + }, [issueKey]); + + const start = useCallback((a: IssueAttachment) => { + const id = a.attachment_id; + if (mapRef.current[id]?.busy) return; + const my = gen.current; + const alive = () => gen.current === my; + const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); }; + + // разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка + const handle = async (st: AttachmentState): Promise => { + if (!alive()) return; + if (st.state === "ready") { + const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`); + if (!alive()) return; + save(blob, a.filename); + put(id, IDLE); + void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба» + } else if (st.state === "fetching") { + const t = setTimeout(() => { + timers.current.delete(t); + if (!alive()) return; + api.get(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e))); + }, POLL_MS); + timers.current.add(t); + } else { + fail(st.error ?? "не удалось скачать вложение"); + } + }; + + put(id, { busy: true, error: null }); + api.post(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" }) + .then(handle).catch((e) => fail(errorText(e))); + }, [issueKey, put, qc]); + + const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]); + return { start, status }; +} diff --git a/web/src/issues/wiki/WikiText.tsx b/web/src/issues/wiki/WikiText.tsx index c70f367..35cab0f 100644 --- a/web/src/issues/wiki/WikiText.tsx +++ b/web/src/issues/wiki/WikiText.tsx @@ -1,14 +1,23 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { IconPaperclip } from "../../ui/index.ts"; import { IssueLink } from "../IssueLink.tsx"; -import type { Inline } from "./inline.ts"; +import { TOO_BIG_HINT } from "../attachments/format.ts"; +import type { Inline, WikiAttachment } from "./inline.ts"; import { type Block, type ListBlock, parseWiki } from "./parse.ts"; import "./wiki.css"; -interface Props { text: string; projectKey?: string } +// вид ссылки на вложение в тексте: busy — идёт скачивание; href — вложение больше предела, ссылка ведёт в Jira +export interface AttachmentView { busy: boolean; href?: string } +interface Props { + text: string; projectKey?: string; + attachments?: WikiAttachment[]; // вложения задачи: по ним распознаются `!имя!`, `[^имя]` + onAttachment?: (id: string) => void; + attachmentView?: (id: string) => AttachmentView; +} // вики-разметка Jira только React-элементами, HTML строкой не вставляется (design wiki-render #2, #5); при ошибке — исходник (#6) -export function WikiText({ text, projectKey }: Props) { - return ; +export function WikiText(props: Props) { + return ; } class Fallback extends Component<{ text: string; children: ReactNode }, { failed: boolean }> { @@ -20,54 +29,67 @@ class Fallback extends Component<{ text: string; children: ReactNode }, { failed } } -function Rendered({ text, projectKey }: Props) { - return
    {renderBlocks(parseWiki(text, { projectKey }))}
    ; +// то, что нужно рендеру строки, кроме текста: ссылки на вложения +interface Ctx { onAttachment?: (id: string) => void; attachmentView?: (id: string) => AttachmentView } + +function Rendered({ text, projectKey, attachments, onAttachment, attachmentView }: Props) { + const ctx: Ctx = { onAttachment, attachmentView }; + return
    {renderBlocks(parseWiki(text, { projectKey, attachments }), ctx)}
    ; } -function renderBlocks(blocks: Block[]): ReactNode[] { +function renderBlocks(blocks: Block[], ctx: Ctx): ReactNode[] { return blocks.map((b, n) => { switch (b.t) { - case "heading": { const H = `h${b.level}` as const; return {renderInlines(b.children)}; } - case "p": return

    {renderInlines(b.children)}

    ; - case "list": return renderList(b, n); + case "heading": { const H = `h${b.level}` as const; return {renderInlines(b.children, ctx)}; } + case "p": return

    {renderInlines(b.children, ctx)}

    ; + case "list": return renderList(b, n, ctx); case "table": return ( {b.rows.map((r, i) => ( - {r.map((c, j) => c.header ? : )} + {r.map((c, j) => c.header ? : )} ))}
    {renderInlines(c.children)}{renderInlines(c.children)}
    {renderInlines(c.children, ctx)}{renderInlines(c.children, ctx)}
    ); case "pre": return
    {b.text}
    ; - case "quote": return
    {renderBlocks(b.children)}
    ; + case "quote": return
    {renderBlocks(b.children, ctx)}
    ; case "hr": return
    ; } }); } -function renderList(list: ListBlock, key: number): ReactNode { +function renderList(list: ListBlock, key: number, ctx: Ctx): ReactNode { const L = list.ordered ? "ol" : "ul"; return ( - {list.items.map((it, i) =>
  • {renderInlines(it.children)}{it.lists.map(renderList)}
  • )} + {list.items.map((it, i) =>
  • {renderInlines(it.children, ctx)}{it.lists.map((l, k) => renderList(l, k, ctx))}
  • )}
    ); } -function renderInlines(nodes: Inline[]): ReactNode[] { +function renderInlines(nodes: Inline[], ctx: Ctx): ReactNode[] { return nodes.map((x, n) => { switch (x.t) { case "text": return x.text; case "br": return
    ; - case "strong": return {renderInlines(x.children)}; - case "em": return {renderInlines(x.children)}; - case "del": return {renderInlines(x.children)}; - case "ins": return {renderInlines(x.children)}; - case "sup": return {renderInlines(x.children)}; - case "sub": return {renderInlines(x.children)}; + case "strong": return {renderInlines(x.children, ctx)}; + case "em": return {renderInlines(x.children, ctx)}; + case "del": return {renderInlines(x.children, ctx)}; + case "ins": return {renderInlines(x.children, ctx)}; + case "sup": return {renderInlines(x.children, ctx)}; + case "sub": return {renderInlines(x.children, ctx)}; case "code": return {x.text}; - case "link": return {renderInlines(x.children)}; - case "issue": return {x.children.length ? renderInlines(x.children) : undefined}; + case "link": return {renderInlines(x.children, ctx)}; + case "issue": return {x.children.length ? renderInlines(x.children, ctx) : undefined}; + case "attachment": { + const view = ctx.attachmentView?.(x.id) ?? { busy: false }; + const label = <>{renderInlines(x.children, ctx)}; + return view.href + ? {label} + : ; + } case "mention": return @{x.login}; } }); diff --git a/web/src/issues/wiki/inline.test.ts b/web/src/issues/wiki/inline.test.ts index ad03b36..1073755 100644 --- a/web/src/issues/wiki/inline.test.ts +++ b/web/src/issues/wiki/inline.test.ts @@ -74,3 +74,46 @@ describe("parseInline: ключи задач", () => { }); it("без projectKey — текст", () => expect(parseInline("AURORA-1445")).toEqual([t("AURORA-1445")])); }); + +describe("parseInline: ссылки на вложения", () => { + const atts = [ + { id: "31337", filename: "скрин 1.png", created: "2026-10-15T12:00:00.000+0300" }, + { id: "31338", filename: "a.pdf", created: null }, + { id: "10", filename: "дубль.png", created: "2026-10-14T12:00:00.000+0300" }, + { id: "20", filename: "дубль.png", created: "2026-10-20T12:00:00.000+0300" }, + ]; + const A = { attachments: atts }; + const att = (id: string, ...children: Inline[]): Inline => ({ t: "attachment", id, children }); + + it("!имя|параметры! и !имя!", () => { + expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]); + expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]); + }); + it("[^имя] и [текст|^имя]", () => { + expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]); + expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]); + }); + it("имя точно (с регистром); несколько с одним именем — самое новое", () => { + expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]); + expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]); + }); + it("имени нет среди вложений — исходник как есть", () => { + expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]); + expect(parseInline("[^нет.pdf] и [x|^нет.pdf]", A)).toEqual([t("[^нет.pdf] и [x|^нет.pdf]")]); + }); + it("одиночный «!» перед ссылкой не мешает ей (I-1)", () => { + expect(parseInline("Внимание! см. !a.pdf!", A)).toContainEqual(att("31338", t("a.pdf"))); + const r = parseInline("Важно! [^a.pdf] и !a.pdf!", A); + expect(r.filter((n) => n.t === "attachment")).toEqual([att("31338", t("a.pdf")), att("31338", t("a.pdf"))]); + }); + it("внешняя картинка не вложение", () => { + expect(JSON.stringify(parseInline("!http://x/скрин 1.png!", { attachments: [{ id: "1", filename: "http://x/скрин 1.png", created: null }] }))).not.toContain("attachment"); + }); + it("без опции attachments — как раньше", () => { + expect(parseInline("!скрин 1.png!")).toEqual([t("!скрин 1.png!")]); + expect(parseInline("[^a.pdf]")).toEqual([t("[^a.pdf]")]); + }); + it("внутри подписи ссылки вложения нет (кнопка в ссылке недопустима)", () => { + expect(parseInline("[!a.pdf!|https://x.y]", A)).toEqual([link("https://x.y", t("!a.pdf!"))]); + }); +}); diff --git a/web/src/issues/wiki/inline.ts b/web/src/issues/wiki/inline.ts index 94873d7..abfb5a8 100644 --- a/web/src/issues/wiki/inline.ts +++ b/web/src/issues/wiki/inline.ts @@ -1,7 +1,9 @@ // Строчная вики-разметка Jira (design wiki-render #4, #5): выделение, {{моно}}, ссылки, упоминания, ключи задач. // Вход — одна строка без переводов строк; разбор не бросает исключений, нераспознанное остаётся текстом как есть. -export interface WikiOptions { projectKey?: string } +// вложения задачи для разбора ссылок `!имя!`, `[^имя]` (как у хаба: имя точно, при повторе имени — самое новое) +export interface WikiAttachment { id: string; filename: string; created: string | null } +export interface WikiOptions { projectKey?: string; attachments?: WikiAttachment[] } type Mark = "strong" | "em" | "del" | "ins" | "sup" | "sub"; @@ -11,6 +13,7 @@ export type Inline = | { t: "code"; text: string } | { t: "link"; href: string; children: Inline[] } | { t: "issue"; key: string; children: Inline[] } + | { t: "attachment"; id: string; children: Inline[] } | { t: "mention"; login: string } | { t: "br" }; @@ -20,6 +23,8 @@ const WORD = /[\p{L}\p{N}]/u; const SPACE = /\s/; const URL = /https?:\/\/[^\s\]|<>"]+/y; const URL_TAIL = /[.,;:!?)]+$/; +const IMAGE = /!([^!|\n]+)(?:\|[^!\n]*)?!/y; +const ATT_LINK = /^(?:([^|]*)\|)?\^(.+)$/; // схема ссылки — только http, https, mailto (спека «Разметка Jira в карточке»); иначе ссылки нет function safeHref(raw: string): string | null { @@ -27,6 +32,18 @@ function safeHref(raw: string): string | null { return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null; } +// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id +function findAttachment(opts: WikiOptions, name: string): string | null { + const same = (opts.attachments ?? []).filter((a) => a.filename === name); + if (!same.length) return null; + const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0]; + return same.reduce((best, a) => { + const [bt, bi] = rank(best); + const [at, ai] = rank(a); + return at > bt || (at === bt && ai > bi) ? a : best; + }).id; +} + function keyPattern(projectKey: string | undefined): RegExp | null { if (!projectKey) return null; const escaped = projectKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -71,6 +88,13 @@ export function parseInline(line: string, opts: WikiOptions = {}, label = false) text(c); i += 1; continue; } + if (c === "!" && !label && opts.attachments?.length) { + IMAGE.lastIndex = i; + const m = IMAGE.exec(line); + const id = m && !m[1].includes("://") ? findAttachment(opts, m[1]) : null; + if (m && id) { out.push({ t: "attachment", id, children: [{ t: "text", text: m[1] }] }); i += m[0].length; continue; } + } + if (c === "h" && !label && prevOk(i)) { URL.lastIndex = i; const m = URL.exec(line); @@ -126,6 +150,11 @@ function bracket(body: string, opts: WikiOptions, isKey: (s: string) => boolean) const login = body.slice(1).trim(); return login && !SPACE.test(login) ? { t: "mention", login } : null; } + const ref = ATT_LINK.exec(body); + if (ref) { + const id = findAttachment(opts, ref[2]); + return id ? { t: "attachment", id, children: ref[1] ? parseInline(ref[1], opts, true) : [{ t: "text", text: ref[2] }] } : null; + } const bar = body.indexOf("|"); const label = bar >= 0 ? body.slice(0, bar) : ""; const target = (bar >= 0 ? body.slice(bar + 1) : body).trim(); diff --git a/web/src/ui/icons.tsx b/web/src/ui/icons.tsx index 252d6b9..81b232a 100644 --- a/web/src/ui/icons.tsx +++ b/web/src/ui/icons.tsx @@ -37,3 +37,8 @@ export const IconList = make(<>); export const IconHistory = make(<>); export const IconSidebar = make(<>); +export const IconPaperclip = make(<>); +export const IconDownload = make(<>); +export const IconFile = make(<>); +export const IconImage = make(<>); +export const IconSheet = make(<>);