28 KiB
Вложения из 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, 413too_large, 409agent_too_old, 404not_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
- Два одновременных
POST /attachments/{id}/fetch(веб + MCP) — одна команда; второй ответfetchingс тем жеcommand_id(тест в Task 3: два потока на одной базе). - Имя вложения с кириллицей, пробелами,
:,/—Content-Dispositionсfilename*=UTF-8''…корректен, копия MCP с заменой/ \ :и управляющих на_(тесты в Task 4 и Task 5). - Отчёт
doneпоfetch_attachmentпосле уборки файла или с чужим размером —failed,attachment_fileне тронута (тест в Task 3). - Повторная
fetch_attachmentпослеfailed— новый пакет; послеdoneи уборки файла — новый пакет (тест в Task 3). - Ссылка
!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(422bad_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; пустой → 200size: 0; 11 МБ → 413);test_attachment_get_404;test_attachment_fetch_flow(POST fetch →fetching+ пакет; агентclaim→fetch_attachmentсpause_seconds2;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(404not_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).