186 lines
28 KiB
Markdown
186 lines
28 KiB
Markdown
# Вложения из 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)`.
|