OpenSpec: change attachment-fetch — вложения из Jira по запросу (#83)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
da8f5c9fde
commit
54d87225bd
2
openspec/changes/attachment-fetch/.openspec.yaml
Normal file
2
openspec/changes/attachment-fetch/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
150
openspec/changes/attachment-fetch/design.md
Normal file
150
openspec/changes/attachment-fetch/design.md
Normal file
@ -0,0 +1,150 @@
|
||||
## 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`. Цена ошибки: нет.
|
||||
49
openspec/changes/attachment-fetch/proposal.md
Normal file
49
openspec/changes/attachment-fetch/proposal.md
Normal 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) или старый хаб ломают синхронизацию.
|
||||
71
openspec/changes/attachment-fetch/specs/agent/spec.md
Normal file
71
openspec/changes/attachment-fetch/specs/agent/spec.md
Normal 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` нет содержимого файла
|
||||
241
openspec/changes/attachment-fetch/specs/commands/spec.md
Normal file
241
openspec/changes/attachment-fetch/specs/commands/spec.md
Normal 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»
|
||||
141
openspec/changes/attachment-fetch/specs/data-model/spec.md
Normal file
141
openspec/changes/attachment-fetch/specs/data-model/spec.md
Normal 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`
|
||||
291
openspec/changes/attachment-fetch/specs/hub-api/spec.md
Normal file
291
openspec/changes/attachment-fetch/specs/hub-api/spec.md
Normal 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"]`
|
||||
144
openspec/changes/attachment-fetch/specs/mcp/spec.md
Normal file
144
openspec/changes/attachment-fetch/specs/mcp/spec.md
Normal 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»
|
||||
180
openspec/changes/attachment-fetch/specs/web-app/spec.md
Normal file
180
openspec/changes/attachment-fetch/specs/web-app/spec.md
Normal 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** под вложением этот текст и «Повторить»
|
||||
Loading…
x
Reference in New Issue
Block a user