Вложения из Jira по запросу: список при синхронизации, скачивание в вебе и MCP (#83) #84

Merged
aep merged 17 commits from feat/83-attachment-fetch into main 2026-10-22 15:05:52 +03:00
47 changed files with 3481 additions and 97 deletions

View File

@ -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/<sha256>` (каталог — параметр, `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/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ, кроме `allow_empty` для файла от агента), `touch` (mtime при выдаче содержимого), `sweep_downloads` (каталоги копий MCP `downloads/<id>/`, старше 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/<id>/<имя>` (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/<sha256>` → `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/<id>/<имя>`.
- Журнал изменений: выдача `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` и своих копий не держит.

View File

@ -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 `<id>/<имя>`, уборка 30 дней); каталог данных, `backups/`, `release/`, `files/` и `downloads/` — права 0700. Порт по умолчанию 8790.
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
## Архитектура

View File

@ -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 с.
- Заголовок пакета: `Скачать вложение <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)`.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22

View File

@ -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/<sha256>`, `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. **Адрес содержимого агент собирает сам** — `<base_url>/secure/attachment/<id>/<имя>`, а не берёт `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/<id>/<имя>`.** Файл хранилища без имени и
расширения Claude не откроет (анти-цель). Каталог по id — нет конфликтов имён; уборка `downloads/` — тем же `tick`
хаба (30 дней). MCP читает файл через `GET /attachments/{id}/content` (граница «MCP — только через `client`»), а не
из `files/` напрямую.
12. **MCP `attachment` ждёт сам** (опрос раз в 2 с, по умолчанию до 120 с, не больше 600): обычно агент успевает за
секунды; не успел — понятный ответ, повторный вызов не ставит вторую команду.
13. **Веб скачивает через `Blob`.** Маршруты хаба требуют токен в заголовке, ссылка `<a href>` его не передаст;
`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/<id>/<имя>` — выяснится на живой
проверке, поправить сборку адреса.
5. 2026-10-22 — **копия для Claude — `<данные хаба>/downloads/<id>/<имя>`, уборка 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` (нет файла, чужой размер) дают один текст «хаб: файл
<sha> не найден в хранилище».** Обоснование: одна проверка размера на постановку из любого входа; 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-<id>`.** Обоснование: без этого «..» (разделители уже заменены) остаётся именем файла
в каталоге копий; укрепление против обхода пути. Цена ошибки: нет. (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.)

View File

@ -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/<id>/`.
- **Веб.** В карточке раздел «Вложения (N)», плашки файлов под комментарием, ссылки на вложения в отрендеренном тексте —
все скачивают файл одной кнопкой; «Загружается…», пока агент везёт файл.
## Non-Goals
- Превью картинок в карточке и показ `!картинка!` картинкой.
- Скачивание файлов больше 10 МБ — только ссылка на Jira.
- Автоматическая подкачка файлов (заранее, при синхронизации, по подписке).
- Удаление и переименование вложений в Jira из моста.
- Привязка файла к комментарию по автору и времени — только по ссылке в тексте.
## Anti-Goals
- Синхронизация стала тяжелее: при `refresh` агент качает содержимое вложений или делает лишние вызовы Jira на задачу.
- Учётные данные Jira уходят на адрес не из `base_url` агента (адрес `content` из ответа Jira).
- Повторные нажатия и параллельные запросы из веба и MCP ставят несколько одинаковых скачиваний.
- Claude получает файл без имени и расширения и не может его открыть.
- Старый агент (≤ v13) или старый хаб ломают синхронизацию.

View File

@ -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` нет содержимого файла

View File

