sdlc-hub/docs/superpowers/plans/2026-10-22-attachment-fetch-hub.md
Andrey Epifancev acabda547d План: вложения из Jira по запросу, хаб (#83)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:05:21 +04:00

186 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Вложения из 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 с.
- Заголовок пакета: `Скачать вложение <filename> (<key>)`; `batch.project = NULL`; без хвостового `refresh`.
- Состояния вложения: `ready`, `fetching`, `failed`, `none`.
- Коды ошибок API: 404 `no_attachment`, 413 `too_large`, 409 `agent_too_old`, 404 `not_fetched`.
- Тексты (точно): «у <key> нет вложения #<id> в зеркале — обновите задачу»; «файл больше 10 МБ (<размер>) — скачайте в Jira»; «агент v<N> не умеет скачивать вложения — нужен v14»; «хаб: файл <file> не найден в хранилище».
- Формат размера (`attachments.fmt_size`): `< 1024` — «N Б»; `< 1024²` — КБ, `≥` — МБ, один знак после запятой, запятая как разделитель («234,4 КБ», «23,8 МБ»); `None` — «размер неизвестен».
- Каталог копий MCP: `<каталог данных>/downloads/<attachment_id>/<имя>` (каталог данных — `paths.db_path().parent`; у `Hub` — `db_path.parent`).
- Тексты интерфейсов — по-русски; комментарии в коде — по-русски, плотность как в окружающем коде.
- Каждый коммит — с `(#83)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Зелёный — весь набор: `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<n>.<k>`.
- `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 «<key>: attachments[<n>].<поле> — …», как `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` «хаб: файл <sha> не найден в хранилище», строки нет); `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-<id>`); `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` (файл записан в `<tmp>/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<Blob>` (тот же токен и ошибки, что `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` → `<a download=filename>` → `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)`.