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

28 KiB
Raw Blame History

Вложения из 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.
  • Тексты (точно): «у нет вложения # в зеркале — обновите задачу»; «файл больше 10 МБ (<размер>) — скачайте в Jira»; «агент v не умеет скачивать вложения — нужен v14»; «хаб: файл не найден в хранилище».
  • Формат размера (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 «: 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-<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).