@ -0,0 +1,241 @@
## MODIFIED Requirements
### Requirement: Команда — желаемый результат на одной задаче
Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить →
записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done`
без записи. `fetch_attachment` в Jira не пишет: она читает файл вложения и передаёт его хабу; повтор безопасен. Типы команд и их контракт:
| Тип | Параметры | Что читает | Что пишет |
|---|---|---|---|
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = <proj> AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.<issue_type>` (для эпика — с `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: {"<id>": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал |
| `fetch_attachment` | `key`, `attachment_id` (id вложения Jira, строка из цифр) | `GET /issue/{key}?fields=attachment` (имя и размер вложения) | ничего в Jira; содержимое — `GET <base_url>/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: <sha>, 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 «<key> не в проекте 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`),
заголовок «Скачать вложение <filename> (<key>)»; через `POST /batches` её MAY поставить и клиент, с теми же проверками.
При постановке хаб SHALL проверять: `attachment_id` — непустая строка из цифр; вложение с этим id есть в зеркале у задачи
`key` (иначе 422 «у <key> нет вложения #<id> в зеркале — обновите задачу»); его размер в зеркале не больше 10 МБ
(10 485 760 байт) — иначе 413 `too_large` «файл больше 10 МБ (<размер>) — скачайте в Jira»; размер неизвестен (`NULL`) —
ставится, предел проверяет агент. Агент SHALL выполнять её так:
1. `GET /issue/{key}?fields=attachment`; вложения с этим id нет — `failed` «вложение #<id> удалено из <key> в Jira»;
2. размер по Jira больше 10 МБ — `failed` «файл больше 10 МБ (<размер>) — скачайте в Jira», содержимое не качается;
размер неизвестен — качается, но чтение ответа SHALL обрываться на 10 485 761-м байте с тем же `failed`;
3. содержимое — `GET` по адресу, собранному из `base_url` агента: `<base_url>/secure/attachment/<id>/<имя в 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** агент читает задачу, качает `<base_url>/secure/attachment/31337/%D1%81%D0%BA%D1%80%D0%B8%D0%BD%201.png`,
отправляет содержимое хабу; отчёт `done {attachment_id: "31337", file: <sha256>, 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 <base_url>/secure/attachment/31337/…` ответила 302 на `https://files.example/…`
- **THEN** агент туда не ходит, команда `failed` «Jira перенаправила скачивание на files.example»

View File

@ -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 = <M>` или `… labels in (<M1>, …)` и завести проекты с этими метками (название проекта
`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`

View File

@ -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/<sha>`, файл загружен
- **THEN** 200 `{file: <sha>, size, content}`, sha256 от декодированного `content` равен `<sha>`; клиентским токеном — 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/<attachment_id>/`
(копии MCP, см. `mcp`) старше 30 дней по времени изменения; повтор команды
после уборки отвергается при постановке 422 «файл <sha> не загружен: 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/<дата-время>-<batch>.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<N> не умеет
скачивать вложения — нужен 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` «хаб: файл <file> не найден в хранилище» и `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: <sha>, size: 100}`, а файла `<sha>` в `files/` нет
- **THEN** команда `failed` «хаб: файл <sha> не найден в хранилище»; `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"]`

View File

@ -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 последних (номер «#<comment_id>», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: #<id> <имя>, …»; затем вложения (см. «Вложения в MCP») |
| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка «<key> #<id> <автор>, создан <created>» (и «, изменён <updated>», если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у <key> нет комментария #<id>»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «#<id> <имя> (<размер>)» |
| `attachment` | `key`, `attachment` (номер вложения «#<id>» или «<id>», иначе — точное имя файла), `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<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `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: <sha>, filename: "ОСВ сч. 60 за 2 кв.
2026.xlsx"}` и хвостовым `refresh`
#### Scenario: Файла нет
- **WHEN** тот же вызов, файла по `path` нет
- **THEN** ответ «файл не найден: <path>», пакет не создан, в хаб ничего не загружено
#### 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 последних комментариев с номерами «#<comment_id>» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2»
#### Scenario: Комментарий целиком
- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}`
- **THEN** ответ — «AURORA-9001 #101 <автор>, создан <created>» и полный текст комментария без обрезки, переносы строк сохранены
#### Scenario: Нет такого комментария
- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999
- **THEN** ответ «у AURORA-9001 нет комментария #999»
## ADDED Requirements
### Requirement: Вложения в MCP
`issue` SHALL после комментариев выводить «вложения (N):» и по строке на каждое вложение (новые сверху): «#<id> <имя>
(<размер>) — <автор>, <created>», дальше через запятую: «в описании», если на вложение ссылается описание, «в #<comment_id>»
для каждого ссылающегося комментария, «скачано», если `state = ready`, «больше 10 МБ — только в Jira» для таких файлов;
без вложений — «вложений нет». Размер — в байтах, КБ или МБ с одним знаком после запятой («512 Б», «234,4 КБ», «2,3 МБ»);
неизвестный — «размер неизвестен».
`attachment` SHALL:
1. найти вложение по `GET /issues/{key}`: по номеру, иначе по точному имени (несколько с одним именем — самое новое, в
ответ строка «у <key> N вложений «<имя>», взято самое новое #<id>»); не найдено — «у <key> нет вложения «<значение>»;
список — issue <key>», в хаб больше не ходить;
2. `POST /attachments/{id}/fetch` с автором `claude`; 413 — «файл больше 10 МБ (<размер>) — скачайте в Jira:
задача <key>, вложение «<имя>»»; 409 и прочие ошибки хаба — их текст;
3. пока `state = fetching` — опрашивать `GET /attachments/{id}` раз в 2 с, не дольше `wait_seconds`; не дождался —
«скачивание ещё идёт (команда #<command_id>, агент <онлайн/офлайн>); вызовите attachment ещё раз — вторая команда не
поставится»; `failed` — «не удалось скачать: <error>»;
4. при `ready` — `GET /attachments/{id}/content` и записать файл в `<каталог данных хаба>/downloads/<id>/<имя>`, где имя —
`filename` с заменой `/`, `\`, `:` и управляющих символов на `_` (пустое — `attachment-<id>`); каталог с правами
`0700`; запись через временный файл и переименование; если там уже лежит файл того же размера — не перезаписывать;
5. ответить «<абсолютный путь>» отдельной строкой и под ним «<имя> (<размер>), <mime_type>, задача <key>».
#### 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»

View File

@ -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/<id>/<имя в 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** под вложением этот текст и «Повторить»

View File

@ -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 = ("<!doctype html><meta charset=\"utf-8\"><title>Хаб Jira</title><p>Веб-приложение не собрано: выполните "
"<code>make web</code> в каталоге jira-hub и перезапустите хаб.</p>").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"),

View File

@ -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)}

View File

@ -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"))

View File

@ -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:

View File

@ -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), данных переносить не нужно
}

View File

@ -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 <root>/<attachment_id>/, в которых ничего не менялось 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 уходят

View File

@ -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-<id>."""
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/<id>/<имя>: каталоги 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:

View File

@ -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

View File

@ -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))

View File

@ -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:

View File

@ -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()

94
tests/test_attachments.py Normal file
View File

@ -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

View File

@ -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"]

View File

@ -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()

View File

@ -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 / "нет") == []

View File

@ -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: папка «освежена» — уборка её не заденет

View File

@ -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"]

View File

@ -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

View File

@ -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})

View File

@ -67,4 +67,16 @@ describe("api", () => {
expect((fetch.mock.calls[0][1]!.headers as Record<string, string>).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<string, string>).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);
});
});

View File

@ -40,11 +40,12 @@ function withParams(path: string, params?: Params): string {
return s ? `${path}?${s}` : path;
}
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
// общая часть запросов: токен, сеть, 401/5xx/4xx; успешный Response возвращается нетронутым
async function send(method: string, path: string, accept: string, body?: unknown, token = getToken()): Promise<Response> {
if (!token) throw new AuthError("нет токена", token);
// токен с недопустимыми символами (например, оставленный прежней доской) — сразу на экран входа, а не «хаб не отвечает»
if (!TOKEN_RE.test(token)) throw new AuthError("недопустимые символы в токене", token);
const headers: Record<string, string> = { Authorization: "Bearer " + token, Accept: "application/json" };
const headers: Record<string, string> = { 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<T>(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));
}
}
async function call<T>(method: string, path: string, body?: unknown, token = getToken()): Promise<T> {
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: <T>(path: string, params?: Params) => call<T>("GET", withParams(path, params)),
post: <T>(path: string, body: unknown = {}) => call<T>("POST", path, body),
put: <T>(path: string, body: unknown) => call<T>("PUT", path, body),
// тело ответа как есть (содержимое вложения): те же токен и ошибки, JSON не разбирается
blob: async (path: string) => (await send("GET", path, "*/*")).blob(),
};
// проверка токена до сохранения: экран входа

View File

@ -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) поля не шлёт
}

View File

@ -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> = {}): 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<IssueDetail>, 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);
});
});

View File

@ -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 = <IconButton label="Закрыть" icon={<IconClose />} onClick={onClose} />;
if (issue.isPending) {
return <div className="issue" data-testid="issue-card"><div className="issue-head"><div className="row1"><span className="key">{key}</span><span className="spacer" />{close}</div><p className="hint">загрузка {key}…</p></div></div>;
@ -49,6 +54,8 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
i.sprint && <span key="sprint">{i.sprint}</span>,
i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
].filter(Boolean);
const atts = i.attachments;
const wiki = wikiAttachments(atts, dl);
return (
<div className="issue" data-testid="issue-card">
<header className="issue-head" data-testid="issue-head">
@ -68,8 +75,9 @@ export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClos
<div className="cols">
<div className="main">
<Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} />}
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} />
{i.description && <Description text={i.description} projectKey={settings.data?.project_key} wiki={wiki} />}
{atts && atts.length > 0 && <AttachmentList items={atts} dl={dl} />}
<Comments items={i.comments ?? []} projectKey={settings.data?.project_key} wiki={wiki} atts={atts ?? []} dl={dl} />
{i.changes && <History items={i.changes} />}
</div>
<Panel className="side">
@ -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<string, string> }) {
const groups = groupLinks(issue, linkTypes);
return (
@ -113,7 +137,7 @@ function SourceToggle({ source, onToggle }: { source: boolean; onToggle: () => v
return <Button variant="ghost" aria-pressed={source} onClick={onToggle}>{source ? "текст" : "исходник"}</Button>;
}
function Description({ text, projectKey }: { text: string; projectKey?: string }) {
function Description({ text, projectKey, wiki }: { text: string; projectKey?: string; wiki: WikiAtt }) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const [source, setSource] = useState(false);
@ -141,13 +165,13 @@ function Description({ text, projectKey }: { text: string; projectKey?: string }
</div>
{source
? <pre ref={ref as RefObject<HTMLPreElement>} className={cls}>{text}</pre>
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} /></div>}
: <div ref={ref as RefObject<HTMLDivElement>} className={cls}><WikiText text={text} projectKey={projectKey} {...wiki} /></div>}
{(long || open) && <Button variant="ghost" className="more" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</Button>}
</section>
);
}
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 (
<li>
@ -157,12 +181,13 @@ function Comment({ c, projectKey }: { c: IssueComment; projectKey?: string }) {
</div>
{source
? <pre className="body">{c.body}</pre>
: <div className="body"><WikiText text={c.body} projectKey={projectKey} /></div>}
: <div className="body"><WikiText text={c.body} projectKey={projectKey} {...wiki} /></div>}
<AttachmentChips items={(c.attachment_ids ?? []).flatMap((id) => atts.filter((a) => a.attachment_id === id))} dl={dl} />
</li>
);
}
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 (
<section className="comments" data-testid="comments">
@ -173,7 +198,7 @@ function Comments({ items, projectKey }: { items: IssueComment[]; projectKey?: s
{!items.length && <p className="hint">комментариев нет</p>}
{items.length > 0 && !hidden && (
<ul>
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} />)}
{items.map((c) => <Comment key={c.comment_id} c={c} projectKey={projectKey} wiki={wiki} atts={atts} dl={dl} />)}
</ul>
)}
</section>

View File

@ -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 (
<ul className="att-chips">
{items.map((a) => {
const text = `${a.filename} · ${fmtSize(a.size)}`;
if (isTooBig(a.size)) {
return (
<li key={a.attachment_id}>
<a className="att-chip" href={jiraAttachmentUrl(a.attachment_id, a.filename)} target="_blank" rel="noopener noreferrer" title={TOO_BIG_HINT}>
<IconExternal size={16} />{text}
</a>
</li>
);
}
const { busy } = dl.status(a.attachment_id);
return (
<li key={a.attachment_id}>
<button type="button" className="att-chip" disabled={busy} onClick={() => dl.start(a)}>
<IconPaperclip size={16} />{text}{busy && <span className="att-busy"> Загружается…</span>}
</button>
</li>
);
})}
</ul>
);
}

View File

@ -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<typeof useAttachmentDownload>;
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 (
<section className="attachments" data-testid="attachments">
<div className="sec-head"><h3>Вложения ({items.length})</h3></div>
<ul className="att-list">
{newestFirst(items).map((a) => <Row key={a.attachment_id} a={a} dl={dl} />)}
</ul>
</section>
);
}
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 (
<li className="att-row">
<span className="att-icon" role="img" aria-label={KIND_LABEL[kind]}><Icon /></span>
<div className="att-main">
<span className="att-name">{a.filename}</span>
<span className="att-meta">
{fmtSize(a.size)}{who && ` · ${who}`}
{a.state === "ready" && <>{" "}<Badge tone="success">у хаба</Badge></>}
</span>
{error && <p className="att-error" role="alert">{error}</p>}
</div>
{isTooBig(a.size)
? <a className="ui-btn ui-btn--secondary ui-btn--md" href={jiraAttachmentUrl(a.attachment_id, a.filename)} target="_blank" rel="noopener noreferrer" title={TOO_BIG_HINT}><IconExternal size={16} />Открыть в Jira</a>
: <Button icon={busy ? undefined : <IconDownload size={16} />} loading={busy} onClick={() => dl.start(a)}>
{busy ? "Загружается…" : error ? "Повторить" : "Скачать"}
</Button>}
</li>
);
}

View File

@ -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))}
}

View File

@ -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");
});
});

View File

@ -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";
}

View File

@ -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 }) => <QueryClientProvider client={makeQueryClient()}>{children}</QueryClientProvider>;
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 });
});
});

View File

@ -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<Record<string, DownloadStatus>>({});
const mapRef = useRef(map);
const gen = useRef(0);
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
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<void> => {
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<AttachmentState>(`/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<AttachmentState>(`/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 };
}

View File

@ -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 <Fallback text={text}><Rendered text={text} projectKey={projectKey} /></Fallback>;
export function WikiText(props: Props) {
return <Fallback text={props.text}><Rendered {...props} /></Fallback>;
}
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 <div className="wiki">{renderBlocks(parseWiki(text, { projectKey }))}</div>;
// то, что нужно рендеру строки, кроме текста: ссылки на вложения
interface Ctx { onAttachment?: (id: string) => void; attachmentView?: (id: string) => AttachmentView }
function Rendered({ text, projectKey, attachments, onAttachment, attachmentView }: Props) {
const ctx: Ctx = { onAttachment, attachmentView };
return <div className="wiki">{renderBlocks(parseWiki(text, { projectKey, attachments }), ctx)}</div>;
}
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 <H key={n}>{renderInlines(b.children)}</H>; }
case "p": return <p key={n}>{renderInlines(b.children)}</p>;
case "list": return renderList(b, n);
case "heading": { const H = `h${b.level}` as const; return <H key={n}>{renderInlines(b.children, ctx)}</H>; }
case "p": return <p key={n}>{renderInlines(b.children, ctx)}</p>;
case "list": return renderList(b, n, ctx);
case "table":
return (
<table key={n}><tbody>
{b.rows.map((r, i) => (
<tr key={i}>{r.map((c, j) => c.header ? <th key={j}>{renderInlines(c.children)}</th> : <td key={j}>{renderInlines(c.children)}</td>)}</tr>
<tr key={i}>{r.map((c, j) => c.header ? <th key={j}>{renderInlines(c.children, ctx)}</th> : <td key={j}>{renderInlines(c.children, ctx)}</td>)}</tr>
))}
</tbody></table>
);
case "pre": return <pre key={n}>{b.text}</pre>;
case "quote": return <blockquote key={n}>{renderBlocks(b.children)}</blockquote>;
case "quote": return <blockquote key={n}>{renderBlocks(b.children, ctx)}</blockquote>;
case "hr": return <hr key={n} />;
}
});
}
function renderList(list: ListBlock, key: number): ReactNode {
function renderList(list: ListBlock, key: number, ctx: Ctx): ReactNode {
const L = list.ordered ? "ol" : "ul";
return (
<L key={key}>
{list.items.map((it, i) => <li key={i}>{renderInlines(it.children)}{it.lists.map(renderList)}</li>)}
{list.items.map((it, i) => <li key={i}>{renderInlines(it.children, ctx)}{it.lists.map((l, k) => renderList(l, k, ctx))}</li>)}
</L>
);
}
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 <br key={n} />;
case "strong": return <strong key={n}>{renderInlines(x.children)}</strong>;
case "em": return <em key={n}>{renderInlines(x.children)}</em>;
case "del": return <del key={n}>{renderInlines(x.children)}</del>;
case "ins": return <ins key={n}>{renderInlines(x.children)}</ins>;
case "sup": return <sup key={n}>{renderInlines(x.children)}</sup>;
case "sub": return <sub key={n}>{renderInlines(x.children)}</sub>;
case "strong": return <strong key={n}>{renderInlines(x.children, ctx)}</strong>;
case "em": return <em key={n}>{renderInlines(x.children, ctx)}</em>;
case "del": return <del key={n}>{renderInlines(x.children, ctx)}</del>;
case "ins": return <ins key={n}>{renderInlines(x.children, ctx)}</ins>;
case "sup": return <sup key={n}>{renderInlines(x.children, ctx)}</sup>;
case "sub": return <sub key={n}>{renderInlines(x.children, ctx)}</sub>;
case "code": return <code key={n}>{x.text}</code>;
case "link": return <a key={n} href={x.href} target="_blank" rel="noopener noreferrer">{renderInlines(x.children)}</a>;
case "issue": return <IssueLink key={n} issueKey={x.key}>{x.children.length ? renderInlines(x.children) : undefined}</IssueLink>;
case "link": return <a key={n} href={x.href} target="_blank" rel="noopener noreferrer">{renderInlines(x.children, ctx)}</a>;
case "issue": return <IssueLink key={n} issueKey={x.key}>{x.children.length ? renderInlines(x.children, ctx) : undefined}</IssueLink>;
case "attachment": {
const view = ctx.attachmentView?.(x.id) ?? { busy: false };
const label = <><IconPaperclip size={16} />{renderInlines(x.children, ctx)}</>;
return view.href
? <a key={n} className="att-ref" href={view.href} target="_blank" rel="noopener noreferrer" title={TOO_BIG_HINT}>{label}</a>
: <button key={n} type="button" className="att-ref" disabled={view.busy} onClick={() => ctx.onAttachment?.(x.id)}>
{label}{view.busy && <span className="att-busy"> Загружается…</span>}
</button>;
}
case "mention": return <span key={n} className="mention">@{x.login}</span>;
}
});

View File

@ -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!"))]);
});
});

View File

@ -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();

View File

@ -37,3 +37,8 @@ export const IconList = make(<><path d="M9 6h11M9 12h11M9 18h11"/><path d="M4 6h
export const IconMenu = make(<><path d="M4 6h16M4 12h16M4 18h16"/></>);
export const IconHistory = make(<><path d="M4 12a8 8 0 1 0 2.3-5.7"/><path d="M4 4v4h4"/><path d="M12 8v4l3 2"/></>);
export const IconSidebar = make(<><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></>);
export const IconPaperclip = make(<><path d="M20 11.5l-7.8 7.8a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/></>);
export const IconDownload = make(<><path d="M12 4v12"/><path d="M7 11l5 5 5-5"/><path d="M4 20h16"/></>);
export const IconFile = make(<><path d="M14 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7z"/><path d="M14 3v4h4"/></>);
export const IconImage = make(<><rect x="3.5" y="5" width="17" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M4 17l5-4.5 3.5 3 3-2.5 4.5 4"/></>);
export const IconSheet = make(<><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 10h16M4 15h16M10 4v16"/></>);